diff --git a/apps/cli/README.md b/apps/cli/README.md index 57c07c325a..907cc8daef 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -122,11 +122,13 @@ Important areas: - `src/shared/runtime/` for TTY, stdin, browser, Ink, and process-control services - `src/next/auth/` for login-related services -The local stack commands use `@supabase/stack` for lifecycle, daemon transport, status, and logs. -That stack layer now has an explicit preparation phase, so foreground and detached `start` flows -can surface `Downloading` before normal runtime states. CLI-managed stacks use lazy service startup: -direct listeners and Realtime start with the stack, while HTTP services activate on first proxied -use. The package API itself keeps eager startup as its default. +The local stack commands use `@supabase/stack` for lifecycle, status, logs, and runtime operations. +Managed ownership uses stable loopback `GET /owner` and session-fenced `POST /stop`; same-version +runtime calls use Effect RPC over framed NDJSON at `POST /rpc`. That stack layer now has an explicit +preparation phase, so foreground and detached `start` flows can surface `Downloading` before normal +runtime states. CLI-managed stacks use lazy service startup: direct listeners and Realtime start +with the stack, while HTTP services activate on first proxied use. The package API itself keeps +eager startup as its default. Useful companion docs: diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts index 52dc71ceb3..8073bee193 100644 --- a/apps/cli/scripts/build-binary.integration.test.ts +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -7,6 +7,9 @@ import { fileURLToPath } from "node:url"; const fixturePath = fileURLToPath( new URL("../tests/fixtures/compiled-libpg-query.ts", import.meta.url), ); +const versionFixturePath = fileURLToPath( + new URL("../tests/fixtures/compiled-cli-version.ts", import.meta.url), +); const temporaryDirectories: string[] = []; afterEach(async () => { @@ -50,4 +53,46 @@ describe("compiled binary assets", () => { expect(probeExitCode, stderr).toBe(0); expect(stdout).toContain("libpg-query.wasm loaded"); }, 20_000); + + test("embeds the build version independently of the runtime environment", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-version-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "version-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } + + const build = Bun.spawn( + [ + bunExecutable, + "build", + versionFixturePath, + "--compile", + `--define=SUPABASE_CLI_VERSION=${JSON.stringify("7.8.9-beta.1")}`, + `--outfile=${executable}`, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = await Promise.all([ + build.exited, + new Response(build.stderr).text(), + ]); + expect(buildExitCode, buildStderr).toBe(0); + + const probe = Bun.spawn([executable], { + cwd: directory, + env: { SUPABASE_CLI_VERSION: "9.9.9" }, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = await Promise.all([ + probe.exited, + new Response(probe.stdout).text(), + new Response(probe.stderr).text(), + ]); + + expect(probeExitCode, stderr).toBe(0); + expect(stdout.trim()).toBe("7.8.9-beta.1"); + }, 20_000); }); diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 453a050ee5..024ee6b882 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -17,8 +17,17 @@ if (shell !== "next" && shell !== "legacy") { const entrypoint = `src/${shell}/main.ts`; const outfile = `dist/supabase-${shell}`; +const packageJson = JSON.parse( + await Bun.file(new URL("../package.json", import.meta.url)).text(), +) as { + version?: string; +}; +if (packageJson.version === undefined || packageJson.version.length === 0) { + throw new Error("CLI package version is required for a compiled build"); +} +const versionDefine = `--define=SUPABASE_CLI_VERSION=${JSON.stringify(packageJson.version)}`; const defineArg = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( await bundleServeMainTemplate(), )}`; -await $`bun build ${entrypoint} --compile ${defineArg} --outfile ${outfile}`; +await $`bun build ${entrypoint} --compile ${versionDefine} ${defineArg} --outfile ${outfile}`; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 6356e97128..b7a2057e48 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -148,7 +148,7 @@ async function buildTarget(target: (typeof TARGETS)[number]) { "--compile", "--minify", `--target=${target.bunTarget}`, - `--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, + `--define=SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, serveMainTemplateDefine, ...posthogBuildDefines, @@ -297,7 +297,7 @@ async function buildMuslBinaries() { "--compile", "--minify", `--target=${target.bunTarget}`, - `--define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, + `--define=SUPABASE_CLI_VERSION=${JSON.stringify(version)}`, `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, serveMainTemplateDefine, ...posthogBuildDefines, diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index d7fca8098a..1f3ad20e8f 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,4 +1,10 @@ -import { daemonLayer, resolveManagedStack, stopDaemon } from "@supabase/stack/effect"; +import { + connectLayer, + daemonLayer, + resolveManagedStack, + Stack, + stopDaemon, +} from "@supabase/stack/effect"; import { loadProjectConfig } from "@supabase/config"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; @@ -20,6 +26,7 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { printStackConnectionInfo, startStackWithProgress } from "../../../stack/stack.shared.ts"; import { BranchNotFoundError } from "../errors.ts"; +import { CLI_VERSION } from "../../../../shared/cli/version.ts"; export const switchBranch = Effect.fn("branches.switch")(function* (opts: { name: Option.Option; @@ -97,24 +104,6 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { return; } - yield* projectLinkState.setActiveBranch({ - ref: target.project_ref, - name: target.name, - is_default: target.is_default, - }); - - if (output.format !== "text") { - yield* output.success("Switched", { - branch: { - ref: target.project_ref, - name: target.name, - is_default: target.is_default, - }, - }); - } else { - yield* output.outro(`Switched to branch '${target.name}'.`); - } - // If a local stack is running, stop and restart it against the new branch. const stackCheck = yield* resolveManagedStack({ cacheRoot: cliConfig.supabaseHome, @@ -133,14 +122,41 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { if (Option.isSome(stackCheck) && stackCheck.value.lifecycle === "running") { const stackName = stackCheck.value.identity.name; - const stopping = yield* output.task("Stopping local stack..."); - yield* stopDaemon({ + // Branch switching restarts a running stack, but it is not authorized to + // restart an incompatible daemon. Capture the same-version RPC owner/session + // before stopping it so a mismatch leaves the old stack intact. + const existingLayer = yield* connectLayer({ + cliVersion: CLI_VERSION, cwd: runtimeInfo.cwd, cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, name: stackName, - }).pipe(Effect.tapError(() => stopping.fail())); - yield* stopping.clear(); + }).pipe( + Effect.map(Option.some), + // A running document without a live owner is stale. Continue into the + // normal stop path, which acquires ownership and records it stopped. + Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), + ); + + if (Option.isSome(existingLayer)) { + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* Stack; + const stopping = yield* output.task("Stopping local stack..."); + yield* stack.stop().pipe(Effect.tapError(() => stopping.fail())); + yield* stopping.clear(); + }).pipe(Effect.provide(existingLayer.value)), + ); + } else { + const stopping = yield* output.task("Stopping local stack..."); + yield* stopDaemon({ + cwd: runtimeInfo.cwd, + cacheRoot: cliConfig.supabaseHome, + projectDir: projectHome.projectRoot, + name: stackName, + }).pipe(Effect.tapError(() => stopping.fail())); + yield* stopping.clear(); + } // TODO: run `supabase pull` against the new branch before restarting the stack // so the local config reflects the branch's migrations and seed state. @@ -158,6 +174,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); const stackLayer = yield* daemonLayer({ + cliVersion: CLI_VERSION, cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, projectDir: projectHome.projectRoot, @@ -181,4 +198,22 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { ); } } + + yield* projectLinkState.setActiveBranch({ + ref: target.project_ref, + name: target.name, + is_default: target.is_default, + }); + + if (output.format !== "text") { + yield* output.success("Switched", { + branch: { + ref: target.project_ref, + name: target.name, + is_default: target.is_default, + }, + }); + } else { + yield* output.outro(`Switched to branch '${target.name}'.`); + } }); diff --git a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts index 2eeba2563d..e222ee6400 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient } from "@supabase/api/effect"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Predicate } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -11,6 +11,10 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { emptyEnv, mockOutput, mockProjectLinkState } from "../../../../../tests/helpers/mocks.ts"; import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; import { switchBranch } from "./switch.handler.ts"; +import { makeRunningStackFixture } from "../../../../../tests/helpers/running-stack.ts"; +import { controlTransportLayer } from "@supabase/stack/managed"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; // --------------------------------------------------------------------------- // Fixtures @@ -151,7 +155,7 @@ function setup( const api = mockPlatformApi(opts.branches ?? [MAIN_BRANCH, DEV_BRANCH], { status: opts.status, }); - const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer); + const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer, controlTransportLayer); return { out, layer, api }; } @@ -335,7 +339,13 @@ describe("branches switch handler", () => { const out = mockOutput({ format: "json" }); const linkState = mockProjectLinkState(DEFAULT_LINK_STATE); const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH], { status: 503 }); - const layer = Layer.mergeAll(emptyEnv(), out.layer, linkState, api.layer); + const layer = Layer.mergeAll( + emptyEnv(), + out.layer, + linkState, + api.layer, + controlTransportLayer, + ); yield* switchBranch({ name: Option.some("dev") }).pipe( withJsonErrorHandling, @@ -372,4 +382,90 @@ describe("branches switch handler", () => { ); }), ); + + it.live("does not stop an incompatible local stack before branch restart", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + }), + ).pipe( + Effect.flatMap((fixture) => { + const out = mockOutput(); + const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]); + let linkState = DEFAULT_LINK_STATE; + const linkStateLayer = Layer.succeed( + ProjectLinkState, + ProjectLinkState.of({ + load: Effect.sync(() => Option.some(linkState)), + save: (next) => Effect.sync(() => void (linkState = next)), + clear: Effect.void, + getActiveBranch: Effect.sync(() => Option.some(linkState.active_branch)), + setActiveBranch: (branch) => + Effect.sync(() => { + linkState = { ...linkState, active_branch: branch }; + }), + }), + ); + const layer = Layer.mergeAll(fixture.baseLayer, out.layer, linkStateLayer, api.layer); + return switchBranch({ name: Option.some("dev") }).pipe( + Effect.provide(layer), + Effect.exit, + Effect.andThen((exit) => + Effect.gen(function* () { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired"); + } + expect(api.requests).toHaveLength(1); + expect(linkState.active_branch.ref).toBe(MAIN_BRANCH.project_ref); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ + type: "outro", + message: expect.stringContaining("Switched to branch"), + }), + ); + expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe( + "running", + ); + }), + ), + Effect.ensuring(Effect.promise(() => fixture.dispose())), + ); + }), + ), + ); + + it.live("recovers a stale running document before restarting for the selected branch", () => + Effect.promise(() => makeRunningStackFixture()).pipe( + Effect.flatMap((fixture) => { + const out = mockOutput(); + const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(DEFAULT_LINK_STATE), + api.layer, + ); + return Effect.gen(function* () { + yield* Effect.promise(() => fixture.closeControlOwner()); + expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe("running"); + + // Stop before launching a real replacement daemon: malformed config + // makes the command fail immediately after stale-owner cleanup. + const configDir = join(fixture.projectRoot, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), "[invalid\n"); + + const exit = yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Predicate.isTagged(Cause.squash(exit.cause), "NoRunningStackError")).toBe(false); + } + expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe("stopped"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose()))); + }), + ), + ); }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 1649b9b5d8..d77829c913 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,7 +1,8 @@ import { connectLayer, daemonLayer, Stack, type EdgeRuntimeConfig } from "@supabase/stack/effect"; import { loadProjectConfig } from "@supabase/config"; -import { Duration, Effect, FileSystem, Layer, Option, Stream } from "effect"; +import { Context, Duration, Effect, FileSystem, Layer, Option, Stream } from "effect"; import { join } from "node:path"; +import { CLI_VERSION } from "../../../../shared/cli/version.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { projectLocalServiceVersionsLayer } from "../../../config/project-local-service-versions.layer.ts"; @@ -62,6 +63,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio servicePolicies: { "edge-runtime": "eager" as const }, }; const stackLayer = yield* daemonLayer({ + cliVersion: CLI_VERSION, cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, projectDir: projectHome.projectRoot, @@ -74,8 +76,9 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio ...stackConfig, portIntents: managedPortIntents(stackConfig, loadedProjectConfig ?? undefined), }); - yield* startStackWithProgress().pipe(Effect.provide(stackLayer)); - const stack = yield* Stack.pipe(Effect.provide(stackLayer)); + const context = yield* Layer.build(stackLayer); + const stack = Context.get(context, Stack); + yield* startStackWithProgress().pipe(Effect.provide(context)); return { stack, startedByCommand: true }; }); @@ -88,6 +91,7 @@ export const connectOrStartFunctionsDevStack = Effect.fnUntraced(function* ( const runtimeInfo = yield* RuntimeInfo; const existingLayer = yield* connectLayer({ + cliVersion: CLI_VERSION, cwd: runtimeInfo.cwd, cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, @@ -98,7 +102,8 @@ export const connectOrStartFunctionsDevStack = Effect.fnUntraced(function* ( ); if (Option.isSome(existingLayer)) { - const stack = yield* Stack.pipe(Effect.provide(existingLayer.value)); + const context = yield* Layer.build(existingLayer.value); + const stack = Context.get(context, Stack); return { stack, startedByCommand: false }; } diff --git a/apps/cli/src/next/commands/logs/logs.handler.ts b/apps/cli/src/next/commands/logs/logs.handler.ts index 74122361c1..090d46a513 100644 --- a/apps/cli/src/next/commands/logs/logs.handler.ts +++ b/apps/cli/src/next/commands/logs/logs.handler.ts @@ -1,10 +1,11 @@ import { connectLayer, Stack } from "@supabase/stack/effect"; -import { Effect, Stream } from "effect"; +import { Context, Effect, Layer, Stream } from "effect"; import { CliConfig } from "../../config/cli-config.service.ts"; import { ProjectHome } from "../../config/project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { LogsFlags } from "./logs.command.ts"; import { UnsupportedLogsOutputFormatError } from "./logs.errors.ts"; @@ -65,12 +66,14 @@ export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { } const layer = yield* connectLayer({ + cliVersion: CLI_VERSION, cwd: runtimeInfo.cwd, cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, name: flags.stack, }); - const stack = yield* Effect.provide(Stack, layer); + const context = yield* Layer.build(layer); + const stack = Context.get(context, Stack); const services = flags.service.length === 0 ? undefined : flags.service; const history = flags.tail > 0 ? yield* stack.logHistoryAll(flags.tail, services) : []; const historyStream = Stream.fromIterable(history).pipe( diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index 68bc81790a..d47ab9481b 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { logs } from "./logs.handler.ts"; import { mockOutput, @@ -10,6 +10,46 @@ import { import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; describe("logs handler", () => { + it.live("fails with an actionable upgrade error without restarting an incompatible owner", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + }), + ).pipe( + Effect.flatMap((fixture) => { + const out = mockOutput(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + processControl.layer, + mockProjectLinkState(), + BunServices.layer, + ); + return logs({ stack: fixture.stackName, service: [], tail: 10, noFollow: false }).pipe( + Effect.provide(layer), + Effect.exit, + Effect.ensuring(Effect.promise(() => fixture.dispose())), + Effect.andThen((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired"); + } + expect(processControl.exitCalls).toEqual([]); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("[postgres]"), + }), + ); + }), + ), + ); + }), + ), + ); + it.live("attaches to managed control and renders persisted and live history", () => Effect.promise(() => makeRunningStackFixture()).pipe( Effect.flatMap((fixture) => { diff --git a/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts b/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts new file mode 100644 index 0000000000..6d6873adf8 --- /dev/null +++ b/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@effect/vitest"; +import { StackUnavailableError } from "@supabase/stack/effect"; +import { makeTestStack } from "@supabase/stack/testing"; +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; +import { Ink } from "../../../../shared/runtime/ink.service.ts"; +import { Stack } from "@supabase/stack/effect"; +import { startForegroundWithStopSignal } from "./foreground.flow.ts"; + +const inkLayer = Layer.succeed(Ink, { + render: () => + Effect.succeed({ + unmount: () => undefined, + rerender: () => undefined, + waitUntilExit: () => new Promise(() => undefined), + }), +}); + +describe("start foreground flow", () => { + it.effect("does not start the runtime when dashboard state initialization fails", () => + Effect.gen(function* () { + let startCalls = 0; + const stack = { + ...makeTestStack(), + getInfo: () => Effect.fail(new StackUnavailableError({ phase: "starting" })), + start: () => + Effect.sync(() => { + startCalls += 1; + }), + }; + const exit = yield* startForegroundWithStopSignal(Effect.never).pipe( + Effect.provide(Layer.mergeAll(Layer.succeed(Stack, stack), inkLayer)), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(startCalls).toBe(0); + }), + ); + + it.effect("creates the dashboard state stream before starting the runtime", () => + Effect.gen(function* () { + const stopRequested = Deferred.makeUnsafe(); + const started = Deferred.makeUnsafe(); + let allStateChangesCalled = false; + let startCalls = 0; + const stack = { + ...makeTestStack(), + start: () => + Effect.gen(function* () { + expect(allStateChangesCalled).toBe(true); + startCalls += 1; + yield* Deferred.succeed(started, undefined); + }), + allStateChanges: () => { + allStateChangesCalled = true; + return Stream.never; + }, + }; + const fiber = yield* startForegroundWithStopSignal(Deferred.await(stopRequested)).pipe( + Effect.provide(Layer.mergeAll(Layer.succeed(Stack, stack), inkLayer)), + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(started); + expect(startCalls).toBe(1); + yield* Deferred.succeed(stopRequested, undefined); + yield* Fiber.join(fiber); + }), + ); +}); diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index bb22890b5b..abad14771f 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -3,6 +3,7 @@ import { loadProjectConfig } from "@supabase/config"; import { DEFAULT_MANAGED_STACK_NAME, daemonLayer, + restartManagedStackForUpgrade, fillServiceVersionManifest, resolveStackSummary, type StackSummary, @@ -35,6 +36,7 @@ import { inkLayer } from "../../../shared/runtime/ink.layer.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { start } from "./start.handler.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; /** * Deprecation warning shown when `[api].auto_expose_new_tables = true` is loaded from @@ -89,7 +91,12 @@ interface StartVersionStateShape { readonly launch: { readonly mode?: StartMode; readonly versions: Readonly>; - readonly excludedServices: ReadonlyArray; + /** + * Preserve the managed document's raw exclusions. The document may contain + * service names introduced by a newer CLI; narrowing is only appropriate + * when deriving the current runtime configuration. + */ + readonly excludedServices: ReadonlyArray; }; readonly previousUpdateFingerprint?: string; readonly drift?: NonNullable; @@ -99,6 +106,7 @@ interface StartVersionStateShape { readonly workspacePath: string; readonly stackName: string; readonly cwd: string; + readonly cliVersion: string; }; } @@ -106,6 +114,21 @@ export class StartVersionState extends Context.Service, +): StartVersionStateShape["launch"] => ({ + mode: summary.launch.mode, + versions: summary.launch.versions, + excludedServices: summary.launch.excludedServices ?? [], +}); + const flags = { stack: Flag.string("stack").pipe( Flag.withDescription("Name of the managed local stack for this project."), @@ -229,7 +252,8 @@ export const startCommand = Command.make("start", flags).pipe( : { lastNotifiedUpdateFingerprint: existingSummary.lastNotifiedUpdateFingerprint }), }; - const stackLayer = yield* daemonLayer({ + const managedInput = { + cliVersion: CLI_VERSION, cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, projectDir: projectHome.projectRoot, @@ -237,7 +261,20 @@ export const startCommand = Command.make("start", flags).pipe( portIntents, launch, ...stackConfig, - }); + }; + const stackLayer = yield* daemonLayer(managedInput).pipe( + Effect.catchTag("DaemonUpgradeRequired", (error) => + output + .warn( + [ + `Local stack was started with CLI v${error.oldCliVersion}. Restarting it with CLI v${error.newCliVersion}.`, + "Database and storage data, pinned service versions, and sticky ports will be preserved.", + "Existing connections will briefly disconnect.", + ].join("\n"), + ) + .pipe(Effect.andThen(restartManagedStackForUpgrade(managedInput))), + ), + ); const summary = yield* resolveStackSummary({ cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, @@ -247,11 +284,7 @@ export const startCommand = Command.make("start", flags).pipe( return { stackLayer, startVersionState: StartVersionState.of({ - launch: { - mode: summary.launch.mode, - versions: serviceVersionContext.pinnedBaseline, - excludedServices: flags.exclude, - }, + launch: startVersionStateLaunch(summary), ...(summary.lastNotifiedUpdateFingerprint === undefined ? {} : { previousUpdateFingerprint: summary.lastNotifiedUpdateFingerprint }), @@ -264,6 +297,7 @@ export const startCommand = Command.make("start", flags).pipe( workspacePath: projectHome.projectRoot, stackName: flags.stack, cwd: runtimeInfo.cwd, + cliVersion: CLI_VERSION, }, }), }; diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 920bfba263..833dd03444 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -8,7 +8,7 @@ import { } from "@supabase/stack/effect"; import { Effect, Layer } from "effect"; import { start } from "./start.handler.ts"; -import { StartVersionState } from "./start.command.ts"; +import { startVersionStateLaunch, StartVersionState } from "./start.command.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { inkLayer } from "../../../shared/runtime/ink.layer.ts"; import { @@ -25,6 +25,7 @@ describe("start handler", () => { Effect.promise(() => makeRunningStackFixture()).pipe( Effect.flatMap((fixture) => connectLayer({ + cliVersion: fixture.cliVersion, cacheRoot: fixture.homeDir, cwd: fixture.projectRoot, projectDir: fixture.projectRoot, @@ -38,8 +39,13 @@ describe("start handler", () => { pinnedBaseline: versions, candidateBaseline: versions, }); + const postStartLaunch = { + ...fixture.launch, + versions: { postgres: "17.7.0" }, + excludedServices: ["analytics", "future-service"], + } as const; const state = StartVersionState.of({ - launch: fixture.launch, + launch: startVersionStateLaunch({ launch: postStartLaunch }), serviceVersionContext: { ...serviceVersionContext, updateFingerprint: "new-fingerprint", @@ -49,6 +55,7 @@ describe("start handler", () => { workspacePath: fixture.projectRoot, stackName: fixture.stackName, cwd: fixture.projectRoot, + cliVersion: fixture.cliVersion, }, drift: [ { @@ -83,13 +90,17 @@ describe("start handler", () => { mode: "docker", exclude: [], serviceVersion: [], - detach: false, + detach: true, }).pipe( Effect.provide(layer), Effect.tap( Effect.promise(async () => { const document = await fixture.readDocument(); - expect(document?.launch?.lastNotifiedUpdateFingerprint).toBe("new-fingerprint"); + expect(document?.launch).toMatchObject({ + versions: { postgres: "17.7.0" }, + excludedServices: ["analytics", "future-service"], + lastNotifiedUpdateFingerprint: "new-fingerprint", + }); }), ), Effect.ensuring(Effect.promise(() => fixture.dispose())), diff --git a/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts b/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts new file mode 100644 index 0000000000..cd95c2d7df --- /dev/null +++ b/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "@effect/vitest"; +import { makeTestStack } from "@supabase/stack/testing"; +import { Stack } from "@supabase/stack/effect"; +import { Cause, Context, Effect, Layer, Stream, SubscriptionRef } from "effect"; +import { StartDashboardState } from "./dashboard-state.ts"; + +it.live("does not report RPC stream interruption as a dashboard failure", () => + Effect.scoped( + Effect.gen(function* () { + const stack = { + ...makeTestStack(), + allStateChanges: () => Stream.failCause(Cause.interrupt()), + }; + const context = yield* Layer.build( + StartDashboardState.live.pipe(Layer.provide(Layer.succeed(Stack, stack))), + ); + const state = Context.get(context, StartDashboardState); + yield* Effect.yieldNow; + + expect(yield* SubscriptionRef.get(state.phaseRef)).toBe("starting"); + expect(yield* SubscriptionRef.get(state.errorRef)).toBeNull(); + }), + ), +); diff --git a/apps/cli/src/next/commands/start/ui/dashboard-state.ts b/apps/cli/src/next/commands/start/ui/dashboard-state.ts index 4dae0e45bb..9d745b51c8 100644 --- a/apps/cli/src/next/commands/start/ui/dashboard-state.ts +++ b/apps/cli/src/next/commands/start/ui/dashboard-state.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Context, Stream, SubscriptionRef } from "effect"; +import { Cause, Context, Effect, Layer, Stream, SubscriptionRef } from "effect"; import type { StackServiceState, StackInfo } from "@supabase/stack/effect"; import { Stack } from "@supabase/stack/effect"; @@ -41,7 +41,12 @@ export class StartDashboardState extends Context.Service< updateServiceStates(current, state), ), ), - Effect.ignore, + Effect.catch((error) => + Effect.all([ + SubscriptionRef.set(errorRef, Cause.pretty(Cause.fail(error))), + SubscriptionRef.set(phaseRef, "failed"), + ]).pipe(Effect.asVoid), + ), Effect.forkScoped({ startImmediately: true }), ); diff --git a/apps/cli/src/next/commands/start/ui/foreground-session.ts b/apps/cli/src/next/commands/start/ui/foreground-session.ts index 1e02c71410..3433c20e24 100644 --- a/apps/cli/src/next/commands/start/ui/foreground-session.ts +++ b/apps/cli/src/next/commands/start/ui/foreground-session.ts @@ -1,7 +1,7 @@ import { clearTimeout, setTimeout } from "node:timers"; import { createElement } from "react"; import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; -import { Cause, Effect, Layer } from "effect"; +import { Cause, Context, Effect, Layer } from "effect"; import { RegistryContext } from "@effect/atom-react"; import { Stack } from "@supabase/stack/effect"; import { Ink } from "../../../../shared/runtime/ink.service.ts"; @@ -25,9 +25,12 @@ export const makeStartForegroundSession = Effect.fnUntraced(function* () { const stack = yield* Stack; const ink = yield* Ink; const registry = AtomRegistry.make({ scheduleTask }); - const model = createStartDashboardModel( + const stateContext = yield* Layer.build( Layer.provide(StartDashboardState.live, Layer.succeed(Stack, stack)), ); + const model = createStartDashboardModel( + Layer.succeed(StartDashboardState, Context.get(stateContext, StartDashboardState)), + ); yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index db446245f0..b05dc21ee5 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Context, Effect, Layer, Option, Predicate } from "effect"; import { loadProjectConfig } from "@supabase/config"; import { connectLayer, @@ -12,11 +12,60 @@ import { ProjectHome } from "../../config/project-home.service.ts"; import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { StatusFlags } from "./status.command.ts"; import { managedPortIntents } from "../../config/managed-port-intents.ts"; import { isExcludedStackService, toStartStackConfig } from "../../config/stack-config.ts"; import { formatPortDriftWarning } from "../../stack/port-drift.ts"; +const renderUpgradeRequiredStatus = Effect.fnUntraced(function* (input: { + readonly summary: StackSummary; + readonly error: { + readonly oldCliVersion: string; + readonly newCliVersion: string; + readonly state: "starting" | "running" | "stopping" | "deleting" | "failed"; + readonly ready: boolean; + }; +}) { + const output = yield* Output; + const message = "Local Supabase stack is managed by a different CLI version."; + const running = input.error.state === "running" && input.error.ready; + const data = { + stack: input.summary.name, + running, + state: input.error.state, + ready: input.error.ready, + degraded: true, + reason: "daemon_upgrade_required" as const, + daemon_cli_version: input.error.oldCliVersion, + cli_version: input.error.newCliVersion, + ports: input.summary.ports, + versions: input.summary.versions, + launch: input.summary.launch, + instruction: "Run `supabase start` to restart the stack with the current CLI.", + }; + + if (output.format !== "text") { + yield* output.success(message, data); + return; + } + + yield* output.warn(message); + yield* output.info(`Stack: ${input.summary.name}`); + yield* output.info(`Daemon CLI: ${input.error.oldCliVersion}`); + yield* output.info(`Current CLI: ${input.error.newCliVersion}`); + yield* output.info(`State: ${input.error.state}`); + yield* output.info(`Ready: ${String(input.error.ready)}`); + yield* output.info(formatPortsLine(input.summary.ports)); + yield* output.info(`Runtime mode: ${input.summary.launch.mode}`); + for (const [name, version] of Object.entries(input.summary.versions).sort(([a], [b]) => + a.localeCompare(b), + )) { + yield* output.info(`${name} version: ${version}`); + } + yield* output.info(data.instruction); +}); + function formatServiceStateLine(service: { readonly name: string; readonly status: string; @@ -85,23 +134,37 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { yield* output.intro("Show local Supabase stack status"); - const layer = yield* connectLayer({ + const summaryInput = { + cacheRoot: cliConfig.supabaseHome, + projectDir: projectHome.projectRoot, + cwd: runtimeInfo.cwd, + name: _flags.stack, + }; + const layerResult = yield* connectLayer({ + cliVersion: CLI_VERSION, cwd: runtimeInfo.cwd, cacheRoot: cliConfig.supabaseHome, projectDir: projectHome.projectRoot, name: _flags.stack, }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), + Effect.map((layer) => ({ _tag: "live" as const, layer })), + Effect.catchTag("DaemonUpgradeRequired", (error) => + Effect.succeed({ _tag: "upgrade" as const, error }), + ), + Effect.catchTag("NoRunningStackError", () => Effect.succeed({ _tag: "none" as const })), ); - if (Option.isNone(layer)) { - const summary = yield* resolveConfiguredSummary({ - cacheRoot: cliConfig.supabaseHome, - projectDir: projectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: _flags.stack, - }).pipe( + if (Predicate.isTagged(layerResult, "upgrade")) { + // An incompatible daemon is authoritative for its own managed summary. + // Do not parse the current checkout's config before rendering this status: + // a newer CLI may have introduced config that this CLI cannot decode. + const summary = yield* resolveStackSummary(summaryInput); + yield* renderUpgradeRequiredStatus({ summary, error: layerResult.error }); + return; + } + + if (Predicate.isTagged(layerResult, "none")) { + const summary = yield* resolveConfiguredSummary(summaryInput).pipe( Effect.map(Option.some), Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), ); @@ -153,15 +216,26 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { return; } - const summary = yield* resolveConfiguredSummary({ - cacheRoot: cliConfig.supabaseHome, - projectDir: projectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: _flags.stack, - }); + const summary = yield* resolveConfiguredSummary(summaryInput); + + const stackResult = yield* Effect.scoped( + Effect.gen(function* () { + const context = yield* Layer.build(layerResult.layer); + const stack = Context.get(context, Stack); + const [info, services] = yield* Effect.all([stack.getInfo(), stack.getAllStates()]); + return { _tag: "live" as const, info, services }; + }), + ).pipe( + Effect.catchTag("DaemonUpgradeRequired", (error) => + Effect.succeed({ _tag: "upgrade" as const, error }), + ), + ); + if (Predicate.isTagged(stackResult, "upgrade")) { + yield* renderUpgradeRequiredStatus({ summary, error: stackResult.error }); + return; + } - const stack = yield* Effect.provide(Stack, layer.value); - const [info, services] = yield* Effect.all([stack.getInfo(), stack.getAllStates()]); + const { info, services } = stackResult; const serviceVersionContext = yield* resolveServiceVersionContext( [], fillServiceVersionManifest(summary.versions), diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index add4708b8e..9eef4a5d59 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -106,4 +106,181 @@ describe("status handler", () => { }), ), ); + + it.live("renders a degraded owner/document summary when the daemon CLI version differs", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + }), + ).pipe( + Effect.flatMap((fixture) => { + mkdirSync(join(fixture.projectRoot, "supabase"), { recursive: true }); + writeFileSync(join(fixture.projectRoot, "supabase", "config.toml"), "[invalid\n"); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + BunServices.layer, + ); + return status({ stack: fixture.stackName }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => fixture.dispose())), + Effect.andThen( + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "Local Supabase stack is managed by a different CLI version.", + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: "Run `supabase start` to restart the stack with the current CLI.", + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "State: running" }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "Ready: true" }), + ); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("API URL:"), + }), + ); + }), + ), + ); + }), + ), + ); + + it.live("returns only the degraded owner/document fields in structured output", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + }), + ).pipe( + Effect.flatMap((fixture) => { + mkdirSync(join(fixture.projectRoot, "supabase"), { recursive: true }); + writeFileSync(join(fixture.projectRoot, "supabase", "config.toml"), "[invalid\n"); + const out = mockOutput({ format: "json", interactive: false }); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + BunServices.layer, + ); + return status({ stack: fixture.stackName }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => fixture.dispose())), + Effect.andThen( + Effect.sync(() => { + const success = out.messages.find((message) => message.type === "success"); + expect(success).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + degraded: true, + reason: "daemon_upgrade_required", + daemon_cli_version: "2.60.0", + instruction: "Run `supabase start` to restart the stack with the current CLI.", + }), + }), + ); + expect(success?.data).not.toHaveProperty("api_url"); + expect(success?.data).not.toHaveProperty("services"); + }), + ), + ); + }), + ), + ); + + it.live("reports an incompatible starting owner as not running in structured output", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + ownerState: "starting", + }), + ).pipe( + Effect.flatMap((fixture) => { + mkdirSync(join(fixture.projectRoot, "supabase"), { recursive: true }); + writeFileSync(join(fixture.projectRoot, "supabase", "config.toml"), "[invalid\n"); + const out = mockOutput({ format: "json", interactive: false }); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + BunServices.layer, + ); + return status({ stack: fixture.stackName }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => fixture.dispose())), + Effect.andThen( + Effect.sync(() => { + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toEqual( + expect.objectContaining({ + degraded: true, + running: false, + state: "starting", + ready: false, + daemon_cli_version: "2.60.0", + }), + ); + }), + ), + ); + }), + ), + ); + + it.live("renders an incompatible starting owner state in text output", () => + Effect.promise(() => + makeRunningStackFixture({ + cliVersion: "2.60.0", + ownerState: "starting", + }), + ).pipe( + Effect.flatMap((fixture) => { + mkdirSync(join(fixture.projectRoot, "supabase"), { recursive: true }); + writeFileSync(join(fixture.projectRoot, "supabase", "config.toml"), "[invalid\n"); + const out = mockOutput(); + const layer = Layer.mergeAll( + fixture.baseLayer, + out.layer, + mockProjectLinkState(), + mockProjectLocalServiceVersions(), + BunServices.layer, + ); + return status({ stack: fixture.stackName }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => fixture.dispose())), + Effect.andThen( + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "Local Supabase stack is managed by a different CLI version.", + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "State: starting" }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "Ready: false" }), + ); + }), + ), + ); + }), + ), + ); }); diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index 3ceaafff76..a78106b125 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -17,6 +17,7 @@ import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { UpdateFlags } from "./update.command.ts"; function diffCachedLinkedVersions( @@ -108,6 +109,7 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { cwd: runtimeInfo.cwd, workspacePath: projectHome.projectRoot, stackName: flags.stack, + cliVersion: CLI_VERSION, launch: { versions: serviceVersionContext.candidateBaseline, excludedServices: existingSummary.value.launch.excludedServices ?? [], diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 6b51a4d163..17cfba257e 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,4 +1,8 @@ -import type { StackConfig, VersionManifest } from "@supabase/stack/effect"; +import { + expandExcludedServices, + type StackConfig, + type VersionManifest, +} from "@supabase/stack/effect"; export const excludedStackServices = [ "auth", @@ -24,18 +28,18 @@ export function toStartStackConfig( exclude: ReadonlyArray, mode?: StartMode, ): StackConfig { - const excluded = new Set(exclude); + const excluded = expandExcludedServices(exclude); const native = mode === "native"; return { ...(mode === undefined ? {} : { mode }), realtime: native || excluded.has("realtime") ? false : {}, storage: native || excluded.has("storage") ? false : {}, - imgproxy: native || excluded.has("imgproxy") || excluded.has("storage") ? false : {}, + imgproxy: native || excluded.has("imgproxy") ? false : {}, mailpit: native || excluded.has("mailpit") ? false : {}, pgmeta: native || excluded.has("pgmeta") ? false : {}, - studio: native || excluded.has("studio") || excluded.has("pgmeta") ? false : {}, + studio: native || excluded.has("studio") ? false : {}, analytics: native || excluded.has("analytics") ? false : {}, - vector: native || excluded.has("vector") || excluded.has("analytics") ? false : {}, + vector: native || excluded.has("vector") ? false : {}, pooler: native || excluded.has("pooler") ? false : {}, ...(excluded.has("auth") ? { auth: false } : {}), ...(excluded.has("postgrest") ? { postgrest: false } : {}), diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index 46ef8ffd67..44ad028e7d 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -38,6 +38,21 @@ describe("toStartStackConfig", () => { postgrest: false, }); }); + + it("excludes graph companions for storage, pgmeta, and analytics", () => { + expect(toStartStackConfig(["storage"], "docker")).toMatchObject({ + storage: false, + imgproxy: false, + }); + expect(toStartStackConfig(["pgmeta"], "docker")).toMatchObject({ + pgmeta: false, + studio: false, + }); + expect(toStartStackConfig(["analytics"], "docker")).toMatchObject({ + analytics: false, + vector: false, + }); + }); }); describe("withServiceVersions", () => { diff --git a/apps/cli/src/shared/cli/version.integration.test.ts b/apps/cli/src/shared/cli/version.integration.test.ts index e776298879..72ae62c467 100644 --- a/apps/cli/src/shared/cli/version.integration.test.ts +++ b/apps/cli/src/shared/cli/version.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; +import { fileURLToPath } from "node:url"; import { vi } from "vitest"; import { legacyRoot } from "../../legacy/cli/root.ts"; import { nextRoot } from "../../next/cli/root.ts"; @@ -30,7 +31,7 @@ describe("CLI --version (text)", () => { logs.push(line); }); try { - // `Command.runWith` keeps handler/global-flag services in its env type even when + // `Command.runWith` keeps handler/global-flag services in the effect type even when // `--version` exits early; only BunServices + CliOutput are needed at runtime here. await Effect.runPromise( Command.runWith(legacyRoot, { version: "2.99.0-beta.1" })(["--version"]).pipe( @@ -71,4 +72,33 @@ describe("CLI --version (text)", () => { expect(logs[0]).toMatch(/^\d+\.\d+\.\d+/); expect(logs[0]).not.toMatch(/supabase\s+v/i); }); + + test("source execution ignores a runtime version environment variable", async () => { + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } + + const versionModule = fileURLToPath(new URL("./version.ts", import.meta.url)); + const child = Bun.spawn( + [ + bunExecutable, + "-e", + `import { CLI_VERSION } from ${JSON.stringify(versionModule)}; console.log(CLI_VERSION);`, + ], + { + env: { ...process.env, SUPABASE_CLI_VERSION: "9.9.9" }, + stdout: "pipe", + stderr: "pipe", + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout.trim()).toBe("0.0.0-dev"); + }); }); diff --git a/apps/cli/src/shared/cli/version.ts b/apps/cli/src/shared/cli/version.ts index 2b3b4fe445..1e60d4c7d9 100644 --- a/apps/cli/src/shared/cli/version.ts +++ b/apps/cli/src/shared/cli/version.ts @@ -1,5 +1,7 @@ -// This constant is injected at compile time by `apps/cli/scripts/build.ts` -// via `bun build --define "process.env.SUPABASE_CLI_VERSION=..."`. -// At runtime outside a compiled SFE (dev, tests), we fall back to the -// env var or a sentinel so that bugs are visible in CLI output. -export const CLI_VERSION = process.env.SUPABASE_CLI_VERSION ?? "0.0.0-dev"; +// The build scripts replace this symbol with the immutable package version in +// released binaries. It intentionally is not read from the runtime +// environment: source execution must remain an unambiguous development build. +declare const SUPABASE_CLI_VERSION: string | undefined; + +export const CLI_VERSION = + typeof SUPABASE_CLI_VERSION === "string" ? SUPABASE_CLI_VERSION : "0.0.0-dev"; diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index bd89a4c1d5..e1a3edefb5 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -31,6 +31,16 @@ const readRawString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" ? field : undefined; }; +const readCauseMessage = (value: ErrorRecord): string | undefined => { + const cause = value["cause"]; + if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + if (typeof cause === "string" && cause.trim().length > 0) return cause.trim(); + if (isErrorRecord(cause)) { + return readString(cause, "message") ?? readString(cause, "detail"); + } + return undefined; +}; + const mappedError = ( error: ErrorRecord, context?: CliErrorSuggestionContext, @@ -52,6 +62,79 @@ const mappedError = ( message: readString(error, "message") ?? "Failed to start the Supabase daemon.", suggestion: "Check local resources and try `supabase start` again.", }; + case "DaemonUpgradeRequired": { + const oldCliVersion = readString(error, "oldCliVersion") ?? "an older CLI"; + const newCliVersion = readString(error, "newCliVersion") ?? "the current CLI"; + return { + code: tag, + message: `The local Supabase stack is running under ${oldCliVersion}, but this CLI is ${newCliVersion}.`, + suggestion: "Run `supabase start` to restart the stack with the current CLI.", + }; + } + case "StackUnavailableError": { + const phase = readString(error, "phase"); + const detail = readString(error, "detail"); + const message = + phase === "starting" + ? "The local Supabase stack is still starting." + : phase === "stopping" + ? "The local Supabase stack is still stopping." + : phase === "failed" + ? "The local Supabase stack failed to start." + : phase === "deleting" + ? "The local Supabase stack is being deleted." + : "The local Supabase stack is unavailable."; + const suggestion = + phase === "starting" + ? "Wait for `supabase start` to finish, then try again." + : phase === "stopping" + ? "Wait for the current stop operation to finish, then try again." + : phase === "failed" + ? "Run `supabase start` again to recreate the local stack." + : phase === "deleting" + ? "Wait for the current delete operation to finish, then try again." + : "Run `supabase start`, then retry the command."; + return { + code: tag, + message, + ...(detail === undefined ? {} : { detail }), + suggestion, + }; + } + case "StackRpcTransportError": { + const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; + const procedure = readString(error, "procedure") ?? "the requested operation"; + const cause = readCauseMessage(error); + return { + code: tag, + message: "Could not communicate with the local Supabase stack.", + detail: `RPC ${procedure} at ${endpoint} failed${cause === undefined ? "." : `: ${cause}`}`, + suggestion: "Check that the stack is running, then retry the command.", + }; + } + case "StackRpcProtocolError": { + const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; + const procedure = readString(error, "procedure") ?? "the requested operation"; + const detail = readString(error, "detail") ?? "the response did not match the RPC protocol"; + return { + code: tag, + message: "The local Supabase stack returned an invalid RPC response.", + detail: `RPC ${procedure} at ${endpoint} failed protocol validation: ${detail}`, + suggestion: "Restart the stack with `supabase start`, then retry the command.", + }; + } + case "StopTimeout": { + const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; + const lastState = readString(error, "lastState"); + return { + code: tag, + message: "Timed out waiting for the local Supabase stack to stop.", + detail: `The stack at ${endpoint} did not stop before the timeout${ + lastState === undefined ? "." : ` (last state: ${lastState}).` + }`, + suggestion: "Check `supabase status`, then retry `supabase stop`.", + }; + } case "MissingOption": { // Mirror Go Cobra's `required flag(s) "X" not set` wording. Effect CLI's // default `Missing required flag: --X` differs and would break scripts diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index d1fe9a7715..16316b7a63 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -27,6 +27,96 @@ describe("normalizeCliError", () => { }); }); + test("maps DaemonUpgradeRequired to an actionable start instruction", () => { + expect( + normalizeCliError({ + _tag: "DaemonUpgradeRequired", + oldCliVersion: "2.60.0", + newCliVersion: "2.61.0", + }), + ).toEqual({ + code: "DaemonUpgradeRequired", + message: "The local Supabase stack is running under 2.60.0, but this CLI is 2.61.0.", + suggestion: "Run `supabase start` to restart the stack with the current CLI.", + }); + }); + + test("maps an unavailable starting stack to a wait-and-retry instruction", () => { + expect( + normalizeCliError({ + _tag: "StackUnavailableError", + phase: "starting", + }), + ).toEqual({ + code: "StackUnavailableError", + message: "The local Supabase stack is still starting.", + suggestion: "Wait for `supabase start` to finish, then try again.", + }); + }); + + test("maps an unavailable stopping stack to a stop completion instruction", () => { + expect( + normalizeCliError({ + _tag: "StackUnavailableError", + phase: "stopping", + }), + ).toEqual({ + code: "StackUnavailableError", + message: "The local Supabase stack is still stopping.", + suggestion: "Wait for the current stop operation to finish, then try again.", + }); + }); + + test("maps RPC transport failures with the procedure and endpoint", () => { + expect( + normalizeCliError({ + _tag: "StackRpcTransportError", + endpoint: "http://127.0.0.1:54321", + procedure: "GetInfo", + cause: new Error("ECONNRESET"), + }), + ).toEqual({ + code: "StackRpcTransportError", + message: "Could not communicate with the local Supabase stack.", + detail: "RPC GetInfo at http://127.0.0.1:54321 failed: ECONNRESET", + suggestion: "Check that the stack is running, then retry the command.", + }); + }); + + test("maps RPC protocol failures with the procedure, endpoint, and detail", () => { + expect( + normalizeCliError({ + _tag: "StackRpcProtocolError", + endpoint: "http://127.0.0.1:54321", + procedure: "GetInfo", + detail: "Invalid GetInfo response", + }), + ).toEqual({ + code: "StackRpcProtocolError", + message: "The local Supabase stack returned an invalid RPC response.", + detail: + "RPC GetInfo at http://127.0.0.1:54321 failed protocol validation: Invalid GetInfo response", + suggestion: "Restart the stack with `supabase start`, then retry the command.", + }); + }); + + test("maps stop timeouts with the endpoint and last observed state", () => { + expect( + normalizeCliError({ + _tag: "StopTimeout", + endpoint: "http://127.0.0.1:54321", + ownerSessionId: "session-123", + lastState: "stopping", + }), + ).toEqual({ + code: "StopTimeout", + message: "Timed out waiting for the local Supabase stack to stop.", + detail: + "The stack at http://127.0.0.1:54321 did not stop before the timeout (last state: stopping).", + suggestion: "Check `supabase status`, then retry `supabase stop`.", + }); + }); + test("falls back to tagged error fields when no explicit mapping exists", () => { const error = { _tag: "ExampleError", diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 8210a0dc26..7864cb517b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -217,7 +217,7 @@ describe("extractErrorTags", () => { "PlainThingError", "FreeStandingTag", ]); - }); + }, 30_000); it("ignores definitions that only appear in comments", () => { const source = [ diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 575bf42f50..e6381689f0 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -99,6 +99,10 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "daemon_protocol", "daemon_status", "daemon_transport", + "daemon_upgrade_required", + "daemon_upgrade_preflight", + "daemon_upgrade_restart", + "daemon_stop_timeout", "database", "docker_not_running", "filesystem", @@ -126,6 +130,7 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "managed_control_transport", "managed_control_protocol", "managed_control_address_conflict", + "managed_control_stop_conflict", "managed_document", "managed_control_required", "managed_attached", @@ -1022,7 +1027,32 @@ const externalActionabilityByTag: Record = { }), StackNotRunningError: () => actionability.startStack, StackReadinessError: () => actionability.startStack, + StackUnavailableError: () => actionability.startStack, + StackRpcTransportError: () => ({ + ...actionability.externalNetwork, + fingerprint_suffix: "daemon_transport", + }), + StackRpcProtocolError: () => ({ + ...actionability.impossibleState, + fingerprint_suffix: "daemon_protocol", + }), NoRunningStackError: () => actionability.startStack, + DaemonUpgradeRequired: () => ({ + ...actionability.startStack, + fingerprint_suffix: "daemon_upgrade_required", + }), + UpgradePreflightError: () => ({ + ...actionability.startStack, + fingerprint_suffix: "daemon_upgrade_preflight", + }), + UpgradeRestartError: () => ({ + ...actionability.startStack, + fingerprint_suffix: "daemon_upgrade_restart", + }), + StopTimeout: () => ({ + ...actionability.stopStack, + fingerprint_suffix: "daemon_stop_timeout", + }), InvalidControlOwnershipIdError: () => ({ ...actionability.impossibleState, fingerprint_suffix: "managed_control_ownership", @@ -1047,6 +1077,10 @@ const externalActionabilityByTag: Record = { ...actionability.startStack, fingerprint_suffix: "managed_control_address_conflict", }), + ControlStopConflictError: () => ({ + ...actionability.impossibleState, + fingerprint_suffix: "managed_control_stop_conflict", + }), InvalidManagedStackDocumentError: () => ({ ...actionability.invalidConfig, fingerprint_suffix: "managed_document", diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index a426e73158..8f94bdbf4c 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -489,6 +489,22 @@ describe("classifyCliErrorActionability", () => { expect(readiness.suggested_command).toBe("supabase start"); }); + it("classifies control stop conflicts as a sanitized internal invariant failure", () => { + const result = classifyCliErrorActionability({ + _tag: "ControlStopConflictError", + endpoint: "http://127.0.0.1:54321", + }); + + expect(result).toEqual({ + error_kind: "internal_bug", + error_category: "impossible_state", + error_fingerprint: "tag:ControlStopConflictError:managed_control_stop_conflict", + has_suggestion: true, + suggestion_type: "rerun_debug", + }); + expect(JSON.stringify(result)).not.toContain("127.0.0.1"); + }); + it("splits docker pull failures from a stopped docker daemon", () => { const daemonDown = classifyCliErrorActionability({ _tag: "DockerPullError", diff --git a/apps/cli/tests/fixtures/compiled-cli-version.ts b/apps/cli/tests/fixtures/compiled-cli-version.ts new file mode 100644 index 0000000000..07b930c371 --- /dev/null +++ b/apps/cli/tests/fixtures/compiled-cli-version.ts @@ -0,0 +1,3 @@ +import { CLI_VERSION } from "../../src/shared/cli/version.ts"; + +console.log(CLI_VERSION); diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 712dad8ea8..450e8764f1 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -2,20 +2,31 @@ import { BunServices } from "@effect/platform-bun"; import { Stack, StackServiceState, + StackBuildError, type StackInfo, httpTransportClientLayer, } from "@supabase/stack/effect"; -import { DaemonServer } from "@supabase/stack/testing"; +import { makeSupervisorControlApplication, SupervisorLifecycle } from "@supabase/stack/testing"; import { ManagedStackManager, + acquireControl, + controlTransportLayer, deriveStackId, managedStackManagerLayer, type ControlOwnership, - type ManagedStackManagerShape, type ManagedPortIntentDocument, } from "@supabase/stack/managed"; -import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Option, Stream } from "effect"; -import { HttpServer } from "effect/unstable/http"; +import { + Deferred, + Effect, + Exit, + Fiber, + Layer, + ManagedRuntime, + Option, + Scope, + Stream, +} from "effect"; import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,6 +34,7 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import { CliConfig } from "../../src/next/config/cli-config.service.ts"; import { ProjectHome } from "../../src/next/config/project-home.service.ts"; import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../../src/shared/cli/version.ts"; const launch = { mode: "docker" as const, @@ -51,36 +63,35 @@ const history = [ { timestamp: 1_000, service: "postgres", stream: "stdout" as const, line: "ready" }, ]; -const stackLayer = (info: StackInfo, onStop: Effect.Effect): Layer.Layer => - Layer.succeed(Stack, { - getInfo: () => Effect.succeed(info), - start: () => Effect.void, - stop: () => onStop, - dispose: () => onStop, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: (name: string) => { - const state = stackStates.find((candidate) => candidate.name === name); - return state === undefined - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.succeed(state); - }, - getAllStates: () => Effect.succeed(stackStates), - stateChanges: (name: string) => - Effect.succeed(Stream.fromIterable(stackStates.filter((state) => state.name === name))), - allStateChanges: () => Stream.fromIterable(stackStates), - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: (name: string) => - Stream.fromIterable(history.filter((entry) => entry.service === name)), - subscribeAllLogs: () => Stream.fromIterable(history), - logHistory: (name: string, limit?: number) => - Effect.succeed(history.filter((entry) => entry.service === name).slice(-(limit ?? 100))), - logHistoryAll: (limit?: number) => Effect.succeed(history.slice(-(limit ?? 100))), - }); +const stackService = (info: StackInfo, onStop: Effect.Effect): Stack["Service"] => ({ + getInfo: () => Effect.succeed(info), + start: () => Effect.void, + stop: () => onStop, + dispose: () => onStop, + startService: () => Effect.void, + stopService: () => Effect.void, + restartService: () => Effect.void, + reloadFunctions: () => Effect.void, + reloadEdgeRuntime: () => Effect.void, + getState: (name: string) => { + const state = stackStates.find((candidate) => candidate.name === name); + return state === undefined + ? Effect.fail(new ServiceNotFoundError({ name })) + : Effect.succeed(state); + }, + getAllStates: () => Effect.succeed(stackStates), + stateChanges: (name: string) => + Effect.succeed(Stream.fromIterable(stackStates.filter((state) => state.name === name))), + allStateChanges: () => Stream.fromIterable(stackStates), + waitReady: () => Effect.void, + waitAllReady: () => Effect.void, + subscribeLogs: (name: string) => + Stream.fromIterable(history.filter((entry) => entry.service === name)), + subscribeAllLogs: () => Stream.fromIterable(history), + logHistory: (name: string, limit?: number) => + Effect.succeed(history.filter((entry) => entry.service === name).slice(-(limit ?? 100))), + logHistoryAll: (limit?: number) => Effect.succeed(history.slice(-(limit ?? 100))), +}); function projectHome(projectRoot: string): ProjectHome["Service"] { const projectHomeDir = join(projectRoot, ".supabase"); @@ -95,7 +106,12 @@ function projectHome(projectRoot: string): ProjectHome["Service"] { } export async function makeManagedStackFixture( - options: { running?: boolean; stackName?: string } = {}, + options: { + running?: boolean; + stackName?: string; + cliVersion?: string; + ownerState?: "starting"; + } = {}, ) { const root = mkdtempSync(join(tmpdir(), "supabase-cli-managed-stack-")); const projectRoot = join(root, "repo"); @@ -104,33 +120,53 @@ export async function makeManagedStackFixture( mkdirSync(projectRoot, { recursive: true }); const stackName = options.stackName ?? "default"; const running = options.running ?? true; + const cliVersion = options.cliVersion ?? CLI_VERSION; + const ownerState = options.ownerState; const project = projectHome(projectRoot); - const managerRuntime = ManagedRuntime.make(managedStackManagerLayer({ stateRoot })); - const ready = await managerRuntime.runPromise(Deferred.make()); - const ownerReady = await managerRuntime.runPromise( - Deferred.make<{ - ownership: ControlOwnership; - info: StackInfo; - manager: ManagedStackManagerShape; - }>(), + const managerRuntime = ManagedRuntime.make( + Layer.mergeAll(managedStackManagerLayer({ stateRoot }), controlTransportLayer), ); + const ready = await managerRuntime.runPromise(Deferred.make()); const daemonReady = await managerRuntime.runPromise(Deferred.make()); let stackId = ""; - let daemonRuntime: ManagedRuntime.ManagedRuntime | undefined; + let lifecycleScope: Scope.Scope | undefined; + let ownedControl: ControlOwnership | undefined; const setup = managerRuntime.runFork( Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; const environment = yield* manager.ensureWorkspace(projectRoot); stackId = deriveStackId(environment.identity, stackName); - const ownership = yield* manager.acquireControl(stackId); + lifecycleScope = Scope.makeUnsafe(); + const supervisorLifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId: crypto.randomUUID(), + daemonCliVersion: cliVersion, + close: Effect.void, + }).pipe(Effect.provide(Layer.succeed(Scope.Scope, lifecycleScope))); + let owned: ControlOwnership | undefined; + const application = { + app: yield* makeSupervisorControlApplication(supervisorLifecycle, { + update: (id, next) => + owned === undefined + ? Effect.fail(new StackBuildError({ detail: "fixture owner is not acquired" })) + : manager.updateLaunch(owned, { stackId: id, launch: next }).pipe( + Effect.asVoid, + Effect.mapError((error) => new StackBuildError({ detail: String(error) })), + ), + }), + }; + const ownership = yield* acquireControl({ stackId, application }); if (ownership._tag !== "Owned") throw new Error("fixture failed to acquire control"); + owned = ownership; + ownedControl = ownership; + yield* supervisorLifecycle.setClose(ownership.close); const started = yield* manager.startStack({ workspacePath: projectRoot, stackName, portDocument, ownership, - lifecycle: running ? "running" : "stopped", + lifecycle: ownerState ?? (running ? "running" : "stopped"), runtime: running ? { pid: process.pid, controlEndpoint: ownership.endpoint.url, protocolVersion: 1 } : undefined, @@ -151,11 +187,17 @@ export async function makeManagedStackFixture( serviceRoleJwt: "test-service-role-jwt", serviceEndpoints: {}, }; - if (running) { - yield* Deferred.succeed(ownerReady, { ownership, info, manager }); + if (running && ownerState === undefined) { + const runtimeStack = stackService( + info, + manager.recordLifecycle(ownership, { stackId, lifecycle: "stopped" }).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ), + ); + yield* supervisorLifecycle.publishStack(runtimeStack); yield* Deferred.await(daemonReady); - yield* ownership.setState("running", true); - } else { + } else if (!running) { yield* ownership.close; } yield* Deferred.succeed(ready, void 0); @@ -164,40 +206,14 @@ export async function makeManagedStackFixture( }), ), ); - if (running) { - const owner = await managerRuntime.runPromise(Deferred.await(ownerReady)); - const daemonLayer = DaemonServer.layerWithShutdown( - Effect.forkDetach(Effect.sleep("50 millis").pipe(Effect.andThen(owner.ownership.close))).pipe( - Effect.asVoid, - ), - owner.ownership.ownerStatus, - { - includeOwnerRoute: false, - launchUpdate: (next) => - owner.manager - .updateLaunch(owner.ownership, { stackId, launch: next }) - .pipe(Effect.asVoid), - }, - ).pipe( - Layer.provide( - stackLayer( - owner.info, - owner.manager.recordLifecycle(owner.ownership, { stackId, lifecycle: "stopped" }).pipe( - Effect.asVoid, - Effect.catch(() => Effect.void), - ), - ), - ), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.ownership.server)), - ); - daemonRuntime = ManagedRuntime.make(daemonLayer); - await daemonRuntime.runPromise(DaemonServer); + if (running && ownerState === undefined) { await managerRuntime.runPromise(Deferred.succeed(daemonReady, void 0)); } await managerRuntime.runPromise(Deferred.await(ready)); const baseLayer = Layer.mergeAll( BunServices.layer, + controlTransportLayer, httpTransportClientLayer, Layer.succeed(ProjectHome, project), Layer.succeed( @@ -263,18 +279,28 @@ export async function makeManagedStackFixture( return yield* manager.inspectStack(stackId); }), ), + closeControlOwner: () => managerRuntime.runPromise(ownedControl?.close ?? Effect.void), launch, + cliVersion, async dispose() { - await managerRuntime.runPromise(Fiber.interrupt(setup)); - await daemonRuntime?.dispose(); + await managerRuntime.runPromise(Fiber.interrupt(setup).pipe(Effect.exit)); + await Effect.runPromise(Scope.close(lifecycleScope ?? Scope.makeUnsafe(), Exit.void)).catch( + () => undefined, + ); await managerRuntime.dispose(); rmSync(root, { recursive: true, force: true }); }, }; } -export const makeRunningStackFixture = (options: { stackName?: string } = {}) => - makeManagedStackFixture({ ...options, running: true }); +export const makeRunningStackFixture = ( + options: { + stackName?: string; + cliVersion?: string; + ownerState?: "starting"; + } = {}, +) => makeManagedStackFixture({ ...options, running: true }); -export const makeStoppedStackFixture = (options: { stackName?: string } = {}) => - makeManagedStackFixture({ ...options, running: false }); +export const makeStoppedStackFixture = ( + options: { stackName?: string; cliVersion?: string } = {}, +) => makeManagedStackFixture({ ...options, running: false }); diff --git a/docs/adr/0011-cli-release-and-distribution-strategy.md b/docs/adr/0011-cli-release-and-distribution-strategy.md index ff8ca3315f..d4f6ca3052 100644 --- a/docs/adr/0011-cli-release-and-distribution-strategy.md +++ b/docs/adr/0011-cli-release-and-distribution-strategy.md @@ -305,7 +305,7 @@ This section tracks the work that has landed against the pre-cutover gates. Deta : [`apps/cli/scripts/update-homebrew.ts`](../../apps/cli/scripts/update-homebrew.ts) and [`apps/cli/scripts/update-scoop.ts`](../../apps/cli/scripts/update-scoop.ts) now accept `--name ` plus the pre-existing `--repo`, `--tap`/`--bucket`, so the exact production updater code paths can be exercised against a reviewer's own `homebrew-*` / `scoop-*` repos under a non-`supabase` name (e.g., `supabase-shim-poc`). The `--name` flag controls the formula filename + Ruby class on Homebrew, and the manifest filename on Scoop, so `supabase` (stable) and `supabase-beta` can coexist as separate formulas / manifests in the same tap / bucket. The installed binary is always `supabase` (matching the Go CLI's historical behaviour — both `Formula/supabase.rb` and `Formula/supabase-beta.rb` ran `bin.install "supabase"`); PoC reviewers must `brew uninstall supabase` first if the official CLI is already installed. **Side-effect bug fix**: the Homebrew formula now installs the `supabase-go` sidecar alongside the SFE (`bin.install "supabase-go" if File.exist?("supabase-go")`) — see gate 2 above for root-cause analysis. **C. Real CLI version plumbing** (hygiene; surfaced via gate 2 validation) -: The CLI used to hard-code `"0.1.0"` for both `--version` output and telemetry. New [`apps/cli/src/shared/cli/version.ts`](../../apps/cli/src/shared/cli/version.ts) exports `CLI_VERSION = process.env.SUPABASE_CLI_VERSION ?? "0.0.0-dev"`. [`apps/cli/scripts/build.ts`](../../apps/cli/scripts/build.ts) injects the real version at compile time via `bun build --define=process.env.SUPABASE_CLI_VERSION=...` (both glibc and musl builds). [`apps/cli/src/shared/cli/run.ts`](../../apps/cli/src/shared/cli/run.ts) feeds it into Effect CLI's `Command.runWith`; [`apps/cli/src/shared/telemetry/runtime.layer.ts`](../../apps/cli/src/shared/telemetry/runtime.layer.ts) uses the same constant. +: The CLI used to hard-code `"0.1.0"` for both `--version` output and telemetry. New [`apps/cli/src/shared/cli/version.ts`](../../apps/cli/src/shared/cli/version.ts) exports `CLI_VERSION` from the compile-time-only `SUPABASE_CLI_VERSION` symbol and otherwise uses the visible `"0.0.0-dev"` source sentinel. [`apps/cli/scripts/build.ts`](../../apps/cli/scripts/build.ts) injects the immutable package version via `bun build --define=SUPABASE_CLI_VERSION=...` (both glibc and musl builds); runtime environment variables cannot change the compatibility identity. [`apps/cli/src/shared/cli/run.ts`](../../apps/cli/src/shared/cli/run.ts) feeds it into Effect CLI's `Command.runWith`; [`apps/cli/src/shared/telemetry/runtime.layer.ts`](../../apps/cli/src/shared/telemetry/runtime.layer.ts) uses the same constant. **D. Build correctness fix** : [`apps/cli/scripts/build.ts`](../../apps/cli/scripts/build.ts) now runs `go build -trimpath -ldflags="-s -w" -o ${outfile} .` with `.cwd(goSource)` instead of passing `goSource` as a positional argument. Passing an absolute path caused Go to resolve the module from the invocation CWD (the repo root, which has no `go.mod`) and fail. diff --git a/packages/stack/README.md b/packages/stack/README.md index 4a6186c5b2..ea62ce58f0 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -54,10 +54,30 @@ const runtime = ``` `daemonLayer` starts the managed supervisor and returns a remote `Stack` layer; +`restartManagedStackForUpgrade` is the explicit stop/start operation used by +`supabase start` when the owner was started by another CLI version; `connectLayer` reattaches through the deterministic control endpoint; `stopDaemon` and the discovery helpers delegate to the managed lifecycle facade. No CLI metadata file or PID polling is involved. +Managed ownership is exposed by one deterministic loopback HTTP listener. The +stable cross-build control protocol is `GET /owner` plus session-fenced +`POST /stop`; runtime operations use same-version Effect RPC over framed NDJSON +at `POST /rpc`. The complete application is installed before the listener +binds, and runtime RPC is available only after the supervisor publishes a +running lifecycle state. + +The CLI version must exactly match the daemon CLI version before a remote +runtime client is constructed. Released and preview CLI versions are immutable +and unique, so the version is the compatibility identity. An incompatible owner is never spoken to +over RPC: connect-only commands report an actionable upgrade requirement, and +only an explicit `supabase start` may preflight, stop the exact old owner +session, and start the current version. Upgrade restart preserves the managed +identity and launch metadata, data roots, runtime mode, pinned service +versions, exclusions, and sticky port assignments; it never deletes the +managed stack. Existing connections briefly disconnect during this normal +stop/start upgrade restart. + After a managed supervisor claims a stack, its persisted Docker, Podman, or native selection remains pinned even if startup later fails. Retry after restoring or starting that runtime; delete and recreate the stack to choose a diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 0ec9f3d7bc..385df2e6af 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -19,10 +19,12 @@ introduce a second service registry, repository contract, or SQLite adapter. ## Managed startup at a glance -The parent resolves the workspace identity before forking. During normal -startup, the child owns the lease, binds the control endpoint, and performs the -manager writes under that ownership. Recovery operations can acquire the same -ownership through the lifecycle facade. +The parent resolves the workspace identity before forking. The child creates a +`SupervisorLifecycle` and the complete control application before attempting +the deterministic loopback bind. Ownership is claimed before expensive +workspace reconciliation, while `/owner` and the session-fenced `/stop` route +remain available throughout startup. Runtime RPC is gated until the runtime is +published as running. ```mermaid sequenceDiagram @@ -35,21 +37,23 @@ sequenceDiagram CLI->>Parent: daemonLayer(config, port intents, launch) Parent->>Child: fork + start message (resolved stack id) + Child->>Control: assemble /owner, /stop, and /rpc application Child->>Control: acquire ownership + bind deterministic endpoint Child->>Manager: ensure workspace + verify stack id Child->>Manager: resolve document, allocate/reuse ports Child->>Manager: write starting - Child->>Runtime: build Stack, ApiProxy, and DaemonServer + Child->>Runtime: build Stack and ApiProxy Child->>Manager: write running + runtime endpoint Child-->>Parent: started(endpoint) Parent-->>CLI: RemoteStack layer - CLI->>Control: stack.start(), status, logs, or service operation + CLI->>Control: same-version Stack RPC at /rpc ``` -`running` in the managed document means that the supervisor and control owner -are ready. The service states are published by the same `Stack` runtime and -move when the caller invokes `stack.start()` or an individual service -operation. +`running` in the managed document is recorded only after +`SupervisorLifecycle` has published a ready runtime. The lifecycle is the one +atomic state projection: control is available during `starting`, while RPC +handlers read `runtimeStack` and fail fast with typed `StackUnavailableError` +until `running` (and again during shutdown). ## Public entrypoints @@ -97,10 +101,17 @@ stack. ### Concurrency and cleanup -`DaemonServer` creates one lazily-started, uninterruptible shutdown fiber in -the layer scope. Every stop or terminal-readiness caller joins that fiber, so -concurrent requests share one transaction and interrupting one caller cannot -cancel the owner. The short response-flush signal is also a scoped fiber. +`SupervisorLifecycle` owns one shutdown fiber in its scope. The fiber waits for +the first shutdown reason, then runs one uninterruptible teardown transaction. +Every stop or terminal-readiness caller joins that transaction, so concurrent +requests share its result and interrupting one caller cannot cancel the owner. +The transaction independently attempts runtime stop, runtime disposal, +ownership/listener close, and publication of `closed`; the first cleanup +failure (including the exact primary `Cause`) is returned only after all +cleanup steps have been attempted. For HTTP stop, Node and Bun close the +listener gracefully after flushing a successful `202`; the stable client +consumes that bounded response and polls the exact session fence until the +owner disappears. `StackPreparation` resolves independent services with a concurrency cap of four. Its closure includes the resources for every public graph dependency a requested @@ -138,17 +149,17 @@ runtime requests; it never edits the document directly. 1. `daemonLayer` discovers the workspace and derives the stack id before the parent forks a supervisor child. Managed-only port intents and launch metadata stay separate from the generic daemon configuration. -2. The child binds the loopback control endpoint first, re-checks workspace - discovery, and refuses to continue if the identity no longer derives the - same id. -3. The child supervisor removes stale named container resources when required; - after acquiring ownership it re-reads the existing document, selects or - validates its concrete runtime, then the manager allocates or reuses ports - and records `starting`. -4. The child builds the direct runtime and `DaemonServer`, records `running` - with its control endpoint, and sends the endpoint to the parent. -5. The parent returns a `RemoteStack` layer. The CLI then calls - `stack.start()` over the control transport when service startup is needed. +2. The child constructs lifecycle state and the complete static application, + then claims the deterministic endpoint. A bind failure never leaves a + partially installed runtime server. +3. The owner re-checks workspace identity, re-reads the document, selects or + validates its concrete runtime, and reconciles stale named resources. The + manager allocates or reuses ports and records `starting`. +4. The child builds the direct runtime, publishes it through + `SupervisorLifecycle`, records `running`, and sends the verified owner + descriptor to the parent. +5. The parent returns a `RemoteStack` layer. The CLI invokes `StackRpc` over + `POST /rpc` for runtime operations. `connectManagedStack` reads the document, probes the deterministic endpoint without binding it, and returns a `RemoteStack` only when the owner reports a @@ -158,7 +169,7 @@ endpoint; mutating operations acquire control ownership. ### Update, stop, and delete - `updateManagedLaunch` is owner-gated. An attached client posts the validated - launch payload to `/managed/launch`; the owner invokes + launch payload to the same-version `UpdateLaunch` RPC; the owner invokes `ManagedStackManager.updateLaunch`, and the caller re-reads the document. - `stopManagedStack` asks an attached owner to perform a graceful `RemoteStack.stop()`, waits for the document to become `stopped`, and lets @@ -246,14 +257,94 @@ sequence cannot yield an unambiguous owner or free endpoint. The manager reserves every known candidate against service allocation, and the document records the endpoint the owner actually bound. An exact service port can still equal a future candidate of an identity that has never started, so that -low-probability conflict is rejected when ownership is acquired rather than -forbidding every explicit port in the reserved range. - -This is deliberately a small single-user localhost mechanism. The control -protocol has no token authentication; ownership, endpoint identity, and -protocol-version checks provide the lifecycle boundary. `DaemonServer` exposes -status, service operations, logs, graceful stop, and launch-update routes; -`RemoteStack` is the typed client used by consumers. +candidate is skipped when ownership is acquired. Start fails only when the +sequence cannot distinguish an owner from an ambiguous transport failure or +find a free endpoint; explicit ports are not forbidden across the whole +reserved range. + +This is deliberately a small single-user localhost mechanism. The stable +control protocol has no token authentication; ownership, endpoint identity, +protocol-version checks, and the owner session fence provide the lifecycle +boundary. The one static application exposes only: + +- `GET /owner` for the current owner, lifecycle phase, readiness, and daemon + CLI version; +- `POST /stop` for an idempotent shutdown request containing the ownership id + and exact owner session id; and +- `POST /rpc` for same-version Effect RPC over framed NDJSON, fenced to the + expected ownership id and owner session before dispatch. + +`RemoteStack` is the thin typed RPC adapter. It never maintains a handwritten +runtime route table or stream parser. Remote stop uses the stable `/stop` route +and waits for the targeted owner session to end. + +The `/owner` payload contains the deterministic ownership id, random +`ownerSessionId`, control protocol/version, lifecycle state, readiness, and +daemon CLI version. `/stop` requires both ownership id and +session id, returns `409` for a different owner session, and returns `202` only +after the supervisor has accepted the one-shot shutdown request. The caller +then observes the targeted session until it disappears. The protocol is +session-fenced from its first supported release; there is no +legacy runtime compatibility window or second-server handoff. + +### CLI version identity and upgrade restart + +The owner response includes the daemon CLI version. Released and preview +versions are immutable and unique, so that version is the compatibility +identity. A `RemoteStack` RPC client is constructed only when the client CLI +version equals the owner CLI version. A mismatch is a typed +`DaemonUpgradeRequired`; it never becomes an attempted RPC request. Every RPC +request repeats the same owner/session fence so a client that outlives its +captured listener session is rejected before a handler runs. + +Direct source execution uses the visible `0.0.0-dev` sentinel. It is a +development mode, not a cross-checkout compatibility promise: after changing +runtime or RPC code, the developer restarts the managed stack before testing. + +Only an explicit `supabase start` may authorize an upgrade restart. It +preflights the managed document and persisted launch selection while the old +owner is live, sends the session-fenced `/stop`, waits for that exact session to +end, then starts the current version. The upgrade restart preserves the +managed stack identity and creation metadata, data roots, runtime mode, pinned +service versions, exclusions, and sticky port intents. It never invokes the +destructive delete path or silently changes launch metadata. + +The public `@supabase/stack/effect` entry exposes this authorization as +`restartManagedStackForUpgrade`. Ordinary `daemonLayer` calls cannot authorize a +restart: an incompatible owner fails with `DaemonUpgradeRequired` and remains running. + +An upgrade restart is one supervisor-owned transaction. After preflight, the +CLI emits its restart notice, then the supervisor uses the shared stable +`ControlClient` with the captured ownership and session ids, re-observes that +exact session until it has ended, and reacquires the deterministic endpoint +within a bounded timeout. Persisted +exclusions are applied to effective runtime service policies before preflight, +active-port calculation, allocation, configuration resolution, and startup; +copying them only into `stack.json` is insufficient. Once the new runtime is +up, its managed summary is authoritative for subsequent launch updates. + +Connect-only commands fail with `DaemonUpgradeRequired`: status renders a degraded +owner/document summary with an instruction to run `supabase start`, while logs, +service operations, and other runtime commands return the actionable upgrade +error. No read-only command restarts a live stack. A stop request always uses +the stable control protocol, regardless of CLI version. + +The upgrade restart is a stop/start transaction rather than a supervisor handoff. +Preflight failure leaves the old owner running; stop timeout never binds a new +owner; startup failure preserves the document and data for retry. Concurrent +ordinary starts never restart an incompatible stack, and a delayed stop +containing the old session id receives `409` from the new owner. + +### Static application and lifecycle ownership + +`SupervisorLifecycle` owns the atomic lifecycle state, owner session, runtime +publication, and one cached shutdown transaction. All shutdown sources join +that transaction. The accepted `202` stop response is flushed by the listener's +graceful close; the stable control client consumes the bounded response body and +polls the exact fenced session until it disappears. Teardown then attempts +runtime stop and disposal, ownership/listener close, and `closed` publication +while preserving the first cleanup `Cause`. Node, Bun, and compiled Bun +children use the same pre-bind application and lifecycle composition. ## Service execution and `ApiProxy` @@ -289,30 +380,36 @@ self-dispatch path; the daemon branch does not run normal CLI command dispatch. ## Component map -| Concern | Owner | -| ------------------------------------------------- | ----------------------------------------------------------------- | -| Public Promise and Effect entrypoints | `src/{node,bun,effect-node,effect-bun}.ts` | -| Identity discovery and stack id | `managed/environment.ts`, `managed/identity.ts`, `managed/git.ts` | -| Document paths, schema, and atomic persistence | `managed/paths.ts`, `managed/document.ts`, `managed/store.ts` | -| Managed reads, writes, ports, and lifecycle state | `managed/manager.ts`, `managed/lifecycle.ts`, `discovery.ts` | -| Ownership and deterministic endpoint | `managed/control.ts` | -| Detached child protocol and startup | `supervisor.ts`, `daemon-node.ts`, `daemon-bun.ts` | -| Runtime control routes and client | `DaemonServer.ts`, `RemoteStack.ts`, `HttpTransportClient.ts` | -| Direct runtime construction and service lifecycle | `createStack.ts`, `layers.ts`, `LocalStack.ts`, `Stack.ts` | -| Asset resolution and native/Docker graph | `StackPreparation.ts`, `StackBuilder.ts`, `ServiceCatalog.ts` | -| Public API routing | `ApiProxy.ts` | -| Platform listeners and process services | `platform-node.ts`, `platform-bun.ts` | +| Concern | Owner | +| ------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Public Promise and Effect entrypoints | `src/{node,bun,effect-node,effect-bun}.ts` | +| Identity discovery and stack id | `managed/environment.ts`, `managed/identity.ts`, `managed/git.ts` | +| Document paths, schema, and atomic persistence | `managed/paths.ts`, `managed/document.ts`, `managed/store.ts` | +| Managed reads, writes, ports, and lifecycle state | `managed/manager.ts`, `managed/lifecycle.ts`, `discovery.ts` | +| Ownership and deterministic endpoint | `managed/control.ts` | +| Detached child protocol and startup | `supervisor.ts`, `SupervisorUpgradeRestart.ts`, `daemon-node.ts`, `daemon-bun.ts` | +| Runtime control RPC and client | `SupervisorControlServer.ts`, `StackRpc.ts`, `RemoteStack.ts`, `HttpTransportClient.ts` | +| Direct runtime construction and service lifecycle | `createStack.ts`, `layers.ts`, `LocalStack.ts`, `Stack.ts` | +| Asset resolution and native/Docker graph | `StackPreparation.ts`, `StackBuilder.ts`, `ServiceCatalog.ts` | +| Public API routing | `ApiProxy.ts` | +| Platform listeners and process services | `platform-node.ts`, `platform-bun.ts` | ## Testing boundary Integration tests exercise the surfaces a consumer uses: manager identity and documents, sibling worktrees and nested projects, detached start/reattach, -launch updates, status and logs, graceful stop, stale-owner recovery, and -deletion. A small number of end-to-end tests cover real subprocess and runtime +launch updates through RPC, status and logs, graceful session-fenced stop, +stale-owner recovery, and deletion. They also cover control before runtime +construction, real HTTP/NDJSON unary and stream calls, stream cancellation, +CLI version mismatch, upgrade restart and preservation (including actual +excluded-service behavior and sticky-port reuse), concurrent lifecycle +requests, cleanup after cancellation or failure, and response flush before +close. Node and Bun control adapters share conflict classification, and a small +number of end-to-end tests cover Node, Bun, and compiled-Bun subprocess boundaries. Unit tests are reserved for pure identity, port, document, projection, and platform algorithms or for branches unreachable through the public runtime -surface. The testing entrypoint exposes only the `DaemonServer` and transport +surface. The testing entrypoint exposes only the static control application and transport seams needed to build those journeys; it does not recreate a repository, SQLite adapter, or contract-fixture implementation. diff --git a/packages/stack/docs/resource-leak-mitigations.md b/packages/stack/docs/resource-leak-mitigations.md index 644f91079f..b6a27d04e7 100644 --- a/packages/stack/docs/resource-leak-mitigations.md +++ b/packages/stack/docs/resource-leak-mitigations.md @@ -19,21 +19,47 @@ write a second StateManager metadata file. ## Detached owner cleanup -The managed supervisor owns the port lease, service processes, and local control -endpoint. It records `starting`, `running`, `failed`, and `stopped` in -`stack.json`. Graceful stop calls the owner through `RemoteStack` and waits for -the document to become `stopped` before a caller may delete it. +The managed supervisor owns the port lease, service processes, and one complete +loopback HTTP application. It records `starting`, `running`, `failed`, and +`stopped` in `stack.json`. `SupervisorLifecycle` owns one atomic state and one +cached shutdown transaction; stop requests from HTTP, signals, parent IPC, +startup failure, and explicit disposal all join that transaction. + +The application is assembled before the deterministic listener binds and has +only three routes: + +- `GET /owner` projects the lifecycle state, readiness, owner session, and + daemon CLI version; +- `POST /stop` accepts an ownership id and exact owner session id, returns a + flushed `202`, and lets the caller wait for that session to end; and +- `POST /rpc` serves same-version Effect RPC over framed NDJSON when + `SupervisorLifecycle.runtimeStack` has published the runtime. Requests carry + the expected ownership id and owner session; a stale session fence is + rejected before a handler runs. Before runtime publication, handlers + fail fast with typed `StackUnavailableError`. + +Graceful remote stop therefore uses the stable session-fenced control route, +waits for the targeted owner session and document transition, then lets the +owner dispose the runtime before releasing control. A stale delayed stop gets +`409` from the new owner and cannot tear it down. + +Every shutdown source joins one cached lifecycle transaction. Once accepted, +the transaction always attempts runtime stop, runtime disposal, +ownership/listener close, and `closed` publication, even when an earlier step +fails. It preserves the first cleanup failure and its exact `Cause` after all +steps have run. Node and Bun close listeners gracefully after flushing the +accepted `202`; the stable client drains that response and then polls the exact +session fence, so listener shutdown cannot be stranded by an unread body. If the owner is gone, the next lifecycle operation acquires control for the stack id, force-removes deterministic Docker containers, reconciles persisted assignments, and records `stopped`. It does not probe PIDs or trust stale -runtime artifacts. -The control endpoint is deterministic from the stack id and is validated before -an attached client connects. +runtime artifacts. The control endpoint is deterministic from the stack id and +is validated before an attached client connects. -The child uses `DaemonServer` over the deterministic loopback TCP control -transport. The endpoint is runtime coordination state, not the public API -proxy URL. +The endpoint is runtime coordination state, not the public API proxy URL. Node, +Bun, and compiled-Bun children use the same static application and lifecycle +composition; the same server owns every lifecycle phase. ## Process supervision @@ -57,9 +83,13 @@ waits for disposal to begin before interrupting the main Effect. Direct ## Regression coverage Integration tests cover manager port/document cleanup, detached supervisor -startup/reattach/launch-update/stop, stale-owner recovery, and delete. The -process-compose and stack suites cover supervised child trees, Docker cleanup -hooks, and one-shot exit observation. Leak helpers compare managed document and +startup/reattach/launch-update over RPC, stop during every startup phase, +session-fenced stop, upgrade restart with actual +excluded-service and sticky-port preservation, cancellation, failed-step +cleanup, and delete. The process-compose and stack suites cover supervised +child trees, Docker cleanup hooks, one-shot exit observation, and +Node/Bun/compiled-Bun re-entry. Node and Bun control adapters exercise the +same conflict classification. Leak helpers compare managed document and runtime roots, temporary Postgres paths, processes, and containers before and after each journey. diff --git a/packages/stack/docs/service-versioning.md b/packages/stack/docs/service-versioning.md index b55d32eeae..237caf3c43 100644 --- a/packages/stack/docs/service-versioning.md +++ b/packages/stack/docs/service-versioning.md @@ -35,9 +35,10 @@ The managed document is stored under the global CLI home: It contains the stack identity, assigned ports and intents, lifecycle, runtime control endpoint, and launch metadata. There is no second state or metadata file. Start, status, logs, update, -services, and stop all go through the managed lifecycle facade and its control protocol. A running -document without an owned control endpoint is stale and can be reclaimed by the next lifecycle -operation. +services, and stop all go through the managed lifecycle facade. The stable cross-build control +protocol is `GET /owner` plus session-fenced `POST /stop`; runtime operations use same-version +Effect RPC over HTTP/NDJSON at `POST /rpc`. A running document without an owned control endpoint +is stale and can be reclaimed by the next lifecycle operation. ## Built-in defaults and remote versions @@ -87,20 +88,26 @@ Start resolves the candidate versions, applies local and command-line overrides, resulting launch selection in the managed document. Starting an existing stack reuses its persisted launch baseline unless an explicit update or override changes it. Port intent is read from the raw project config before defaults are applied so automatic and exact values remain distinguishable. +After startup, the managed summary is authoritative for launch updates: the caller must not +overwrite persisted mode, pinned versions, exclusions, or sticky port assignments with defaults from +the new CLI build. ### `supabase stack status` -Status reads the managed document and acquires its control ownership before reporting a running -stack. This prevents a crashed process from being presented as live. It compares the persisted -launch baseline with the current candidate versions and reports when `supabase stack update` can -adopt newer linked or default versions. +Status reads the managed document and probes `/owner` before reporting a running stack. When the +owner CLI version matches, it may use the runtime RPC projection for detailed service state. A mismatched +owner is reported as a degraded owner/document summary with an instruction to run `supabase start`; +status never restarts a live stack and does not attempt runtime RPC against the mismatched version. It +compares the persisted launch baseline with the current candidate versions and reports when +`supabase stack update` can adopt newer linked or default versions. ### `supabase stack update` Update refreshes the linked cache when the project is linked, computes the candidate baseline, and -updates `launch.versions` through the managed control route when the stack is running. A stopped -stack is updated directly through the manager. It does not maintain a project-level copy of pinned -versions and does not restart the runtime. +updates `launch.versions` through the same-version `UpdateLaunch` RPC when the stack is running. A +stopped stack is updated directly through the manager. It does not maintain a project-level copy of +pinned versions and does not restart the runtime. If the owner CLI version differs, update fails with an +upgrade-required diagnostic rather than restarting the stack. ### `supabase stop` @@ -129,7 +136,16 @@ Values in `.supabase/local-versions.json` override the candidate baseline for th ### CLI upgrades New stacks can adopt newer catalog defaults immediately. Existing stacks remain pinned until update -changes their managed launch metadata. +changes their managed launch metadata. When `supabase start` encounters an incompatible live owner, +it performs an explicit stop/start upgrade restart after preflight. The restart is authorized only by that +explicit operation: it preflights while the old owner is live, stops the exact captured session through +the stable `ControlClient`, re-observes it to completion, and reacquires ownership within bounded time. +Persisted exclusions are reapplied to effective runtime +service policies before preflight, active-port calculation, allocation, configuration resolution, and +startup—not merely copied into `stack.json`. The upgrade restart preserves durable stack identity and +creation metadata, data roots, runtime mode and container runtime, pinned service versions, +exclusions, and sticky port assignments. It never invokes destructive deletion. Connect-only commands +never restart the stack; they report the upgrade requirement instead. ### Team collaboration diff --git a/packages/stack/src/ControlHttpReader.ts b/packages/stack/src/ControlHttpReader.ts new file mode 100644 index 0000000000..76d09bf0e0 --- /dev/null +++ b/packages/stack/src/ControlHttpReader.ts @@ -0,0 +1,160 @@ +import * as Http from "node:http"; +import { Effect } from "effect"; +import { + CONTROL_STATUS_PATH, + ControlProtocolError, + ControlTransportError, + type ControlEndpoint, + type ControlOwnerReader, +} from "./managed/control.ts"; + +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; + +const errorCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return errorCode(cause.cause); + return undefined; +}; + +const readError = ( + endpoint: ControlEndpoint, + cause: unknown, +): ControlTransportError | ControlProtocolError => { + const code = errorCode(cause); + if ( + cause instanceof SyntaxError || + code?.startsWith("HPE_") === true || + (cause instanceof Error && + cause.message === `Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`) || + (cause instanceof Error && cause.message.startsWith("Control status request returned")) + ) { + return new ControlProtocolError({ endpoint, cause }); + } + return new ControlTransportError({ + endpoint, + reason: code === "ECONNREFUSED" ? "unreachable" : "transport", + cause, + }); +}; + +/** Protocol-aware owner reader shared by the Node and Bun control transports. */ +export const readControlOwner: ControlOwnerReader = (endpoint) => + Effect.callback((resume) => { + let response: Http.IncomingMessage | undefined; + let onData: ((chunk: string) => void) | undefined; + let onEnd: (() => void) | undefined; + let onResponseError: ((cause: Error) => void) | undefined; + let onResponseAborted: (() => void) | undefined; + let onResponseClose: (() => void) | undefined; + let settled = false; + let cleanup = () => {}; + let dispose = () => {}; + const finish = (effect: Effect.Effect, shouldDispose = false) => { + if (settled) return; + settled = true; + cleanup(); + if (shouldDispose) dispose(); + resume(effect); + }; + const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const request = Http.request( + { + host: endpoint.hostname, + port: endpoint.port, + path: CONTROL_STATUS_PATH, + method: "GET", + // One-shot connection: a pooled keep-alive connection would let a + // closed listener keep answering status probes while the probes + // themselves keep the connection alive. + agent: false, + }, + (incoming) => { + response = incoming; + let body = ""; + let bodyBytes = 0; + let ended = false; + let responseAborted = false; + onData = (chunk) => { + bodyBytes += Buffer.byteLength(chunk, "utf8"); + if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { + finish( + Effect.fail( + new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), + ), + true, + ); + return; + } + body += chunk; + }; + onEnd = () => { + ended = true; + if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { + finish( + Effect.fail( + new Error(`Control status request returned ${incoming.statusCode ?? 500}`), + ), + true, + ); + return; + } + try { + finish(Effect.succeed(JSON.parse(body))); + } catch (cause) { + finish(Effect.fail(cause), true); + } + }; + onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseAborted = () => { + responseAborted = true; + }; + onResponseClose = () => { + if (responseAborted || !ended) { + finish(Effect.fail(new Error("Control status response closed before end")), true); + } + }; + incoming.setEncoding("utf8"); + incoming.on("data", onData); + incoming.once("end", onEnd); + incoming.once("error", onResponseError); + incoming.once("aborted", onResponseAborted); + incoming.once("close", onResponseClose); + }, + ); + dispose = () => { + response?.destroy(); + request.destroy(); + }; + cleanup = () => { + request.removeListener("error", onRequestError); + if (response !== undefined) { + if (onData !== undefined) response.removeListener("data", onData); + if (onEnd !== undefined) response.removeListener("end", onEnd); + if (onResponseError !== undefined) response.removeListener("error", onResponseError); + if (onResponseAborted !== undefined) response.removeListener("aborted", onResponseAborted); + if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); + } + }; + request.once("error", onRequestError); + request.end(); + return Effect.callback((resumeCancellation) => { + const onClose = () => { + cleanup(); + resumeCancellation(Effect.void); + }; + settled = true; + request.once("close", onClose); + dispose(); + return Effect.sync(() => { + request.removeListener("close", onClose); + cleanup(); + }); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: 500, + orElse: () => Effect.fail(new Error("Control status request timed out")), + }), + Effect.mapError((cause) => readError(endpoint, cause)), + ); diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts index 3098e2b89e..c3beed3f41 100644 --- a/packages/stack/src/DaemonProtocol.ts +++ b/packages/stack/src/DaemonProtocol.ts @@ -1,20 +1,6 @@ import { Schema } from "effect"; -const DaemonErrorCodeSchema = Schema.Literals([ - "SERVICE_NOT_FOUND", - "SERVICE_NOT_READY", - "STACK_READINESS_TIMEOUT", - "STACK_BUILD_ERROR", - "STACK_NOT_RUNNING", -]); - -const StackBuildReasonSchema = Schema.Literals([ - "invalid_config", - "docker_not_running", - "asset_preparation", -]); - -const ControlOwnerStateSchema = Schema.Literals([ +export const ControlOwnerStateSchema = Schema.Literals([ "starting", "running", "stopping", @@ -22,25 +8,30 @@ const ControlOwnerStateSchema = Schema.Literals([ "failed", ]); +export const CONTROL_PROTOCOL = "supabase-stack-control" as const; +export const CONTROL_PROTOCOL_VERSION = 1 as const; + export type ControlOwnerState = typeof ControlOwnerStateSchema.Type; -export const ControlOwnerStatusSchema = Schema.Struct({ - protocolVersion: Schema.Literal(1), +export const ControlOwnerDescriptorSchema = Schema.Struct({ + controlProtocol: Schema.Literal(CONTROL_PROTOCOL), + controlProtocolVersion: Schema.Literal(CONTROL_PROTOCOL_VERSION), ownershipId: Schema.String, + ownerSessionId: Schema.String, + daemonCliVersion: Schema.String, +}); + +export const ControlOwnerStatusSchema = Schema.Struct({ + ...ControlOwnerDescriptorSchema.fields, state: ControlOwnerStateSchema, ready: Schema.Boolean, }); export type ControlOwnerStatus = typeof ControlOwnerStatusSchema.Type; -export const DaemonErrorResponseSchema = Schema.Struct({ - code: DaemonErrorCodeSchema, - error: Schema.String, - service: Schema.optionalKey(Schema.String), - exitCode: Schema.optionalKey(Schema.Number), - timeoutMs: Schema.optionalKey(Schema.Number), - phase: Schema.optionalKey(Schema.String), - reason: Schema.optionalKey(StackBuildReasonSchema), +export const ControlStopRequestSchema = Schema.Struct({ + ownershipId: Schema.String, + ownerSessionId: Schema.String, }); -export type DaemonErrorResponse = typeof DaemonErrorResponseSchema.Type; +export type ControlStopRequest = typeof ControlStopRequestSchema.Type; diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts deleted file mode 100644 index 06dc721a90..0000000000 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ /dev/null @@ -1,607 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { ServiceNotFoundError, type LogEntry } from "@supabase/process-compose"; -import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Predicate, Stream } from "effect"; -import { HttpServer } from "effect/unstable/http"; -import * as http from "node:http"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { DaemonServer } from "./DaemonServer.ts"; -import { StackReadinessError } from "./errors.ts"; -import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; -import { Stack, type StackInfo } from "./Stack.ts"; -import { StackServiceState } from "./StackServiceState.ts"; - -// --------------------------------------------------------------------------- -// Test fixtures -// --------------------------------------------------------------------------- - -const MOCK_INFO: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "pk_test", - secretKey: "sk_test", - anonJwt: "anon_jwt", - serviceRoleJwt: "service_role_jwt", - serviceEndpoints: {}, -}; - -const POSTGRES_STATE = new StackServiceState({ - name: "postgres", - status: "Running", - pid: 1234, - exitCode: null, - restartCount: 0, - startedAt: Date.now(), - error: null, -}); - -const HEALTH_FAILED_STATE = new StackServiceState({ - name: "edge-runtime", - status: "Failed", - pid: null, - exitCode: null, - restartCount: 2, - startedAt: Date.now(), - error: "Health check failed and restart budget was exhausted", -}); - -const MOCK_STATES: ReadonlyArray = [POSTGRES_STATE, HEALTH_FAILED_STATE]; - -const MOCK_LOGS: ReadonlyArray = [ - { timestamp: 1000, service: "postgres", stream: "stdout", line: "starting" }, - { timestamp: 1001, service: "postgres", stream: "stdout", line: "ready" }, - { timestamp: 1002, service: "auth", stream: "stdout", line: "auth started" }, -]; - -// --------------------------------------------------------------------------- -// Mock Stack -// --------------------------------------------------------------------------- - -function mockStack( - options: { - readonly startTimeoutMs?: number; - readonly stopEffect?: Effect.Effect; - } = {}, -) { - let stopped = false; - let stopCalls = 0; - const serviceCalls: string[] = []; - const functionReloads: FunctionsReloadConfig[] = []; - - const layer = Layer.succeed(Stack, { - getInfo: () => Effect.succeed(MOCK_INFO), - start: () => - options.startTimeoutMs === undefined - ? Effect.void - : Effect.fail( - new StackReadinessError({ - target: "stack", - timeoutMs: options.startTimeoutMs, - detail: `Timed out waiting for stack readiness after ${options.startTimeoutMs}ms`, - }), - ), - stop: () => - Effect.gen(function* () { - stopped = true; - stopCalls += 1; - if (options.stopEffect !== undefined) yield* options.stopEffect; - }), - dispose: () => - Effect.sync(() => { - stopped = true; - }), - startService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`start:${name}`); - }), - stopService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`stop:${name}`); - }), - restartService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`restart:${name}`); - }), - reloadFunctions: (config) => - Effect.sync(() => { - functionReloads.push(config ?? {}); - serviceCalls.push("reload-functions"); - }), - reloadEdgeRuntime: () => - Effect.sync(() => { - serviceCalls.push("reload-edge-runtime"); - }), - getState: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.succeed(POSTGRES_STATE), - getAllStates: () => Effect.succeed(MOCK_STATES), - stateChanges: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.succeed(Stream.fromIterable(MOCK_STATES)), - allStateChanges: () => Stream.fromIterable(MOCK_STATES), - waitReady: (name: string) => - name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) : Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: (name: string) => - Stream.fromIterable(MOCK_LOGS.filter((l) => l.service === name)), - subscribeAllLogs: (services?: ReadonlyArray) => - Stream.fromIterable( - services === undefined || services.length === 0 - ? MOCK_LOGS - : MOCK_LOGS.filter((l) => services.includes(l.service)), - ), - logHistory: (name: string, limit?: number) => - Effect.succeed(MOCK_LOGS.filter((l) => l.service === name).slice(-(limit ?? 100))), - logHistoryAll: (limit?: number, services?: ReadonlyArray) => - Effect.succeed( - (services === undefined || services.length === 0 - ? MOCK_LOGS - : MOCK_LOGS.filter((l) => services.includes(l.service)) - ).slice(-(limit ?? 100)), - ), - }); - - return { - layer, - get stopped() { - return stopped; - }, - get stopCalls() { - return stopCalls; - }, - serviceCalls, - functionReloads, - }; -} - -const functionsBundle: ResolvedFunctionsBundle = { - env: { SHARED_SECRET: "shared-secret-value" }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: "/project/supabase/functions/hello/index.ts", - importMapPath: null, - staticFiles: [], - env: { FUNCTION_SECRET: "function-secret-value" }, - }, - ], -}; - -// --------------------------------------------------------------------------- -// Layer builder -// --------------------------------------------------------------------------- - -function buildDaemonLayer( - mock: ReturnType, - beforeShutdown: Effect.Effect = Effect.void, -): Layer.Layer { - return DaemonServer.layerWithShutdown(beforeShutdown).pipe( - Layer.provide(mock.layer), - Layer.provide(NodeHttpServer.layer(() => http.createServer(), { port: 0 }).pipe(Layer.orDie)), - ) as Layer.Layer; -} - -function getUrl(address: HttpServer.Address): string { - if (Predicate.isTagged(address, "TcpAddress")) { - const host = address.hostname === "0.0.0.0" ? "127.0.0.1" : address.hostname; - return `http://${host}:${address.port}`; - } - throw new Error("Unexpected address type"); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("DaemonServer", () => { - let url: string; - let runtime: ManagedRuntime.ManagedRuntime; - let mock: ReturnType; - - beforeAll(async () => { - mock = mockStack(); - runtime = ManagedRuntime.make(buildDaemonLayer(mock)); - const daemon = await runtime.runPromise(DaemonServer); - url = getUrl(daemon.address); - }); - - afterAll(async () => { - await runtime.dispose(); - }); - - // ------------------------------------------------------------------------- - // Health - // ------------------------------------------------------------------------- - - test("GET /health returns 200 OK", async () => { - const res = await fetch(`${url}/health`); - expect(res.status).toBe(200); - expect(await res.text()).toBe("OK"); - }); - - // ------------------------------------------------------------------------- - // Status - // ------------------------------------------------------------------------- - - test("GET /status returns info and service states", async () => { - const res = await fetch(`${url}/status`); - expect(res.status).toBe(200); - const body = (await res.json()) as { info: StackInfo; services: StackServiceState[] }; - expect(body.info).toEqual(MOCK_INFO); - expect(body.services).toHaveLength(2); - expect(body.services.at(0)?.name).toBe("postgres"); - expect(body.services.at(0)?.status).toBe("Running"); - expect(body.services.at(1)).toMatchObject({ - name: "edge-runtime", - status: "Failed", - pid: null, - exitCode: null, - error: "Health check failed and restart budget was exhausted", - }); - }); - - // ------------------------------------------------------------------------- - // Status stream (SSE) - // ------------------------------------------------------------------------- - - test("GET /status/stream returns SSE events", async () => { - const res = await fetch(`${url}/status/stream`); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("text/event-stream"); - const text = await res.text(); - expect(text).toContain("event: state"); - expect(text).toContain("postgres"); - }); - - // ------------------------------------------------------------------------- - // Logs - // ------------------------------------------------------------------------- - - test("GET /logs returns SSE log events for all services", async () => { - const res = await fetch(`${url}/logs`); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("text/event-stream"); - const text = await res.text(); - expect(text).toContain("event: log"); - expect(text).toContain("starting"); - expect(text).toContain("auth started"); - }); - - test("GET /logs filters SSE log events by repeated service query params", async () => { - const res = await fetch(`${url}/logs?service=auth`); - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toContain("auth started"); - expect(text).not.toContain("starting"); - }); - - test("GET /logs/:service returns SSE log events for one service", async () => { - const res = await fetch(`${url}/logs/postgres`); - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toContain("starting"); - expect(text).toContain("ready"); - expect(text).not.toContain("auth started"); - }); - - // ------------------------------------------------------------------------- - // Log history - // ------------------------------------------------------------------------- - - test("GET /logs/:service/history returns JSON log entries", async () => { - const res = await fetch(`${url}/logs/postgres/history`); - expect(res.status).toBe(200); - const body = (await res.json()) as LogEntry[]; - expect(body).toHaveLength(2); - expect(body.at(0)?.line).toBe("starting"); - expect(body.at(1)?.line).toBe("ready"); - }); - - test("GET /logs/:service/history respects limit param", async () => { - const res = await fetch(`${url}/logs/postgres/history?limit=1`); - expect(res.status).toBe(200); - const body = (await res.json()) as LogEntry[]; - expect(body).toHaveLength(1); - expect(body.at(0)?.line).toBe("ready"); - }); - - test("GET /logs/history returns merged log entries", async () => { - const res = await fetch(`${url}/logs/history?limit=3`); - expect(res.status).toBe(200); - const body = (await res.json()) as LogEntry[]; - expect(body).toHaveLength(3); - expect(body.map((entry) => entry.line)).toEqual(["starting", "ready", "auth started"]); - }); - - test("GET /logs/history respects repeated service filters", async () => { - const res = await fetch(`${url}/logs/history?service=auth`); - expect(res.status).toBe(200); - const body = (await res.json()) as LogEntry[]; - expect(body).toHaveLength(1); - expect(body.at(0)?.service).toBe("auth"); - }); - - // ------------------------------------------------------------------------- - // Per-service control - // ------------------------------------------------------------------------- - - test("POST /services/:name/start returns 200", async () => { - const res = await fetch(`${url}/services/postgres/start`, { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - expect(mock.serviceCalls).toContain("start:postgres"); - }); - - test("POST /services/:name/stop returns 200", async () => { - const res = await fetch(`${url}/services/postgres/stop`, { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - expect(mock.serviceCalls).toContain("stop:postgres"); - }); - - test("POST /services/:name/restart returns 200", async () => { - const res = await fetch(`${url}/services/postgres/restart`, { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - expect(mock.serviceCalls).toContain("restart:postgres"); - }); - - test("POST readiness routes validate the shared override representation", async () => { - const stackReady = await fetch(`${url}/ready`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "inherit" }), - }); - expect(stackReady.status).toBe(200); - - const serviceReady = await fetch(`${url}/services/postgres/ready`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "finite", timeoutMs: 100 }), - }); - expect(serviceReady.status).toBe(200); - - const malformedStackReady = await fetch(`${url}/ready`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "finite", timeoutMs: 0 }), - }); - expect(malformedStackReady.status).toBe(400); - expect(await malformedStackReady.json()).toEqual({ - code: "STACK_BUILD_ERROR", - error: "Invalid readiness options", - }); - - const malformedServiceReady = await fetch(`${url}/services/postgres/ready`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "finite", timeoutMs: 0 }), - }); - expect(malformedServiceReady.status).toBe(400); - expect(await malformedServiceReady.json()).toEqual({ - code: "STACK_BUILD_ERROR", - error: "Invalid readiness options", - }); - }); - - test("POST /edge-runtime/reload returns 200", async () => { - const res = await fetch(`${url}/edge-runtime/reload`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ edgeRuntime: { policy: "oneshot" } }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - expect(mock.serviceCalls).toContain("reload-edge-runtime"); - }); - - test("POST /functions/reload validates and forwards its JSON body", async () => { - const res = await fetch(`${url}/functions/reload`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ functions: functionsBundle }), - }); - - expect(res.status).toBe(200); - expect(mock.functionReloads).toContainEqual({ functions: functionsBundle }); - }); - - test("reload validation never renders resolved environment values", async () => { - const secret = "must-not-appear-in-errors"; - const res = await fetch(`${url}/functions/reload`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - functions: { - env: { SECRET: secret }, - functions: [ - { - ...functionsBundle.functions[0], - entrypointPath: "relative/index.ts", - }, - ], - }, - }), - }); - const responseText = await res.text(); - - expect(res.status).toBe(400); - expect(responseText).toContain("Invalid Edge Functions reload payload"); - expect(JSON.parse(responseText)).toMatchObject({ code: "STACK_BUILD_ERROR" }); - expect(responseText).not.toContain(secret); - expect(responseText).not.toContain("relative/index.ts"); - }); - - // ------------------------------------------------------------------------- - // Error cases — service not found - // ------------------------------------------------------------------------- - - test("POST /services/:name/start returns 404 for unknown service", async () => { - const res = await fetch(`${url}/services/unknown/start`, { method: "POST" }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("unknown"); - }); - - test("POST /services/:name/stop returns 404 for unknown service", async () => { - const res = await fetch(`${url}/services/unknown/stop`, { method: "POST" }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("unknown"); - }); - - test("POST /services/:name/restart returns 404 for unknown service", async () => { - const res = await fetch(`${url}/services/unknown/restart`, { method: "POST" }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("unknown"); - }); - - test("a startup readiness timeout returns the typed failure and shuts down the daemon", async () => { - const freshRuntime = ManagedRuntime.make(buildDaemonLayer(mockStack({ startTimeoutMs: 75 }))); - try { - const daemon = await freshRuntime.runPromise(DaemonServer); - const shutdownPromise = freshRuntime.runPromise(daemon.awaitShutdown); - const response = await fetch(`${getUrl(daemon.address)}/start`, { method: "POST" }); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - code: "STACK_READINESS_TIMEOUT", - error: "Timed out waiting for stack readiness after 75ms", - service: "stack", - timeoutMs: 75, - }); - await shutdownPromise; - } finally { - await freshRuntime.dispose(); - } - }); - - // ------------------------------------------------------------------------- - // Stop (tested last since it modifies daemon state) - // ------------------------------------------------------------------------- - - test("POST /stop calls stack.stop and returns 200", async () => { - expect(mock.stopped).toBe(false); - const res = await fetch(`${url}/stop`, { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - expect(mock.stopped).toBe(true); - }); - - test("concurrent POST /stop requests share one shutdown", async () => { - const concurrentMock = mockStack(); - const concurrentRuntime = ManagedRuntime.make( - buildDaemonLayer(concurrentMock, Effect.sleep("20 millis")), - ); - const concurrentDaemon = await concurrentRuntime.runPromise(DaemonServer); - const concurrentUrl = getUrl(concurrentDaemon.address); - try { - const responses = await Promise.all([ - fetch(`${concurrentUrl}/stop`, { method: "POST" }), - fetch(`${concurrentUrl}/stop`, { method: "POST" }), - ]); - expect(responses.map((response) => response.status)).toEqual([200, 200]); - expect(concurrentMock.stopCalls).toBe(1); - } finally { - await concurrentRuntime.dispose(); - } - }); - - test("an interrupted shutdown caller cannot strand the cached transaction", async () => { - const stopEntered = await Effect.runPromise(Deferred.make()); - const releaseStop = await Effect.runPromise(Deferred.make()); - const gatedMock = mockStack({ - stopEffect: Effect.gen(function* () { - yield* Deferred.succeed(stopEntered, void 0); - yield* Deferred.await(releaseStop); - }), - }); - const gatedRuntime = ManagedRuntime.make(buildDaemonLayer(gatedMock)); - try { - const daemon = await gatedRuntime.runPromise(DaemonServer); - const first = gatedRuntime.runFork(daemon.beginShutdown); - await gatedRuntime.runPromise(Deferred.await(stopEntered)); - const interrupt = gatedRuntime.runFork(Fiber.interrupt(first)); - await gatedRuntime.runPromise(Deferred.succeed(releaseStop, void 0)); - - await gatedRuntime.runPromise(Fiber.join(interrupt)); - await gatedRuntime.runPromise(daemon.beginShutdown); - await gatedRuntime.runPromise(daemon.awaitShutdown); - expect(gatedMock.stopCalls).toBe(1); - } finally { - await gatedRuntime.dispose(); - } - }); - - test("POST /stop unregisters the daemon before responding", async () => { - const freshMock = mockStack(); - let registered = true; - const freshRuntime = ManagedRuntime.make( - buildDaemonLayer( - freshMock, - Effect.sync(() => { - registered = false; - }), - ), - ); - try { - const daemon = await freshRuntime.runPromise(DaemonServer); - const res = await fetch(`${getUrl(daemon.address)}/stop`, { method: "POST" }); - - expect(res.status).toBe(200); - expect(registered).toBe(false); - } finally { - await freshRuntime.dispose(); - } - }); - - test("POST /stop resolves awaitShutdown", async () => { - // Use a fresh runtime so /stop hasn't been called yet - const freshMock = mockStack(); - const freshRuntime = ManagedRuntime.make(buildDaemonLayer(freshMock)); - try { - const daemon = await freshRuntime.runPromise(DaemonServer); - const freshUrl = getUrl(daemon.address); - - // Start waiting for shutdown - const shutdownPromise = freshRuntime.runPromise(daemon.awaitShutdown); - - // Trigger stop - await fetch(`${freshUrl}/stop`, { method: "POST" }); - - // awaitShutdown should resolve - await shutdownPromise; - } finally { - await freshRuntime.dispose(); - } - }); - - test("POST /stop resolves awaitShutdown when cleanup defects", async () => { - const freshRuntime = ManagedRuntime.make( - buildDaemonLayer(mockStack(), Effect.die("state cleanup failed")), - ); - try { - const daemon = await freshRuntime.runPromise(DaemonServer); - const shutdownPromise = freshRuntime.runPromise(daemon.awaitShutdown); - - await fetch(`${getUrl(daemon.address)}/stop`, { method: "POST" }); - await shutdownPromise; - } finally { - await freshRuntime.dispose(); - } - }); -}); diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts deleted file mode 100644 index 6c5779d5a9..0000000000 --- a/packages/stack/src/DaemonServer.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { Deferred, Effect, Fiber, Layer, Context, Stream } from "effect"; -import { - Headers, - HttpRouter, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import * as Sse from "effect/unstable/encoding/Sse"; -import type { ControlOwnerStatus, DaemonErrorResponse } from "./DaemonProtocol.ts"; -import { FunctionsReloadConfigSchema } from "./functions.ts"; -import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; -import { ReadyOptionsSchema } from "./StackConfig.ts"; -import { - managedStackLaunchUpdateSchema, - type ManagedStackLaunchUpdate, -} from "./managed/document.ts"; - -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -export class DaemonServer extends Context.Service< - DaemonServer, - { - readonly address: HttpServer.Address; - readonly beginShutdown: Effect.Effect; - readonly awaitShutdown: Effect.Effect; - } ->()("stack/DaemonServer") { - static layerWithShutdown = ( - beforeShutdown: Effect.Effect = Effect.void, - ownerStatus: Effect.Effect = Effect.succeed({ - protocolVersion: 1, - ownershipId: "unbound", - state: "running", - ready: true, - }), - options: { - readonly includeOwnerRoute?: boolean; - readonly launchUpdate?: (launch: ManagedStackLaunchUpdate) => Effect.Effect; - /** Supervisor-owned shutdown callbacks already stop the local stack. */ - readonly stopOnShutdown?: boolean; - } = {}, - ): Layer.Layer => - Layer.effect( - this, - Effect.gen(function* () { - const stack = yield* Stack; - const server = yield* HttpServer.HttpServer; - const scope = yield* Effect.scope; - const shutdownDeferred = yield* Deferred.make(); - const textEncoder = new TextEncoder(); - const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 409 | 500) => - HttpServerResponse.jsonUnsafe(body, { status }); - const notFoundResponse = (name: string) => - errorResponse( - { code: "SERVICE_NOT_FOUND", error: `Service not found: ${name}`, service: name }, - 404, - ); - const notReadyResponse = (name: string, reason: string, exitCode?: number) => - errorResponse( - { - code: "SERVICE_NOT_READY", - error: reason, - service: name, - ...(exitCode === undefined ? {} : { exitCode }), - }, - 500, - ); - const buildErrorResponse = (detail: string, reason?: DaemonErrorResponse["reason"]) => - errorResponse( - { - code: "STACK_BUILD_ERROR", - error: detail, - ...(reason === undefined ? {} : { reason }), - }, - 500, - ); - const notRunningResponse = (phase: string) => - errorResponse( - { - code: "STACK_NOT_RUNNING", - error: `Stack is not running (phase: ${phase})`, - phase, - }, - 409, - ); - const invalidReloadPayloadResponse = () => - errorResponse( - { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, - 400, - ); - const invalidReadinessOptionsResponse = () => - errorResponse({ code: "STACK_BUILD_ERROR", error: "Invalid readiness options" }, 400); - const readinessTimeoutResponse = (target: string, timeoutMs: number, detail: string) => - errorResponse( - { - code: "STACK_READINESS_TIMEOUT", - error: detail, - service: target, - timeoutMs, - }, - 500, - ); - const shutdownTransaction = Effect.uninterruptible( - Effect.gen(function* () { - if (options.stopOnShutdown !== false) yield* stack.stop(); - yield* beforeShutdown; - }).pipe( - Effect.ensuring( - // The HTTP module has no response-flushed hook. Delay the process - // shutdown signal long enough for the final JSON response to leave - // the socket. - Deferred.succeed(shutdownDeferred, void 0).pipe( - Effect.delay("25 millis"), - Effect.forkIn(scope, { startImmediately: true, uninterruptible: true }), - Effect.asVoid, - ), - ), - ), - ); - const shutdownFiber = yield* Effect.cached( - Effect.uninterruptible( - Effect.forkIn(shutdownTransaction, scope, { - startImmediately: true, - uninterruptible: true, - }), - ), - ); - const beginShutdown = shutdownFiber.pipe(Effect.flatMap(Fiber.join)); - const terminalReadinessResponse = (target: string, timeoutMs: number, detail: string) => - beginShutdown.pipe(Effect.as(readinessTimeoutResponse(target, timeoutMs, detail))); - - // Helper: wrap an Effect Stream as a text/event-stream response - const sseResponse = ( - stream: Stream.Stream, - event: string, - toData: (a: A) => string, - ): HttpServerResponse.HttpServerResponse => - HttpServerResponse.stream( - stream.pipe( - Stream.map((a) => - textEncoder.encode( - Sse.encoder.write({ _tag: "Event", event, id: undefined, data: toData(a) }), - ), - ), - ), - { - status: 200, - contentType: "text/event-stream", - headers: Headers.fromInput({ - "cache-control": "no-cache", - connection: "keep-alive", - }), - }, - ); - - const ownerRoutes = - options.includeOwnerRoute === false - ? [] - : [ - HttpRouter.route( - "GET", - "/owner", - ownerStatus.pipe(Effect.map((status) => HttpServerResponse.jsonUnsafe(status))), - ), - ]; - const launchUpdate = options.launchUpdate; - const routes = [ - ...ownerRoutes, - // Health check - HttpRouter.route("GET", "/health", HttpServerResponse.text("OK", { status: 200 })), - - ...(launchUpdate === undefined - ? [] - : [ - HttpRouter.route( - "POST", - "/managed/launch", - Effect.gen(function* () { - const launch = yield* HttpServerRequest.schemaBodyJson( - managedStackLaunchUpdateSchema, - ); - yield* launchUpdate(launch); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }), - ), - ]), - - // Versioned lifecycle ownership/readiness status. The remaining - // routes are the existing Stack management transport. - // Status: connection info + all service states - HttpRouter.route( - "GET", - "/status", - Effect.gen(function* () { - const info = yield* stack.getInfo(); - const services = yield* stack.getAllStates(); - return HttpServerResponse.jsonUnsafe({ info, services }); - }), - ), - - // Status stream: SSE of service state changes - HttpRouter.route( - "GET", - "/status/stream", - Effect.sync(() => - sseResponse(stack.allStateChanges(), "state", (s) => JSON.stringify(s)), - ), - ), - - // Start: begin service startup - HttpRouter.route( - "POST", - "/start", - Effect.gen(function* () { - yield* stack.start(); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - HttpRouter.route( - "POST", - "/ready", - Effect.gen(function* () { - const opts = yield* HttpServerRequest.schemaBodyJson(ReadyOptionsSchema); - yield* stack.waitAllReady(opts); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReadinessOptionsResponse()), - HttpServerError: () => Effect.succeed(invalidReadinessOptionsResponse()), - }), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - // Stop: graceful shutdown - HttpRouter.route( - "POST", - "/stop", - Effect.gen(function* () { - yield* beginShutdown; - return HttpServerResponse.jsonUnsafe({ ok: true }); - }), - ), - - // Logs: SSE of all logs - HttpRouter.route( - "GET", - "/logs", - Effect.gen(function* () { - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - const services = parseServices(searchParams.service); - return sseResponse(stack.subscribeAllLogs(services), "log", (e) => JSON.stringify(e)); - }), - ), - - // Merged log history across all services - HttpRouter.route( - "GET", - "/logs/history", - Effect.gen(function* () { - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - const limit = parseLimit(searchParams.limit); - const services = parseServices(searchParams.service); - const entries = yield* stack.logHistoryAll(limit, services); - return HttpServerResponse.jsonUnsafe(entries); - }), - ), - - // Log history for a service (registered before /logs/:service to avoid shadowing) - HttpRouter.route( - "GET", - "/logs/:service/history", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - const service = parseSingleParam(routeParams.service)!; - const limit = parseLimit(searchParams.limit); - const entries = yield* stack.logHistory(service, limit); - return HttpServerResponse.jsonUnsafe(entries); - }), - ), - - // Logs for a specific service: SSE - HttpRouter.route( - "GET", - "/logs/:service", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - const service = parseSingleParam(routeParams.service)!; - return sseResponse(stack.subscribeLogs(service), "log", (e) => JSON.stringify(e)); - }), - ), - - // Per-service control - HttpRouter.route( - "POST", - "/services/:name/start", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - yield* stack.startService(routeParams.name!); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackNotRunningError", (e) => - Effect.succeed(notRunningResponse(e.phase)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - HttpRouter.route( - "POST", - "/services/:name/ready", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - const opts = yield* HttpServerRequest.schemaBodyJson(ReadyOptionsSchema); - yield* stack.waitReady(routeParams.name!, opts); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReadinessOptionsResponse()), - HttpServerError: () => Effect.succeed(invalidReadinessOptionsResponse()), - }), - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - HttpRouter.route( - "POST", - "/services/:name/stop", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - yield* stack.stopService(routeParams.name!); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackNotRunningError", (e) => - Effect.succeed(notRunningResponse(e.phase)), - ), - ), - ), - - HttpRouter.route( - "POST", - "/services/:name/restart", - Effect.gen(function* () { - const routeParams = yield* HttpRouter.params; - yield* stack.restartService(routeParams.name!); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackNotRunningError", (e) => - Effect.succeed(notRunningResponse(e.phase)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - HttpRouter.route( - "POST", - "/functions/reload", - Effect.gen(function* () { - const body = yield* HttpServerRequest.schemaBodyJson(FunctionsReloadConfigSchema); - yield* stack.reloadFunctions(body); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), - HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), - }), - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackNotRunningError", (e) => - Effect.succeed(notRunningResponse(e.phase)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - - HttpRouter.route( - "POST", - "/edge-runtime/reload", - Effect.gen(function* () { - const body = yield* HttpServerRequest.schemaBodyJson(EdgeRuntimeReloadConfigSchema); - yield* stack.reloadEdgeRuntime(body); - return HttpServerResponse.jsonUnsafe({ ok: true }); - }).pipe( - Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), - HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), - }), - Effect.catchTag("ServiceNotFoundError", (e) => - Effect.succeed(notFoundResponse(e.name)), - ), - Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), - ), - Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail, e.reason)), - ), - Effect.catchTag("StackNotRunningError", (e) => - Effect.succeed(notRunningResponse(e.phase)), - ), - Effect.catchTag("StackReadinessError", (e) => - terminalReadinessResponse(e.target, e.timeoutMs, e.detail), - ), - ), - ), - ]; - - const httpEffect = yield* HttpRouter.toHttpEffect(HttpRouter.addAll(routes)); - yield* Effect.forkScoped(server.serve(httpEffect)); - - return { - address: server.address, - beginShutdown, - awaitShutdown: Deferred.await(shutdownDeferred), - }; - }), - ); - - static layer: Layer.Layer = - this.layerWithShutdown(); -} - -function parseLimit(value: string | ReadonlyArray | undefined): number | undefined { - const raw = Array.isArray(value) ? value.at(0) : value; - if (raw === undefined) return undefined; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function parseServices( - value: string | ReadonlyArray | undefined, -): ReadonlyArray | undefined { - if (value === undefined) return undefined; - return typeof value === "string" ? [value] : value; -} - -function parseSingleParam(value: string | ReadonlyArray | undefined): string | undefined { - if (value === undefined) return undefined; - return typeof value === "string" ? value : value[0]; -} diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts index e65a7f3c2a..37b8c72cf0 100644 --- a/packages/stack/src/HttpTransportClient.ts +++ b/packages/stack/src/HttpTransportClient.ts @@ -1,5 +1,15 @@ import { Context, Data, Effect, Layer } from "effect"; -import type { ControlEndpoint } from "./managed/control.ts"; +import { + CONTROL_STATUS_PATH, + CONTROL_STOP_PATH, + ControlProtocolError, + ControlStopConflictError, + ControlTransportError, + makeControlClient, + type ControlClientShape, + type ControlClientTransport, + type ControlEndpoint, +} from "./managed/control.ts"; export class HttpTransportClientError extends Data.TaggedError("HttpTransportClientError")<{ readonly endpoint: ControlEndpoint; @@ -25,13 +35,113 @@ export const httpTransportClientLayer = Layer.succeed(HttpTransportClient, { try: (signal) => fetch(`${endpoint.url}${path}`, { ...init, - signal: AbortSignal.any( + signal: init?.signal === undefined || init.signal === null - ? [signal, AbortSignal.timeout(30_000)] - : [signal, init.signal], - ), + ? signal + : AbortSignal.any([signal, init.signal]), }), catch: (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), }), }); + +const CONTROL_REQUEST_TIMEOUT_MS = 500; + +const errorCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return errorCode(cause.cause); + return undefined; +}; + +const controlTransportError = ( + endpoint: ControlEndpoint, + cause: HttpTransportClientError, +): ControlTransportError => + new ControlTransportError({ + endpoint, + reason: + errorCode(cause) === "ECONNREFUSED" || errorCode(cause) === "ConnectionRefused" + ? "unreachable" + : "transport", + cause, + }); + +const consumeControlResponse = ( + endpoint: ControlEndpoint, + response: Response, +): Effect.Effect => + Effect.tryPromise({ + try: () => + (response.body === null ? Promise.resolve() : response.arrayBuffer()).then(() => undefined), + catch: (cause) => new ControlTransportError({ endpoint, reason: "transport", cause }), + }); + +const makeHttpControlTransport = ( + transport: HttpTransportClient["Service"], +): ControlClientTransport => ({ + read: (endpoint) => + Effect.suspend(() => + transport.request(endpoint, CONTROL_STATUS_PATH, { + method: "GET", + headers: { connection: "close" }, + signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + }), + ).pipe( + Effect.mapError((cause) => controlTransportError(endpoint, cause)), + Effect.flatMap((response) => + response.ok + ? Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new ControlProtocolError({ endpoint, cause }), + }) + : Effect.fail(new ControlProtocolError({ endpoint, cause: response.status })), + ), + ), + requestStop: (endpoint, request) => + Effect.suspend(() => + transport.request(endpoint, CONTROL_STOP_PATH, { + method: "POST", + body: JSON.stringify(request), + headers: { "content-type": "application/json", connection: "close" }, + signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + }), + ).pipe( + Effect.mapError((cause) => controlTransportError(endpoint, cause)), + Effect.flatMap((response) => + consumeControlResponse(endpoint, response).pipe(Effect.as(response)), + ), + Effect.flatMap( + ( + response, + ): Effect.Effect< + void, + ControlTransportError | ControlProtocolError | ControlStopConflictError + > => { + if (response.ok) return Effect.void; + if (response.status === 409) + return Effect.fail(new ControlStopConflictError({ endpoint })); + // A stop response can be lost after the daemon accepts the request + // (for example while it closes its listener). Treat every non-409 + // HTTP status as ambiguous transport, matching the platform + // transports so callers can observe the fenced owner session. + return Effect.fail( + controlTransportError( + endpoint, + new HttpTransportClientError({ + endpoint, + path: CONTROL_STOP_PATH, + cause: response.status, + reason: "status", + }), + ), + ); + }, + ), + ), +}); + +/** Stable control client backed by the shared HTTP transport service. */ +export const makeHttpControlClient = ( + transport: HttpTransportClient["Service"], +): ControlClientShape => makeControlClient(makeHttpControlTransport(transport)); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts deleted file mode 100644 index b7e632cd2c..0000000000 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; -import { - Cause, - Effect, - Exit, - Fiber, - Layer, - ManagedRuntime, - Predicate, - Result, - Stream, -} from "effect"; -import * as http from "node:http"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { DaemonServer } from "./DaemonServer.ts"; -import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; -import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; -import { RemoteStack } from "./RemoteStack.ts"; -import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; -import type { ReadyOptions } from "./StackConfig.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { HttpTransportClient, HttpTransportClientError } from "./HttpTransportClient.ts"; -import type { ControlEndpoint } from "./managed/control.ts"; - -// --------------------------------------------------------------------------- -// Test fixtures -// --------------------------------------------------------------------------- - -const MOCK_INFO: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "pk_test", - secretKey: "sk_test", - anonJwt: "anon_jwt", - serviceRoleJwt: "service_role_jwt", - serviceEndpoints: {}, -}; - -const POSTGRES_STATE = new StackServiceState({ - name: "postgres", - status: "Running", - pid: 1234, - exitCode: null, - restartCount: 0, - startedAt: Date.now(), - error: null, -}); - -const AUTH_STATE = new StackServiceState({ - name: "auth", - status: "Healthy", - pid: 5678, - exitCode: null, - restartCount: 0, - startedAt: Date.now(), - error: null, -}); - -const HEALTH_FAILED_STATE = new StackServiceState({ - name: "edge-runtime", - status: "Failed", - pid: null, - exitCode: null, - restartCount: 2, - startedAt: Date.now(), - error: "Health check failed and restart budget was exhausted", -}); - -const MOCK_STATES: ReadonlyArray = [ - POSTGRES_STATE, - AUTH_STATE, - HEALTH_FAILED_STATE, -]; - -const MOCK_LOGS: ReadonlyArray = [ - { timestamp: 1000, service: "postgres", stream: "stdout", line: "starting" }, - { timestamp: 1001, service: "postgres", stream: "stdout", line: "ready" }, - { timestamp: 1002, service: "auth", stream: "stdout", line: "auth started" }, -]; - -// --------------------------------------------------------------------------- -// Mock Stack (server-side, backing the DaemonServer) -// --------------------------------------------------------------------------- - -function mockStack( - options: { - readonly startServiceBuildError?: string; - readonly startServiceBuildReason?: - | "invalid_config" - | "docker_not_running" - | "asset_preparation"; - readonly startServiceReadyError?: string; - readonly waitReadyBuildError?: string; - readonly waitReadyBuildReason?: "invalid_config" | "docker_not_running" | "asset_preparation"; - readonly waitReadyTimeoutMs?: number; - readonly restartServiceReadyError?: string; - readonly notRunningPhase?: string; - } = {}, -) { - let stopped = false; - const serviceCalls: string[] = []; - const functionReloads: FunctionsReloadConfig[] = []; - const edgeRuntimeReloads: EdgeRuntimeReloadConfig[] = []; - const readinessCalls: Array<{ readonly target: string; readonly options?: ReadyOptions }> = []; - - const layer = Layer.succeed(Stack, { - getInfo: () => Effect.succeed(MOCK_INFO), - start: () => Effect.void, - stop: () => - Effect.sync(() => { - stopped = true; - }), - dispose: () => - Effect.sync(() => { - stopped = true; - }), - startService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : options.notRunningPhase !== undefined - ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) - : options.startServiceBuildError !== undefined - ? Effect.fail( - new StackBuildError({ - detail: options.startServiceBuildError, - ...(options.startServiceBuildReason === undefined - ? {} - : { reason: options.startServiceBuildReason }), - }), - ) - : options.startServiceReadyError !== undefined - ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.startServiceReadyError, - }), - ) - : Effect.sync(() => { - serviceCalls.push(`start:${name}`); - }), - stopService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : options.notRunningPhase !== undefined - ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) - : Effect.sync(() => { - serviceCalls.push(`stop:${name}`); - }), - restartService: (name: string) => - name === "unknown" - ? Effect.fail(new ServiceNotFoundError({ name })) - : options.notRunningPhase !== undefined - ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) - : options.restartServiceReadyError !== undefined - ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.restartServiceReadyError, - }), - ) - : Effect.sync(() => { - serviceCalls.push(`restart:${name}`); - }), - reloadFunctions: (config) => - options.notRunningPhase !== undefined - ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) - : Effect.sync(() => { - functionReloads.push(config ?? {}); - serviceCalls.push("reload-functions"); - }), - reloadEdgeRuntime: (config) => - options.notRunningPhase !== undefined - ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) - : Effect.sync(() => { - edgeRuntimeReloads.push(config); - serviceCalls.push("reload-edge-runtime"); - }), - getState: (name: string) => { - const match = MOCK_STATES.find((s) => s.name === name); - return match ? Effect.succeed(match) : Effect.fail(new ServiceNotFoundError({ name })); - }, - getAllStates: () => Effect.succeed(MOCK_STATES), - stateChanges: (name: string) => { - const match = MOCK_STATES.find((s) => s.name === name); - return match - ? Effect.succeed(Stream.fromIterable([match])) - : Effect.fail(new ServiceNotFoundError({ name })); - }, - allStateChanges: () => Stream.fromIterable(MOCK_STATES), - waitReady: (name: string, readyOptions?: ReadyOptions) => { - const match = MOCK_STATES.find((s) => s.name === name); - if (match === undefined) return Effect.fail(new ServiceNotFoundError({ name })); - if (options.waitReadyBuildError !== undefined) { - return Effect.fail( - new StackBuildError({ - detail: options.waitReadyBuildError, - ...(options.waitReadyBuildReason === undefined - ? {} - : { reason: options.waitReadyBuildReason }), - }), - ); - } - if (options.waitReadyTimeoutMs !== undefined) { - return Effect.fail( - new StackReadinessError({ - target: name, - timeoutMs: options.waitReadyTimeoutMs, - detail: `Timed out waiting for ${name}`, - }), - ); - } - return Effect.sync(() => { - readinessCalls.push({ target: name, options: readyOptions }); - serviceCalls.push(`ready:${name}`); - }); - }, - waitAllReady: (readyOptions?: ReadyOptions) => - Effect.sync(() => { - readinessCalls.push({ target: "stack", options: readyOptions }); - serviceCalls.push("ready:all"); - }), - subscribeLogs: (name: string) => - Stream.fromIterable(MOCK_LOGS.filter((l) => l.service === name)), - subscribeAllLogs: (services?: ReadonlyArray) => - Stream.fromIterable( - services === undefined || services.length === 0 - ? MOCK_LOGS - : MOCK_LOGS.filter((l) => services.includes(l.service)), - ), - logHistory: (name: string, limit?: number) => - Effect.succeed(MOCK_LOGS.filter((l) => l.service === name).slice(-(limit ?? 100))), - logHistoryAll: (limit?: number, services?: ReadonlyArray) => - Effect.succeed( - (services === undefined || services.length === 0 - ? MOCK_LOGS - : MOCK_LOGS.filter((l) => services.includes(l.service)) - ).slice(-(limit ?? 100)), - ), - }); - - return { - layer, - get stopped() { - return stopped; - }, - serviceCalls, - readinessCalls, - functionReloads, - edgeRuntimeReloads, - }; -} - -const functionsBundle: ResolvedFunctionsBundle = { - env: { SHARED_SECRET: "shared-secret-value" }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: "/project/supabase/functions/hello/index.ts", - importMapPath: null, - staticFiles: [], - env: { FUNCTION_SECRET: "function-secret-value" }, - }, - ], -}; - -// --------------------------------------------------------------------------- -// Layer builder — DaemonServer backed by mock Stack on TCP port -// --------------------------------------------------------------------------- - -function buildServerLayer( - mock: ReturnType, -): Layer.Layer { - return DaemonServer.layer.pipe( - Layer.provide(mock.layer), - Layer.provide(NodeHttpServer.layer(() => http.createServer(), { port: 0 }).pipe(Layer.orDie)), - ); -} - -function testEndpoint(url = "http://127.0.0.1:1"): ControlEndpoint { - const parsed = new URL(url); - return { - hostname: parsed.hostname, - port: Number(parsed.port || 80), - url, - }; -} - -function buildClientLayer(url: string): Layer.Layer { - const clientLayer = Layer.succeed(HttpTransportClient, { - request: (endpoint, path, init) => - Effect.tryPromise({ - try: () => fetch(`${url}${path}`, init), - catch: (cause) => - new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), - }), - }); - return RemoteStack.layer(testEndpoint(url)).pipe(Layer.provide(clientLayer)); -} - -// --------------------------------------------------------------------------- -// Tests — RemoteStack talks to DaemonServer via TCP. -// --------------------------------------------------------------------------- - -describe("RemoteStack integration", () => { - let serverRuntime: ManagedRuntime.ManagedRuntime; - let clientRuntime: ManagedRuntime.ManagedRuntime; - let mock: ReturnType; - - beforeAll(async () => { - mock = mockStack(); - serverRuntime = ManagedRuntime.make(buildServerLayer(mock)); - const daemon = await serverRuntime.runPromise(DaemonServer); - - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - const url = `http://${host}:${addr.port}`; - clientRuntime = ManagedRuntime.make(buildClientLayer(url)); - }); - - afterAll(async () => { - await clientRuntime?.dispose(); - await serverRuntime?.dispose(); - }); - - test("getInfo returns stack info", async () => { - const info = await clientRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.getInfo())); - expect(info).toEqual(MOCK_INFO); - }); - - test("getAllStates returns service states", async () => { - const states = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.getAllStates()), - ); - expect(states).toHaveLength(3); - expect(states.at(0)?.name).toBe("postgres"); - expect(states.at(1)?.name).toBe("auth"); - expect(states.at(2)).toMatchObject({ - name: "edge-runtime", - status: "Failed", - pid: null, - exitCode: null, - error: "Health check failed and restart budget was exhausted", - }); - }); - - test("getState returns a single service state", async () => { - const state = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.getState("postgres")), - ); - expect(state.name).toBe("postgres"); - expect(state.status).toBe("Running"); - }); - - test("getState fails for unknown service", async () => { - const exit = await clientRuntime.runPromiseExit( - Effect.flatMap(Stack, (stack) => stack.getState("unknown")), - ); - expect(Exit.isFailure(exit)).toBe(true); - }); - - test("startService records the call", async () => { - await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.startService("postgres")), - ); - expect(mock.serviceCalls).toContain("start:postgres"); - }); - - test("startService fails for unknown service", async () => { - const exit = await clientRuntime.runPromiseExit( - Effect.flatMap(Stack, (stack) => stack.startService("unknown")), - ); - expect(Exit.isFailure(exit)).toBe(true); - }); - - test("waitReady passes one validated finite override through the daemon", async () => { - await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.waitReady("auth", { mode: "finite", timeoutMs: 250 })), - ); - expect(mock.serviceCalls).toContain("ready:auth"); - expect(mock.readinessCalls).toContainEqual({ - target: "auth", - options: { mode: "finite", timeoutMs: 250 }, - }); - }); - - test("waitReady rejects dot path segments locally", async () => { - const error = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.waitReady("..")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(error, "ServiceNotFoundError")).toBe(true); - expect(mock.serviceCalls).not.toContain("ready:all"); - }); - - test("waitAllReady sends explicit inherit semantics to the daemon", async () => { - await clientRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.waitAllReady())); - expect(mock.serviceCalls).toContain("ready:all"); - expect(mock.readinessCalls).toContainEqual({ - target: "stack", - options: { mode: "inherit" }, - }); - }); - - test("preserves StackReadinessError across the daemon transport", async () => { - const failingMock = mockStack({ waitReadyTimeoutMs: 75 }); - const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); - let failingClient: ManagedRuntime.ManagedRuntime | undefined; - try { - const daemon = await failingServer.runPromise(DaemonServer); - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); - - const error = await failingClient.runPromise( - Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(error, "StackReadinessError")).toBe(true); - if (Predicate.isTagged(error, "StackReadinessError")) { - expect(error.target).toBe("auth"); - expect(error.timeoutMs).toBe(75); - } - } finally { - await failingClient?.dispose(); - await failingServer.dispose(); - } - }); - - test("interrupting waitReady aborts the daemon request", async () => { - let notifyRequestStarted: (() => void) | undefined; - const requestStarted = new Promise((resolve) => { - notifyRequestStarted = resolve; - }); - let aborted = false; - const clientLayer = Layer.succeed(HttpTransportClient, { - request: (endpoint, path, init) => - Effect.tryPromise({ - try: () => - new Promise((_resolve, reject) => { - notifyRequestStarted?.(); - init?.signal?.addEventListener( - "abort", - () => { - aborted = true; - reject(new DOMException("Aborted", "AbortError")); - }, - { once: true }, - ); - }), - catch: (cause) => - new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), - }), - }); - const runtime = ManagedRuntime.make( - RemoteStack.layer(testEndpoint()).pipe(Layer.provide(clientLayer)), - ); - try { - const fiber = runtime.runFork(Effect.flatMap(Stack, (stack) => stack.waitReady("auth"))); - await requestStarted; - await runtime.runPromise(Fiber.interrupt(fiber)); - expect(aborted).toBe(true); - } finally { - await runtime.dispose(); - } - }); - - test("distinguishes daemon status failures from protocol failures", async () => { - const scenarios = [ - { response: new Response("failed", { status: 500 }), reason: "status" }, - { - response: new Response("not-json", { - status: 200, - headers: { "content-type": "application/json" }, - }), - reason: "protocol", - }, - ] as const; - - for (const scenario of scenarios) { - const clientLayer = Layer.succeed(HttpTransportClient, { - request: () => Effect.succeed(scenario.response), - }); - const runtime = ManagedRuntime.make( - RemoteStack.layer(testEndpoint()).pipe(Layer.provide(clientLayer)), - ); - try { - const exit = await runtime.runPromise( - Effect.flatMap(Stack, (stack) => stack.getInfo()).pipe(Effect.exit), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const defect = Cause.findDefect(exit.cause); - expect(Result.isSuccess(defect)).toBe(true); - if (Result.isSuccess(defect)) { - expect(defect.success).toBeInstanceOf(HttpTransportClientError); - expect(defect.success).toMatchObject({ reason: scenario.reason, path: "/status" }); - } - } - } finally { - await runtime.dispose(); - } - } - }); - - test("preserves daemon identity for invalid SSE responses", async () => { - const scenarios = [ - { response: () => new Response("failed", { status: 500 }), reason: "status" }, - { - response: () => - new Response("data: not-json\n\n", { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - reason: "protocol", - }, - ] as const; - - for (const scenario of scenarios) { - const clientLayer = Layer.succeed(HttpTransportClient, { - request: () => Effect.succeed(scenario.response()), - }); - const runtime = ManagedRuntime.make( - RemoteStack.layer(testEndpoint()).pipe(Layer.provide(clientLayer)), - ); - try { - const exit = await runtime.runPromise( - Effect.flatMap(Stack, (stack) => Stream.runCollect(stack.subscribeAllLogs())).pipe( - Effect.exit, - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const defect = Cause.findDefect(exit.cause); - expect(Result.isSuccess(defect)).toBe(true); - if (Result.isSuccess(defect)) { - expect(defect.success).toBeInstanceOf(HttpTransportClientError); - expect(defect.success).toMatchObject({ reason: scenario.reason, path: "/logs" }); - } - } - } finally { - await runtime.dispose(); - } - } - }); - - test("preserves StackBuildError across remote service operations", async () => { - const failingMock = mockStack({ - restartServiceReadyError: "restart failed readiness", - startServiceBuildError: "stack is stopped", - startServiceBuildReason: "docker_not_running", - waitReadyBuildError: "service has not been activated", - waitReadyBuildReason: "invalid_config", - }); - const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); - let failingClient: ManagedRuntime.ManagedRuntime | undefined; - try { - const daemon = await failingServer.runPromise(DaemonServer); - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); - - const startError = await failingClient.runPromise( - Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(startError, "StackBuildError")).toBe(true); - if (Predicate.isTagged(startError, "StackBuildError")) { - expect(startError.reason).toBe("docker_not_running"); - } - - const readyError = await failingClient.runPromise( - Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(readyError, "StackBuildError")).toBe(true); - if (Predicate.isTagged(readyError, "StackBuildError")) { - expect(readyError.reason).toBe("invalid_config"); - } - - const restartError = await failingClient.runPromise( - Effect.flatMap(Stack, (stack) => stack.restartService("auth")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(restartError, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(restartError, "ServiceReadyError")) { - expect(restartError.reason).toBe("restart failed readiness"); - } - } finally { - await failingClient?.dispose(); - await failingServer.dispose(); - } - }); - - test("preserves StackNotRunningError across mutating daemon operations", async () => { - const failingMock = mockStack({ notRunningPhase: "stopped" }); - const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); - let failingClient: ManagedRuntime.ManagedRuntime | undefined; - try { - const daemon = await failingServer.runPromise(DaemonServer); - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); - - const operations = [ - (stack: Stack["Service"]) => stack.startService("auth"), - (stack: Stack["Service"]) => stack.stopService("auth"), - (stack: Stack["Service"]) => stack.restartService("auth"), - (stack: Stack["Service"]) => stack.reloadFunctions(), - (stack: Stack["Service"]) => - stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), - ]; - for (const operation of operations) { - const error = await failingClient.runPromise( - Effect.flatMap(Stack, operation).pipe(Effect.flip), - ); - expect(error).toBeInstanceOf(StackNotRunningError); - expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); - if (Predicate.isTagged(error, "StackNotRunningError")) expect(error.phase).toBe("stopped"); - } - } finally { - await failingClient?.dispose(); - await failingServer.dispose(); - } - }); - - test("preserves ServiceReadyError from remote startService", async () => { - const failingMock = mockStack({ startServiceReadyError: "start failed readiness" }); - const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); - let failingClient: ManagedRuntime.ManagedRuntime | undefined; - try { - const daemon = await failingServer.runPromise(DaemonServer); - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); - - const error = await failingClient.runPromise( - Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip), - ); - expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(error, "ServiceReadyError")) { - expect(error.reason).toBe("start failed readiness"); - } - } finally { - await failingClient?.dispose(); - await failingServer.dispose(); - } - }); - - test("stopService records the call", async () => { - await clientRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.stopService("auth"))); - expect(mock.serviceCalls).toContain("stop:auth"); - }); - - test("restartService records the call", async () => { - await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.restartService("postgres")), - ); - expect(mock.serviceCalls).toContain("restart:postgres"); - }); - - test("reloadFunctions transports the validated bundle in a JSON body", async () => { - await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.reloadFunctions({ functions: functionsBundle })), - ); - - expect(mock.functionReloads).toEqual([{ functions: functionsBundle }]); - }); - - test("reloadFunctions returns a typed build error for an invalid bundle", async () => { - const invalidBundle = { - ...functionsBundle, - functions: [{ ...functionsBundle.functions[0]!, entrypointPath: "relative/index.ts" }], - }; - - const error = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => - stack.reloadFunctions({ functions: invalidBundle }).pipe(Effect.flip), - ), - ); - - expect(error).toBeInstanceOf(StackBuildError); - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - if (Predicate.isTagged(error, "StackBuildError")) { - expect(error.detail).toBe("Invalid Edge Functions reload payload"); - } - }); - - test("reloadEdgeRuntime records the call", async () => { - await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => - stack.reloadEdgeRuntime({ - edgeRuntime: { policy: "oneshot" }, - functions: functionsBundle, - }), - ), - ); - expect(mock.serviceCalls).toContain("reload-edge-runtime"); - expect(mock.edgeRuntimeReloads).toEqual([ - { edgeRuntime: { policy: "oneshot" }, functions: functionsBundle }, - ]); - }); - - test("logHistory returns entries", async () => { - const entries = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.logHistory("postgres")), - ); - expect(entries).toHaveLength(2); - expect(entries.at(0)?.line).toBe("starting"); - }); - - test("logHistory respects limit", async () => { - const entries = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.logHistory("postgres", 1)), - ); - expect(entries).toHaveLength(1); - expect(entries.at(0)?.line).toBe("ready"); - }); - - test("logHistoryAll returns merged entries", async () => { - const entries = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.logHistoryAll(3)), - ); - expect(entries.map((entry) => entry.line)).toEqual(["starting", "ready", "auth started"]); - }); - - test("logHistoryAll respects service filters", async () => { - const entries = await clientRuntime.runPromise( - Effect.flatMap(Stack, (stack) => stack.logHistoryAll(10, ["auth"])), - ); - expect(entries).toHaveLength(1); - expect(entries.at(0)?.service).toBe("auth"); - }); - - test("stop calls through to daemon", async () => { - // Use a fresh server so /stop doesn't affect other tests - const freshMock = mockStack(); - const freshServer = ManagedRuntime.make(buildServerLayer(freshMock)); - try { - const daemon = await freshServer.runPromise(DaemonServer); - const addr = daemon.address; - if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress"); - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - const freshUrl = `http://${host}:${addr.port}`; - - const res = await fetch(`${freshUrl}/stop`, { method: "POST" }); - expect(res.status).toBe(200); - expect(freshMock.stopped).toBe(true); - } finally { - await freshServer.dispose(); - } - }); -}); diff --git a/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts b/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts new file mode 100644 index 0000000000..532b14ea4c --- /dev/null +++ b/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts @@ -0,0 +1,86 @@ +import { Effect, Exit, Layer, Predicate, Scope } from "effect"; +import { describe, expect, test } from "vitest"; +import { ControlTransport } from "./managed/control.ts"; +import { httpTransportClientLayer } from "./HttpTransportClient.ts"; +import { RemoteStack } from "./RemoteStack.ts"; +import { Stack } from "./Stack.ts"; +import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { makeTestStack } from "./testing.ts"; + +const isBun = typeof Bun !== "undefined"; +const ownerId = "c".repeat(64); + +describe("Bun runtime RPC", () => { + (isBun ? test : test.skip)("serves a same-version runtime RPC request over Bun TCP", async () => { + const { controlTransportLayer } = await import("./platform-bun.ts"); + const scope = Scope.makeUnsafe(); + const lifecycle = await Effect.runPromise( + SupervisorLifecycle.make({ + ownershipId: ownerId, + ownerSessionId: "bun-rpc-session", + daemonCliVersion: "test", + }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), + ); + await Effect.runPromise(lifecycle.publishStack(makeTestStack())); + const application = { + app: await Effect.runPromise( + makeSupervisorControlApplication(lifecycle).pipe( + Effect.provide(Layer.succeed(Scope.Scope, scope)), + ), + ), + }; + const listener = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.bind( + { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, + () => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId: ownerId, + ownerSessionId: "bun-rpc-session", + state: "running" as const, + ready: true, + daemonCliVersion: "test", + }), + () => "accepted" as const, + application, + ), + ).pipe( + Effect.provide(Layer.mergeAll(Layer.succeed(Scope.Scope, scope), controlTransportLayer)), + ), + ); + try { + const address = listener.server.address; + expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); + if (!Predicate.isTagged(address, "TcpAddress")) return; + const endpoint = { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }; + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "bun-rpc-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const exit = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + return yield* remote.getInfo(); + }).pipe(Effect.provide(layer), Effect.exit), + ), + ); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) expect(exit.value.url).toContain("127.0.0.1"); + } finally { + await Effect.runPromise(listener.close); + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + }); +}); diff --git a/packages/stack/src/RemoteStack.rpc.integration.test.ts b/packages/stack/src/RemoteStack.rpc.integration.test.ts new file mode 100644 index 0000000000..f5cb42e4e8 --- /dev/null +++ b/packages/stack/src/RemoteStack.rpc.integration.test.ts @@ -0,0 +1,1210 @@ +import { it } from "@effect/vitest"; +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Predicate, + Result, + Stream, +} from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { ServiceNotFoundError, ServiceReadyError } from "@supabase/process-compose"; +import { createServer, type Server } from "node:http"; +import { expect } from "vitest"; +import { Stack, type StackInfo } from "./Stack.ts"; +import { StackServiceState } from "./StackServiceState.ts"; +import { + HttpTransportClient, + HttpTransportClientError, + httpTransportClientLayer, +} from "./HttpTransportClient.ts"; +import { RemoteStack } from "./RemoteStack.ts"; +import { StackRpcProtocolError } from "./errors.ts"; +import { + StackBuildError, + StackNotRunningError, + StackReadinessError, + StackUnavailableError, +} from "./errors.ts"; +import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; +import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { makeTestStack } from "./testing.ts"; + +const ownerId = "b".repeat(64); + +const controlTransportLayer = + typeof Bun === "undefined" + ? (await import("./platform-node.ts")).controlTransportLayer + : (await import("./platform-bun.ts")).controlTransportLayer; + +const remoteOwner = (ownerSessionId: string) => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId: ownerId, + ownerSessionId, + state: "running" as const, + ready: true, + daemonCliVersion: "test", +}); + +const remoteLayer = ( + endpoint: { readonly hostname: string; readonly port: number; readonly url: string }, + ownerSessionId: string, + transport: HttpTransportClient["Service"], +) => + RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId, + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, transport))); + +const live = (effect: Effect.Effect) => + effect.pipe(Effect.provide(controlTransportLayer)); + +const startMalformedServer = (frame: string) => + Effect.acquireRelease( + Effect.callback< + { + readonly server: Server; + readonly endpoint: { + readonly hostname: string; + readonly port: number; + readonly url: string; + }; + }, + Error + >((resume) => { + const server = createServer((request, response) => { + if (request.url === "/owner") { + response.writeHead(200, { "content-type": "application/json", connection: "close" }); + response.end( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "malformed-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + ); + return; + } + if (request.url === "/rpc") { + response.writeHead(200, { "content-type": "application/x-ndjson", connection: "close" }); + response.end(frame); + return; + } + response.writeHead(404, { connection: "close" }); + response.end(); + }); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + resume(Effect.fail(new Error("malformed RPC test server did not expose an address"))); + return; + } + resume( + Effect.succeed({ + server, + endpoint: { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }, + }), + ); + }); + return Effect.sync(() => { + if (server.listening) server.close(); + }); + }), + ({ server }) => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return Effect.void; + } + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +const startDisconnectServer = ( + requestStarted: Deferred.Deferred, + requestClosed: Deferred.Deferred, +) => + Effect.acquireRelease( + Effect.callback< + { + readonly server: Server; + readonly endpoint: { + readonly hostname: string; + readonly port: number; + readonly url: string; + }; + }, + Error + >((resume) => { + const server = createServer((request, response) => { + if (request.url === "/owner") { + response.writeHead(200, { "content-type": "application/json", connection: "close" }); + response.end( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "disconnect-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + ); + return; + } + if (request.url === "/rpc") { + Deferred.doneUnsafe(requestStarted, Effect.void); + request.once("close", () => { + Deferred.doneUnsafe(requestClosed, Effect.void); + response.destroy(); + }); + return; + } + response.writeHead(404, { connection: "close" }); + response.end(); + }); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + resume(Effect.fail(new Error("disconnect RPC test server did not expose an address"))); + return; + } + resume( + Effect.succeed({ + server, + endpoint: { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }, + }), + ); + }); + return Effect.sync(() => { + if (server.listening) server.close(); + }); + }), + ({ server }) => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return Effect.void; + } + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +it.live("executes every Stack operation over the same-version RPC endpoint", () => + live( + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: ownerId, + ownerSessionId: "rpc-session", + daemonCliVersion: "test", + }); + let calls = 0; + const logReleased = Deferred.makeUnsafe(); + const activeLogStarted = Deferred.makeUnsafe(); + const activeLogReleased = Deferred.makeUnsafe(); + let logSubscriptions = 0; + const serviceState = new StackServiceState({ + name: "auth", + status: "Running", + pid: 1, + exitCode: null, + restartCount: 0, + startedAt: 1, + error: null, + }); + const info: StackInfo = { + url: "http://127.0.0.1:54321", + dbUrl: "postgresql://localhost/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, + }; + const logs = [{ timestamp: 1, service: "auth", stream: "stdout" as const, line: "ready" }]; + const stack: Stack["Service"] = { + getInfo: () => Effect.succeed(info), + start: () => + Effect.sync(() => { + calls += 1; + }), + stop: () => + Effect.sync(() => { + calls += 1; + }), + dispose: () => + Effect.sync(() => { + calls += 1; + }), + startService: (name) => { + switch (name) { + case "unavailable": + return Effect.fail( + new StackUnavailableError({ phase: "stopping", detail: "stack is stopping" }), + ); + case "missing": + return Effect.fail(new ServiceNotFoundError({ name })); + case "error": + return Effect.fail( + new ServiceReadyError({ name, reason: "did not become ready", exitCode: 17 }), + ); + case "build": + return Effect.fail( + new StackBuildError({ detail: "docker failed", reason: "docker_not_running" }), + ); + case "not-running": + return Effect.fail(new StackNotRunningError({ phase: "stopped" })); + case "readiness": + return Effect.fail( + new StackReadinessError({ target: "auth", timeoutMs: 1234, detail: "timed out" }), + ); + default: + return Effect.sync(() => { + calls += 1; + }); + } + }, + stopService: () => + Effect.sync(() => { + calls += 1; + }), + restartService: () => + Effect.sync(() => { + calls += 1; + }), + reloadFunctions: () => + Effect.sync(() => { + calls += 1; + }), + reloadEdgeRuntime: () => + Effect.sync(() => { + calls += 1; + }), + getState: (name) => + name === "missing" + ? Effect.fail(new ServiceNotFoundError({ name })) + : Effect.succeed(serviceState), + getAllStates: () => Effect.succeed([serviceState]), + stateChanges: () => Effect.succeed(Stream.fromIterable([serviceState])), + allStateChanges: () => Stream.fromIterable([serviceState]), + waitReady: () => + Effect.sync(() => { + calls += 1; + }), + waitAllReady: () => + Effect.sync(() => { + calls += 1; + }), + subscribeLogs: () => { + const active = logSubscriptions++ > 0; + const entries = active + ? Stream.fromIterable(logs).pipe( + Stream.tap(() => + Deferred.succeed(activeLogStarted, undefined).pipe(Effect.asVoid), + ), + ) + : Stream.fromIterable(logs); + return Stream.concat(entries, Stream.never).pipe( + Stream.ensuring( + Deferred.succeed(active ? activeLogReleased : logReleased, undefined), + ), + ); + }, + subscribeAllLogs: () => Stream.fromIterable(logs), + logHistory: () => Effect.succeed(logs), + logHistoryAll: () => Effect.succeed(logs), + }; + yield* lifecycle.publishStack(stack); + const application = { + app: yield* makeSupervisorControlApplication(lifecycle), + }; + const owner = yield* acquireControl({ + stackId: ownerId, + initialStatus: yield* lifecycle.currentStatus, + application, + }); + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + yield* lifecycle.setClose(owner.close); + const ownerStatus = yield* owner.ownerStatus; + const rpcPaths: Array = []; + const recordingTransportLayer = Layer.effect( + HttpTransportClient, + Effect.gen(function* () { + const base = yield* HttpTransportClient; + return { + request: ( + endpoint: Parameters[0], + path: string, + init?: RequestInit, + ) => + Effect.sync(() => { + rpcPaths.push(path); + }).pipe(Effect.flatMap(() => base.request(endpoint, path, init))), + }; + }), + ).pipe(Layer.provide(httpTransportClientLayer)); + const mismatchLayer = RemoteStack.layer(owner.endpoint, { + cliVersion: "different-version", + owner: { + ownershipId: owner.ownershipId, + ownerSessionId: ownerStatus.ownerSessionId, + controlProtocolVersion: ownerStatus.controlProtocolVersion, + daemonCliVersion: ownerStatus.daemonCliVersion, + }, + }).pipe(Layer.provide(recordingTransportLayer)); + const mismatchExit = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + yield* Stack; + }), + ).pipe(Effect.provide(mismatchLayer)), + ); + expect(Exit.isFailure(mismatchExit)).toBe(true); + expect(rpcPaths).toEqual(["/owner"]); + const remoteLayer = RemoteStack.layer(owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: owner.ownershipId, + ownerSessionId: ownerStatus.ownerSessionId, + controlProtocolVersion: ownerStatus.controlProtocolVersion, + daemonCliVersion: ownerStatus.daemonCliVersion, + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + yield* Effect.gen(function* () { + const remote = yield* Stack; + expect(yield* remote.getInfo()).toEqual(info); + yield* remote.start(); + yield* remote.startService("auth"); + const readyError = yield* Effect.flip(remote.startService("error")); + expect(readyError).toEqual( + expect.objectContaining({ + _tag: "ServiceReadyError", + name: "error", + reason: "did not become ready", + exitCode: 17, + }), + ); + expect(yield* Effect.flip(remote.startService("unavailable"))).toEqual( + expect.objectContaining({ + _tag: "StackUnavailableError", + phase: "stopping", + detail: "stack is stopping", + }), + ); + expect(yield* Effect.flip(remote.startService("missing"))).toEqual( + expect.objectContaining({ + _tag: "ServiceNotFoundError", + name: "missing", + }), + ); + expect(yield* Effect.flip(remote.startService("build"))).toEqual( + expect.objectContaining({ + _tag: "StackBuildError", + detail: "docker failed", + reason: "docker_not_running", + }), + ); + expect(yield* Effect.flip(remote.startService("not-running"))).toEqual( + expect.objectContaining({ + _tag: "StackNotRunningError", + phase: "stopped", + }), + ); + expect(yield* Effect.flip(remote.startService("readiness"))).toEqual( + expect.objectContaining({ + _tag: "StackReadinessError", + target: "auth", + timeoutMs: 1234, + detail: "timed out", + }), + ); + yield* remote.stopService("auth"); + yield* remote.restartService("auth"); + yield* remote.reloadFunctions(); + yield* remote.reloadEdgeRuntime({ edgeRuntime: { enabled: true } }); + expect(yield* remote.getState("auth")).toEqual(serviceState); + expect(yield* remote.getAllStates()).toEqual([serviceState]); + const authChanges = yield* remote.stateChanges("auth"); + expect(yield* Stream.runCollect(authChanges)).toEqual([serviceState]); + const missingChanges = yield* Effect.exit(remote.stateChanges("missing")); + expect(Exit.isFailure(missingChanges)).toBe(true); + if (Exit.isFailure(missingChanges)) { + const failure = Cause.findErrorOption(missingChanges.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) + expect(failure.value).toMatchObject({ + _tag: "ServiceNotFoundError", + name: "missing", + }); + } + expect(yield* Stream.runCollect(remote.allStateChanges())).toEqual([serviceState]); + yield* remote.waitReady("auth"); + yield* remote.waitAllReady(); + expect(yield* remote.logHistory("auth")).toEqual(logs); + expect(yield* remote.logHistoryAll()).toEqual(logs); + expect( + yield* Effect.scoped(Stream.runCollect(Stream.take(remote.subscribeLogs("auth"), 1))), + ).toEqual([logs[0]]); + yield* Deferred.await(logReleased); + expect(yield* Stream.runCollect(remote.subscribeAllLogs(["auth"]))).toEqual(logs); + expect(calls).toBeGreaterThan(0); + const activeLogs = yield* Effect.forkChild(Stream.runDrain(remote.subscribeLogs("auth"))); + yield* Deferred.await(activeLogStarted); + yield* remote.stop(); + yield* Deferred.await(activeLogReleased); + expect(Exit.isFailure(yield* Fiber.join(activeLogs).pipe(Effect.exit))).toBe(true); + }).pipe(Effect.provide(remoteLayer)); + }), + ), + ), +); + +it.live("fences stale RPC clients after deterministic endpoint replacement", () => + live( + Effect.scoped( + Effect.gen(function* () { + const stackId = "d".repeat(64); + const sessionA = "rpc-fence-session-a"; + const sessionB = "rpc-fence-session-b"; + const info: StackInfo = { + url: "http://127.0.0.1:54321", + dbUrl: "postgresql://localhost/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, + }; + let handlerCalls = 0; + const makeOwner = (ownerSessionId: string, daemonCliVersion: string) => + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId, + daemonCliVersion, + }); + yield* lifecycle.publishStack({ + ...makeTestStack(), + getInfo: () => + Effect.sync(() => { + handlerCalls += 1; + return info; + }), + }); + const owner = yield* acquireControl({ + stackId, + initialStatus: yield* lifecycle.currentStatus, + application: { app: yield* makeSupervisorControlApplication(lifecycle) }, + }); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); + yield* lifecycle.setClose(owner.close); + return { lifecycle, owner }; + }); + + const first = yield* makeOwner(sessionA, "test"); + const firstStatus = yield* first.owner.ownerStatus; + const staleLayer = RemoteStack.layer(first.owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: stackId, + ownerSessionId: firstStatus.ownerSessionId, + controlProtocolVersion: firstStatus.controlProtocolVersion, + daemonCliVersion: firstStatus.daemonCliVersion, + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const staleContext = yield* Layer.build(staleLayer); + const staleRemote = Context.get(staleContext, Stack); + expect((yield* staleRemote.getInfo()).url).toBe(info.url); + expect(handlerCalls).toBe(1); + + yield* first.owner.close; + const replacement = yield* makeOwner(sessionB, "test"); + const staleResult = yield* staleRemote.getInfo().pipe(Effect.result); + expect(Result.isFailure(staleResult)).toBe(true); + if (Result.isFailure(staleResult)) { + expect(staleResult.failure).toBeInstanceOf(StackRpcProtocolError); + } + expect(handlerCalls).toBe(1); + + const replacementStatus = yield* replacement.owner.ownerStatus; + const replacementLayer = RemoteStack.layer(replacement.owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: stackId, + ownerSessionId: replacementStatus.ownerSessionId, + controlProtocolVersion: replacementStatus.controlProtocolVersion, + daemonCliVersion: replacementStatus.daemonCliVersion, + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const replacementContext = yield* Layer.build(replacementLayer); + const replacementRemote = Context.get(replacementContext, Stack); + expect((yield* replacementRemote.getInfo()).url).toBe(info.url); + expect(handlerCalls).toBe(2); + + yield* replacement.owner.close; + }), + ), + ), +); + +it.live.each([ + ["malformed NDJSON", "not-json\n"], + ["incomplete NDJSON", '{"_tag":"RpcResponse","success":'], +] as const)("preserves endpoint and procedure for %s", ([_label, frame]) => + Effect.scoped( + Effect.gen(function* () { + const server = yield* startMalformedServer(frame); + const layer = RemoteStack.layer(server.endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "malformed-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const exit = yield* Effect.exit( + Effect.gen(function* () { + const remote = yield* Stack; + yield* remote.getInfo(); + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(StackRpcProtocolError); + expect(Predicate.isTagged(failure.value, "StackRpcProtocolError")).toBe(true); + expect(failure.value).toMatchObject({ + endpoint: server.endpoint.url, + procedure: "GetInfo", + }); + } + } + }), + ).pipe(Effect.provide(controlTransportLayer)), +); + +it.effect("reports the HTTP status when the owner probe is non-successful", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12348, url: "http://127.0.0.1:12348" }; + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "owner-probe-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe( + Layer.provide( + Layer.succeed(HttpTransportClient, { + request: (_endpoint, path) => + path === "/owner" + ? Effect.succeed( + new Response(JSON.stringify({ error: "internal details must not leak" }), { + status: 503, + headers: { "content-type": "application/json" }, + }), + ) + : Effect.die(`unexpected request ${path}`), + }), + ), + ); + const exit = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + yield* Stack; + }).pipe(Effect.provide(layer)), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toMatchObject({ + _tag: "StackRpcProtocolError", + endpoint: endpoint.url, + procedure: "owner", + detail: "Owner probe returned HTTP 503", + }); + expect(failure.value).not.toHaveProperty( + "detail", + expect.stringContaining("internal details"), + ); + } + } + }), +); + +it.live("interrupts an owned server RPC request when the client disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const requestStarted = Deferred.makeUnsafe(); + const requestClosed = Deferred.makeUnsafe(); + const server = yield* startDisconnectServer(requestStarted, requestClosed); + const layer = RemoteStack.layer(server.endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "disconnect-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + const request = yield* Effect.forkChild(remote.getInfo()); + yield* Deferred.await(requestStarted); + yield* Fiber.interrupt(request); + yield* Deferred.await(requestClosed); + }).pipe(Effect.provide(layer)), + ); + }), + ).pipe(Effect.provide(controlTransportLayer)), +); + +it.live("closes an owner while another client still consumes an RPC stream", () => + live( + Effect.scoped( + Effect.gen(function* () { + const ownerSessionId = "active-stream-stop-session"; + const streamStarted = Deferred.makeUnsafe(); + const streamReleased = Deferred.makeUnsafe(); + const log = { + timestamp: 1, + service: "auth", + stream: "stdout" as const, + line: "ready", + }; + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: ownerId, + ownerSessionId, + daemonCliVersion: "test", + }); + yield* lifecycle.publishStack({ + ...makeTestStack(), + subscribeLogs: () => + Stream.concat( + Stream.succeed(log).pipe( + Stream.tap(() => Deferred.succeed(streamStarted, undefined).pipe(Effect.asVoid)), + ), + Stream.never, + ).pipe( + Stream.ensuring(Deferred.succeed(streamReleased, undefined).pipe(Effect.asVoid)), + ), + }); + const owner = yield* acquireControl({ + stackId: ownerId, + initialStatus: yield* lifecycle.currentStatus, + application: { app: yield* makeSupervisorControlApplication(lifecycle) }, + }); + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + yield* lifecycle.setClose(owner.close); + const layer = RemoteStack.layer(owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: owner.ownershipId, + ownerSessionId, + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const shutdownExit = yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + yield* Effect.forkChild(Stream.runDrain(remote.subscribeLogs("auth")), { + startImmediately: true, + }); + yield* Deferred.await(streamStarted); + const transport = yield* ControlTransport; + yield* transport.requestStop(owner.endpoint, { + ownershipId: owner.ownershipId, + ownerSessionId, + }); + return yield* lifecycle.awaitShutdown.pipe(Effect.timeout("2 seconds"), Effect.exit); + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isSuccess(shutdownExit)).toBe(true); + yield* Deferred.await(streamReleased); + }), + ), + ), +); + +it.effect("times out a hung fast unary RPC with endpoint and procedure context", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12345, url: "http://127.0.0.1:12345" }; + const rpcStarted = yield* Deferred.make(); + const transportLayer = Layer.succeed(HttpTransportClient, { + request: (_requestEndpoint, path) => + path === "/owner" + ? Effect.succeed( + new Response( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "hung-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "hung-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(transportLayer)); + const request = yield* Effect.forkChild( + Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + yield* remote.getInfo(); + }).pipe(Effect.provide(layer), Effect.exit), + ), + ); + yield* Deferred.await(rpcStarted); + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + const result = yield* Fiber.join(request); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toMatchObject({ + _tag: "StackRpcTransportError", + endpoint: endpoint.url, + procedure: "GetInfo", + }); + } + } + }), +); + +it.effect("does not apply the fast timeout to a long-running StartStack RPC", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12347, url: "http://127.0.0.1:12347" }; + const rpcStarted = yield* Deferred.make(); + const transportLayer = Layer.succeed(HttpTransportClient, { + request: (_requestEndpoint, path) => + path === "/owner" + ? Effect.succeed( + new Response( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "long-start-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "long-start-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(transportLayer)); + const request = yield* Effect.forkChild( + Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + yield* remote.start(); + }).pipe(Effect.provide(layer)), + ), + ); + yield* Deferred.await(rpcStarted); + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + expect(request.pollUnsafe()).toBeUndefined(); + yield* Fiber.interrupt(request); + }), +); + +it.effect("does not apply the fast timeout to a long-running StopService RPC", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12349, url: "http://127.0.0.1:12349" }; + const rpcStarted = yield* Deferred.make(); + const transportLayer = Layer.succeed(HttpTransportClient, { + request: (_requestEndpoint, path) => + path === "/owner" + ? Effect.succeed( + new Response( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "long-stop-service-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: "long-stop-service-session", + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(transportLayer)); + const request = yield* Effect.forkChild( + Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + yield* remote.stopService("postgres"); + }).pipe(Effect.provide(layer)), + ), + ); + yield* Deferred.await(rpcStarted); + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + expect(request.pollUnsafe()).toBeUndefined(); + yield* Fiber.interrupt(request); + }), +); + +it.effect("observes the captured session after the stop was accepted and its response resets", () => + Effect.forEach(["ECONNREFUSED", "ConnectionRefused"] as const, (refusedCode) => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12350, url: "http://127.0.0.1:12350" }; + const ownerSessionId = "accepted-reset-session"; + let ownerReads = 0; + const transport: HttpTransportClient["Service"] = { + request: (requestEndpoint, path) => { + if (path === "/owner") { + ownerReads += 1; + return ownerReads === 1 + ? Effect.succeed(Response.json(remoteOwner(ownerSessionId))) + : Effect.fail( + new HttpTransportClientError({ + endpoint: requestEndpoint, + path, + reason: "transport", + cause: { code: refusedCode }, + }), + ); + } + if (path === "/stop") + return Effect.fail( + new HttpTransportClientError({ + endpoint: requestEndpoint, + path, + reason: "transport", + cause: new Error("connection reset after the supervisor accepted the stop"), + }), + ); + return Effect.die(`unexpected request ${path}`); + }, + }; + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + return yield* remote.stop().pipe(Effect.result); + }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), + ); + + expect(Result.isSuccess(result)).toBe(true); + expect(ownerReads).toBe(2); + }), + ).pipe(Effect.asVoid), +); + +it.effect("observes the captured session after an ambiguous HTTP stop status", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12353, url: "http://127.0.0.1:12353" }; + const ownerSessionId = "accepted-http-status-session"; + let ownerReads = 0; + const transport: HttpTransportClient["Service"] = { + request: (requestEndpoint, path) => { + if (path === "/owner") { + ownerReads += 1; + return ownerReads === 1 + ? Effect.succeed(Response.json(remoteOwner(ownerSessionId))) + : Effect.fail( + new HttpTransportClientError({ + endpoint: requestEndpoint, + path, + reason: "transport", + cause: { code: "ECONNREFUSED" }, + }), + ); + } + if (path === "/stop") return Effect.succeed(new Response(null, { status: 503 })); + return Effect.die(`unexpected request ${path}`); + }, + }; + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + return yield* remote.stop().pipe(Effect.result); + }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), + ); + + expect(Result.isSuccess(result)).toBe(true); + expect(ownerReads).toBe(2); + }), +); + +it.effect( + "keeps observing when a transient owner read fails while the target session is alive", + () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12351, url: "http://127.0.0.1:12351" }; + const ownerSessionId = "transient-read-session"; + const transientRead = yield* Deferred.make(); + const targetObserved = yield* Deferred.make(); + let ownerReads = 0; + const transport: HttpTransportClient["Service"] = { + request: (requestEndpoint, path) => { + if (path === "/owner") { + ownerReads += 1; + if (ownerReads === 1) return Effect.succeed(Response.json(remoteOwner(ownerSessionId))); + if (ownerReads === 2) + return Deferred.succeed(transientRead, undefined).pipe( + Effect.andThen( + Effect.fail( + new HttpTransportClientError({ + endpoint: requestEndpoint, + path, + reason: "transport", + cause: { code: "ETIMEDOUT" }, + }), + ), + ), + ); + if (ownerReads === 3) + return Deferred.succeed(targetObserved, undefined).pipe( + Effect.as(Response.json(remoteOwner(ownerSessionId))), + ); + return Effect.succeed(Response.json(remoteOwner("replacement-session"))); + } + if (path === "/stop") return Effect.succeed(new Response(null, { status: 202 })); + return Effect.die(`unexpected request ${path}`); + }, + }; + const stop = yield* Effect.forkChild( + Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + yield* remote.stop(); + }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), + ), + ); + + yield* Deferred.await(transientRead); + yield* Effect.yieldNow; + expect(stop.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("25 millis"); + yield* Deferred.await(targetObserved); + expect(stop.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("25 millis"); + yield* Fiber.join(stop); + expect(ownerReads).toBe(4); + }), +); + +it.effect("finishes the captured stop when a replacement session answers with conflict", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12352, url: "http://127.0.0.1:12352" }; + const ownerSessionId = "replaced-session"; + let ownerReads = 0; + let stopRequests = 0; + const transport: HttpTransportClient["Service"] = { + request: (_requestEndpoint, path) => { + if (path === "/owner") { + ownerReads += 1; + return Effect.succeed( + Response.json(remoteOwner(ownerReads === 1 ? ownerSessionId : "replacement-session")), + ); + } + if (path === "/stop") { + stopRequests += 1; + return Effect.succeed(new Response(null, { status: 409 })); + } + return Effect.die(`unexpected request ${path}`); + }, + }; + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + return yield* remote.stop().pipe(Effect.result); + }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), + ); + + expect(Result.isSuccess(result)).toBe(true); + expect(stopRequests).toBe(1); + expect(ownerReads).toBe(2); + }), +); + +it.effect("finishes a fenced stop when another stack rebinds the endpoint", () => + Effect.gen(function* () { + const endpoint = { hostname: "127.0.0.1", port: 12346, url: "http://127.0.0.1:12346" }; + let ownerReads = 0; + const response = (status: unknown) => + new Response(JSON.stringify(status), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const initialOwner = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: ownerId, + ownerSessionId: "stop-session", + state: "running", + ready: true, + daemonCliVersion: "test", + } as const; + const transportLayer = Layer.succeed(HttpTransportClient, { + request: (_requestEndpoint, path) => { + if (path === "/owner") { + ownerReads += 1; + return Effect.succeed( + response( + ownerReads === 1 ? initialOwner : { ...initialOwner, ownershipId: "f".repeat(64) }, + ), + ); + } + if (path === "/stop") return Effect.succeed(new Response(null, { status: 202 })); + return Effect.die(`unexpected request ${path}`); + }, + }); + const layer = RemoteStack.layer(endpoint, { + cliVersion: "test", + owner: { + ownershipId: ownerId, + ownerSessionId: initialOwner.ownerSessionId, + controlProtocolVersion: 1, + daemonCliVersion: "test", + }, + }).pipe(Layer.provide(transportLayer)); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const remote = yield* Stack; + return yield* remote.stop().pipe(Effect.result); + }).pipe(Effect.provide(layer)), + ); + expect(Result.isSuccess(result)).toBe(true); + expect(ownerReads).toBe(2); + }), +); + +it.live("interrupts the real RPC handler fiber when the client request is canceled", () => + live( + Effect.scoped( + Effect.gen(function* () { + const started = Deferred.makeUnsafe(); + const finalized = Deferred.makeUnsafe(); + const info: StackInfo = { + url: "http://127.0.0.1:54321", + dbUrl: "postgresql://localhost/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, + }; + const stack: Stack["Service"] = { + ...makeTestStack(), + getInfo: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(finalized, undefined)), + Effect.as(info), + ), + }; + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: ownerId, + ownerSessionId: "rpc-cancel-session", + daemonCliVersion: "test", + }); + yield* lifecycle.publishStack(stack); + const application = { + app: yield* makeSupervisorControlApplication(lifecycle), + }; + const owner = yield* acquireControl({ + stackId: ownerId, + initialStatus: yield* lifecycle.currentStatus, + application, + }); + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + yield* lifecycle.setClose(owner.close); + const ownerStatus = yield* owner.ownerStatus; + const layer = RemoteStack.layer(owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: owner.ownershipId, + ownerSessionId: ownerStatus.ownerSessionId, + controlProtocolVersion: ownerStatus.controlProtocolVersion, + daemonCliVersion: ownerStatus.daemonCliVersion, + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + yield* Effect.gen(function* () { + const remote = yield* Stack; + const request = yield* Effect.forkChild(remote.getInfo()); + yield* Deferred.await(started); + yield* Fiber.interrupt(request); + yield* Deferred.await(finalized); + }).pipe(Effect.provide(layer)); + }), + ), + ), +); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 7ee88e81e1..c0eec25100 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,619 +1,385 @@ -import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; -import { Effect, Layer, Predicate, Schema, Stream } from "effect"; -import * as Sse from "effect/unstable/encoding/Sse"; -import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts"; -import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; -import { Stack, StackInfoSchema } from "./Stack.ts"; +import { Effect, Exit, Layer, Match, Scope, Stream } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as HttpBody from "effect/unstable/http/HttpBody"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import type { Scope as ScopeType } from "effect/Scope"; +import { + DaemonUpgradeRequired, + StackBuildError, + StackRpcProtocolError, + StackRpcTransportError, +} from "./errors.ts"; +import { HttpTransportClient, makeHttpControlClient } from "./HttpTransportClient.ts"; +import { + ControlAddressConflictError, + ControlProtocolError, + ControlProtocolMismatchError, + ControlTransportError, + type ControlEndpoint, +} from "./managed/control.ts"; +import { Stack } from "./Stack.ts"; import { inheritReadyOptions } from "./StackConfig.ts"; -import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; -import { HttpTransportClient, HttpTransportClientError } from "./HttpTransportClient.ts"; -import type { ControlEndpoint } from "./managed/control.ts"; -import { SERVICE_NAMES } from "./versions.ts"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -const LogEntrySchema = Schema.Struct({ - timestamp: Schema.Number, - service: Schema.String, - stream: Schema.Union([Schema.Literal("stdout"), Schema.Literal("stderr")]), - line: Schema.String, -}); - -const StatusServiceSchema = Schema.Struct({ - name: Schema.String, - status: StackServiceStatusSchema, - pid: Schema.NullOr(Schema.Number), - exitCode: Schema.NullOr(Schema.Number), - restartCount: Schema.Number, - startedAt: Schema.NullOr(Schema.Number), - error: Schema.NullOr(Schema.String), -}); - -const StatusResponseSchema = Schema.Struct({ - info: StackInfoSchema, - services: Schema.Array(StatusServiceSchema), -}); - -const StatusServiceEventSchema = Schema.fromJsonString(StatusServiceSchema); -const LogEntryEventSchema = Schema.fromJsonString(LogEntrySchema); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function requestHeaders(init?: RequestInit) { - return Object.fromEntries(new Headers(init?.headers).entries()); +import { + StackRpc, + STACK_RPC_PATH, + stackRpcFenceHeaders, + type StackLaunchUpdateRpc, + type StackRpcFence, +} from "./StackRpc.ts"; +import { StackServiceState } from "./StackServiceState.ts"; +import { CONTROL_PROTOCOL_VERSION, ControlOwnerStatusSchema } from "./DaemonProtocol.ts"; + +interface RemoteOwnerDescriptor { + readonly ownershipId: string; + readonly ownerSessionId: string; + readonly endpoint: ControlEndpoint; + readonly controlProtocolVersion: typeof CONTROL_PROTOCOL_VERSION; + readonly daemonCliVersion: string; } -const publicServicePath = (name: string): Effect.Effect => { - const service = SERVICE_NAMES.find((candidate) => candidate === name); - return service === undefined - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.succeed(encodeURIComponent(service)); -}; - -const decodeStatusServiceEvent = ( - endpoint: ControlEndpoint, - path: string, - data: string, -): Effect.Effect => - Schema.decodeUnknownEffect(StatusServiceEventSchema)(data).pipe( - Effect.map(toServiceState), - Effect.mapError( - (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), - ), - ); - -const decodeLogEntryEvent = ( - endpoint: ControlEndpoint, - path: string, - data: string, -): Effect.Effect => - Schema.decodeUnknownEffect(LogEntryEventSchema)(data).pipe( - Effect.mapError( - (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), - ), - ); - -function makeRequest( - endpoint: ControlEndpoint, - path: string, - init?: RequestInit, -): Effect.Effect { - const url = `http://localhost${path}`; - const method = init?.method?.toUpperCase() ?? "GET"; - switch (method) { - case "GET": - return Effect.succeed(HttpClientRequest.get(url, { headers: requestHeaders(init) })); - case "POST": - return Effect.succeed(HttpClientRequest.post(url, { headers: requestHeaders(init) })); - case "PUT": - return Effect.succeed(HttpClientRequest.put(url, { headers: requestHeaders(init) })); - case "PATCH": - return Effect.succeed(HttpClientRequest.patch(url, { headers: requestHeaders(init) })); - case "DELETE": - return Effect.succeed(HttpClientRequest.delete(url, { headers: requestHeaders(init) })); - case "HEAD": - return Effect.succeed(HttpClientRequest.head(url, { headers: requestHeaders(init) })); - case "OPTIONS": - return Effect.succeed(HttpClientRequest.options(url, { headers: requestHeaders(init) })); - case "TRACE": - return Effect.succeed(HttpClientRequest.trace(url, { headers: requestHeaders(init) })); - default: - return Effect.fail( - new HttpTransportClientError({ - endpoint, - path, - cause: `Unsupported HTTP method: ${method}`, - reason: "protocol", - }), - ); - } -} - -/** Make a fetch request to the daemon control endpoint. */ -function httpFetch(endpoint: ControlEndpoint, path: string, init?: RequestInit) { - return Effect.flatMap(HttpTransportClient, (client) => client.request(endpoint, path, init)); +export interface RemoteStackOptions { + readonly owner: Omit; + readonly cliVersion: string; + readonly stackId?: string; } -function httpResponse(endpoint: ControlEndpoint, path: string, init?: RequestInit) { - return Effect.gen(function* () { - const request = yield* makeRequest(endpoint, path, init); - const response = yield* httpFetch(endpoint, path, init); - return HttpClientResponse.fromWeb(request, response); +const protocolError = ( + endpoint: ControlEndpoint, + procedure: string, + detail: string, + cause?: unknown, +) => + new StackRpcProtocolError({ + endpoint: endpoint.url, + procedure, + detail, + ...(cause === undefined ? {} : { cause }), }); -} +const transportError = (endpoint: ControlEndpoint, procedure: string, cause: unknown) => + new StackRpcTransportError({ endpoint: endpoint.url, procedure, cause }); -/** Preserve daemon RPC identity when an HTTP status or body cannot be decoded. */ -function dieOnNonOkStatus( +const controlErrorToRpc = ( endpoint: ControlEndpoint, - path: string, - effect: Effect.Effect, -) { - return effect.pipe( - Effect.mapError( - (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "status" }), - ), - Effect.orDie, - ); -} + procedure: string, + error: + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError, +): StackRpcTransportError | StackRpcProtocolError => { + if (error instanceof ControlTransportError) return transportError(endpoint, procedure, error); + const detail = + error instanceof ControlProtocolMismatchError || error instanceof ControlAddressConflictError + ? error.message + : procedure === "owner" && typeof error.cause === "number" + ? `Owner probe returned HTTP ${error.cause}` + : `Invalid ${procedure} response`; + return protocolError(endpoint, procedure, detail, error); +}; -function dieOnBodyDecodeError( +const translateRpcClientFailure = ( + error: RpcClientError.RpcClientError, endpoint: ControlEndpoint, - path: string, - effect: Effect.Effect, -) { - return effect.pipe( - Effect.mapError( - (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }), - ), - Effect.orDie, - ); -} + procedure: string, +): StackRpcTransportError | StackRpcProtocolError => { + const reason = error.reason; + if (reason instanceof RpcClientError.RpcClientDefect) + return protocolError(endpoint, procedure, reason.message, reason.cause); + if (reason instanceof HttpClientError.HttpClientErrorSchema) + return reason.kind === "TransportError" + ? transportError(endpoint, procedure, reason.cause ?? reason) + : protocolError(endpoint, procedure, error.message, reason); + return transportError(endpoint, procedure, reason); +}; -function withAbortSignal( - effect: (signal: AbortSignal) => Effect.Effect, -): Effect.Effect { - return Effect.acquireUseRelease( - Effect.sync(() => new AbortController()), - (controller) => effect(controller.signal), - (controller) => Effect.sync(() => controller.abort()), - ); -} +const bodyForRequest = ( + body: HttpBody.HttpBody, +): Effect.Effect => { + return Match.valueTags(body, { + Empty: () => Effect.succeed(undefined), + FormData: () => Effect.succeed(undefined), + Uint8Array: (value) => Effect.succeed(value.body), + Raw: (value) => Effect.succeed(typeof value.body === "string" ? value.body : undefined), + Stream: (value) => + Stream.runCollect(value.stream).pipe( + Effect.map((chunks) => { + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; + }), + ), + }); +}; -const failDaemonResponse = ( +const makeHttpClient = ( endpoint: ControlEndpoint, - path: string, - response: HttpClientResponse.HttpClientResponse, - fallbackName: string, -): Effect.Effect< - never, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError -> => - Effect.gen(function* () { - const body = yield* dieOnBodyDecodeError( - endpoint, - path, - HttpClientResponse.schemaBodyJson(DaemonErrorResponseSchema)(response), + transport: HttpTransportClient["Service"], + fence: StackRpcFence, +): HttpClient.HttpClient => + HttpClient.make((request, url, signal) => { + const rawPath = `${url.pathname}${url.search}`; + const path = rawPath === `${STACK_RPC_PATH}/` ? STACK_RPC_PATH : rawPath; + return bodyForRequest(request.body).pipe( + Effect.flatMap((body) => + transport.request(endpoint, path, { + method: request.method, + headers: { ...request.headers, ...stackRpcFenceHeaders(fence) }, + signal, + ...(body === undefined ? {} : { body }), + }), + ), + Effect.map((response) => HttpClientResponse.fromWeb(request, response)), + Effect.mapError( + (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause }), + }), + ), ); - switch (body.code) { - case "SERVICE_NOT_FOUND": - return yield* new ServiceNotFoundError({ name: body.service ?? fallbackName }); - case "SERVICE_NOT_READY": - return yield* new ServiceReadyError({ - name: body.service ?? fallbackName, - reason: body.error, - ...(body.exitCode === undefined ? {} : { exitCode: body.exitCode }), - }); - case "STACK_BUILD_ERROR": - return yield* new StackBuildError({ - detail: body.error, - ...(body.reason === undefined ? {} : { reason: body.reason }), - }); - case "STACK_READINESS_TIMEOUT": - return yield* new StackReadinessError({ - target: body.service ?? fallbackName, - timeoutMs: body.timeoutMs ?? 0, - detail: body.error, - }); - case "STACK_NOT_RUNNING": - return yield* new StackNotRunningError({ phase: body.phase ?? "unknown" }); - } }); -const expectDaemonOk = ( +type GeneratedRpcClient = RpcClient.RpcClient< + RpcGroup.Rpcs, + RpcClientError.RpcClientError +>; +const makeRemoteRpcClient = ( endpoint: ControlEndpoint, - path: string, - response: HttpClientResponse.HttpClientResponse, - fallbackName: string, + options: RemoteStackOptions, ): Effect.Effect< - void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + { readonly client: GeneratedRpcClient; readonly owner: typeof ControlOwnerStatusSchema.Type }, + DaemonUpgradeRequired | StackRpcTransportError | StackRpcProtocolError, + HttpTransportClient | ScopeType > => - response.status >= 200 && response.status < 300 - ? Effect.void - : failDaemonResponse(endpoint, path, response, fallbackName).pipe( - Effect.catchTag("StackNotRunningError", (error) => Effect.die(error)), + Effect.gen(function* () { + const transport = yield* HttpTransportClient; + const control = makeHttpControlClient(transport); + const expectedOwner = options.owner; + const ownerStatus = yield* control + .readOwner(endpoint, expectedOwner.ownershipId) + .pipe(Effect.mapError((error) => controlErrorToRpc(endpoint, "owner", error))); + if (options.cliVersion !== ownerStatus.daemonCliVersion) + return yield* Effect.fail( + new DaemonUpgradeRequired({ + stackId: options.stackId ?? expectedOwner.ownershipId, + oldCliVersion: ownerStatus.daemonCliVersion, + newCliVersion: options.cliVersion, + state: ownerStatus.state, + ready: ownerStatus.ready, + }), ); - -const expectMutatingDaemonOk = ( - endpoint: ControlEndpoint, - path: string, - response: HttpClientResponse.HttpClientResponse, - fallbackName: string, -): Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError -> => - response.status >= 200 && response.status < 300 - ? Effect.void - : failDaemonResponse(endpoint, path, response, fallbackName); - -/** Fetch JSON from the daemon, dying on HTTP errors. */ -function fetchStatus(endpoint: ControlEndpoint, path: string, method = "GET") { - return Effect.gen(function* () { - const response = yield* httpResponse(endpoint, path, { method }); - const okResponse = yield* dieOnNonOkStatus( - endpoint, - path, - HttpClientResponse.filterStatusOk(response), - ); - return yield* dieOnBodyDecodeError( - endpoint, - path, - HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse), + if ( + ownerStatus.ownershipId !== expectedOwner.ownershipId || + ownerStatus.ownerSessionId !== expectedOwner.ownerSessionId || + ownerStatus.controlProtocolVersion !== expectedOwner.controlProtocolVersion || + ownerStatus.daemonCliVersion !== expectedOwner.daemonCliVersion + ) + return yield* Effect.fail( + protocolError( + endpoint, + "owner", + "Remote supervisor owner descriptor changed before RPC construction", + ), + ); + const rpcHttpClient = HttpClient.mapRequest( + makeHttpClient(endpoint, transport, { + ownershipId: expectedOwner.ownershipId, + ownerSessionId: expectedOwner.ownerSessionId, + }), + HttpClientRequest.prependUrl(`${endpoint.url}${STACK_RPC_PATH}`), ); - }); -} - -function fetchLogEntries(endpoint: ControlEndpoint, path: string) { - return Effect.gen(function* () { - const response = yield* httpResponse(endpoint, path); - const okResponse = yield* dieOnNonOkStatus( - endpoint, - path, - HttpClientResponse.filterStatusOk(response), + const protocol = yield* RpcClient.makeProtocolHttp(rpcHttpClient).pipe( + Effect.provide(RpcSerialization.layerNdjson), ); - return yield* dieOnBodyDecodeError( - endpoint, - path, - HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse), + const client = yield* RpcClient.make(StackRpc).pipe( + Effect.provideService(RpcClient.Protocol, protocol), ); + return { client, owner: ownerStatus }; }); -} -function encodeSearchParams( - params: Record | undefined>, -): string { - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value === undefined) continue; - if (Array.isArray(value)) { - for (const item of value) { - searchParams.append(key, item); - } - continue; - } - searchParams.set(key, String(value)); - } - const query = searchParams.toString(); - return query.length > 0 ? `?${query}` : ""; -} +type StackRpcDomainError = Rpc.Error>; +type StackRpcFailure = StackRpcDomainError | RpcClientError.RpcClientError; -/** Convert a ReadableStream SSE body into an Effect Stream of parsed events. */ -function sseStream( - endpoint: ControlEndpoint, - path: string, - parse: (data: string) => Effect.Effect, -) { - return Stream.unwrap( - Effect.gen(function* () { - const controller = new AbortController(); - const response = yield* httpFetch(endpoint, path, { signal: controller.signal }); - if (!response.ok) { - return yield* new HttpTransportClientError({ - endpoint, - path, - cause: new Error(`SSE request failed: ${response.status}`), - reason: "status", - }); - } - const body = response.body; - if (body === null) { - return yield* new HttpTransportClientError({ - endpoint, - path, - cause: new Error("SSE response body is missing"), - reason: "protocol", - }); - } - - // State shared across chunks — parser is stateful, accumulates partial events - const collected: string[] = []; - const parser = Sse.makeParser((event) => { - if (Predicate.isTagged(event, "Event")) { - collected.push(event.data); - } - }); +const isRpcClientFailure = ( + error: E, +): error is Extract => + error instanceof RpcClientError.RpcClientError; - return Stream.fromReadableStream({ - evaluate: () => body, - onError: (cause) => - new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), - }).pipe( - Stream.mapEffect((chunk: Uint8Array) => - Effect.sync(() => { - collected.length = 0; - parser.feed(new TextDecoder().decode(chunk, { stream: true })); - return Array.from(collected); - }).pipe(Effect.flatMap((events) => Effect.forEach(events, parse))), - ), - Stream.flatMap(Stream.fromIterable), - Stream.ensuring(Effect.sync(() => controller.abort())), - ); - }), +const callRpc = ( + endpoint: ControlEndpoint, + procedure: string, + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchIf(isRpcClientFailure, (error) => + Effect.fail(translateRpcClientFailure(error, endpoint, procedure)), + ), ); -} - -/** Deserialize a plain JSON object into a ServiceState Data.Class instance. */ -function toServiceState( - raw: (typeof StatusResponseSchema.Type)["services"][number], -): StackServiceState { - return new StackServiceState({ - name: raw.name, - status: raw.status, - pid: raw.pid, - exitCode: raw.exitCode, - restartCount: raw.restartCount, - startedAt: raw.startedAt, - error: raw.error, - }); -} -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -/** - * RemoteStack implements the Stack interface over HTTP to a daemon running - * on a deterministic loopback control endpoint. - * This allows the CLI to transparently switch between foreground - * (in-process) and detached (daemon) modes. - */ +const streamRpc = ( + endpoint: ControlEndpoint, + procedure: string, + stream: Stream.Stream, +) => + stream.pipe( + Stream.catchIf(isRpcClientFailure, (error) => + Stream.fail(translateRpcClientFailure(error, endpoint, procedure)), + ), + ); export const RemoteStack = { - layer: (endpoint: ControlEndpoint): Layer.Layer => + layer: ( + endpoint: ControlEndpoint, + options: RemoteStackOptions, + ): Layer.Layer< + Stack, + DaemonUpgradeRequired | StackRpcTransportError | StackRpcProtocolError, + HttpTransportClient + > => Layer.effect( Stack, Effect.gen(function* () { - const httpTransportClient = yield* HttpTransportClient; - const httpTransportClientLayer = Layer.succeed(HttpTransportClient, httpTransportClient); - const withHttpTransportClient = ( - effect: Effect.Effect, + const parentScope = yield* Effect.scope; + const rpcScope = yield* Scope.fork(parentScope); + const transport = yield* HttpTransportClient; + const control = makeHttpControlClient(transport); + const { client } = yield* makeRemoteRpcClient(endpoint, options).pipe( + Scope.provide(rpcScope), + ); + const closeRpcScope = yield* Effect.cached(Scope.close(rpcScope, Exit.void)); + const scopedRpcStream = (stream: Stream.Stream) => + stream.pipe(Stream.provideService(Scope.Scope, rpcScope)); + const call = ( + procedure: string, + effect: Effect.Effect, + ) => callRpc(endpoint, procedure, effect); + const fastCall = ( + procedure: string, + effect: Effect.Effect, ) => - effect.pipe( - Effect.provide(httpTransportClientLayer), - Effect.catchTag("HttpTransportClientError", (error) => Effect.die(error)), + call(procedure, effect).pipe( + Effect.timeout("30 seconds"), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(transportError(endpoint, procedure, cause)), + ), ); - const withHttpTransportClientStream = ( - stream: Stream.Stream, - ) => - stream.pipe( - Stream.provide(httpTransportClientLayer), - Stream.catchTag("HttpTransportClientError", (error) => Stream.die(error)), + const requestStop = () => { + const owner = options.owner; + // Closing the RPC scope is best-effort cleanup. The control-plane + // stop is fenced to the session captured during the owner handshake + // and must still be sent when a stream/client finalizer fails. Keep + // the handoff uninterruptible so an interrupted scope close cannot + // skip the stop; the fenced stop remains the observable operation + // and its typed failure is preserved for callers. + return Effect.uninterruptibleMask((restore) => + Effect.exit(restore(closeRpcScope)).pipe( + Effect.andThen( + control.stopSession(endpoint, owner.ownershipId, owner.ownerSessionId), + ), + ), ); - const withLifecycleRequest = ( - request: (signal: AbortSignal) => Effect.Effect, - ) => withHttpTransportClient(withAbortSignal(request)); - + }; return { - getInfo: () => - withHttpTransportClient( - Effect.map(fetchStatus(endpoint, "/status"), (res) => res.info), - ), - - start: () => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const path = "/start"; - const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); - yield* expectDaemonOk(endpoint, path, response, "stack").pipe( - Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), - ); - }), - ), - - stop: () => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); - yield* dieOnNonOkStatus( - endpoint, - path, - HttpClientResponse.filterStatusOk(response), - ); - }), - ), - - dispose: () => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); - yield* dieOnNonOkStatus( - endpoint, - path, - HttpClientResponse.filterStatusOk(response), - ); - }), - ), - - startService: (name: string) => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const servicePath = yield* publicServicePath(name); - const path = `/services/${servicePath}/start`; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - }); - yield* expectMutatingDaemonOk(endpoint, path, response, name); - }), - ), - - stopService: (name: string) => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const servicePath = yield* publicServicePath(name); - const path = `/services/${servicePath}/stop`; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - }); - yield* expectMutatingDaemonOk(endpoint, path, response, name).pipe( - Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), - Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), - ); - }), - ), - - restartService: (name: string) => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const servicePath = yield* publicServicePath(name); - const path = `/services/${servicePath}/restart`; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - }); - yield* expectMutatingDaemonOk(endpoint, path, response, name); - }), - ), - + getInfo: () => fastCall("GetInfo", client.GetInfo(undefined)), + start: () => call("StartStack", client.StartStack(undefined)), + stop: () => requestStop(), + dispose: () => requestStop(), + startService: (name: string) => call("StartService", client.StartService({ name })), + stopService: (name: string) => call("StopService", client.StopService({ name })), + restartService: (name: string) => call("RestartService", client.RestartService({ name })), reloadFunctions: (opts) => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const path = "/functions/reload"; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify(opts ?? {}), - }); - yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); - }), + call( + "ReloadFunctions", + client.ReloadFunctions(opts === undefined ? {} : { options: opts }), ), - - reloadEdgeRuntime: (opts) => - withLifecycleRequest((signal) => - Effect.gen(function* () { - const path = "/edge-runtime/reload"; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify(opts), - }); - yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); - }), - ), - + reloadEdgeRuntime: (opts) => call("ReloadEdgeRuntime", client.ReloadEdgeRuntime(opts)), getState: (name: string) => - withHttpTransportClient( - Effect.gen(function* () { - const { services } = yield* fetchStatus(endpoint, "/status"); - const match = services.find((s) => s.name === name); - if (!match) { - return yield* new ServiceNotFoundError({ name }); - } - return toServiceState(match); - }), + fastCall("GetServiceState", client.GetServiceState({ name })).pipe( + Effect.map((state) => new StackServiceState(state)), ), - getAllStates: () => - withHttpTransportClient( - Effect.map(fetchStatus(endpoint, "/status"), (res) => - res.services.map(toServiceState), - ), + fastCall("GetAllServiceStates", client.GetAllServiceStates(undefined)).pipe( + Effect.map((states) => states.map((state) => new StackServiceState(state))), ), - stateChanges: (name: string) => - withHttpTransportClient( - Effect.gen(function* () { - // Verify the service exists first - const { services } = yield* fetchStatus(endpoint, "/status"); - if (!services.some((s) => s.name === name)) { - return yield* new ServiceNotFoundError({ name }); - } - return withHttpTransportClientStream( - sseStream(endpoint, "/status/stream", (data) => - decodeStatusServiceEvent(endpoint, "/status/stream", data), - ).pipe(Stream.filter((s) => s.name === name)), - ); - }), + fastCall("GetServiceState", client.GetServiceState({ name })).pipe( + Effect.as( + scopedRpcStream( + streamRpc(endpoint, "WatchServiceStates", client.WatchServiceStates({ name })), + ).pipe(Stream.map((state) => new StackServiceState(state))), + ), ), - allStateChanges: () => - withHttpTransportClientStream( - sseStream(endpoint, "/status/stream", (data) => - decodeStatusServiceEvent(endpoint, "/status/stream", data), - ), + scopedRpcStream( + streamRpc(endpoint, "WatchServiceStates", client.WatchServiceStates({})), + ).pipe( + Stream.catchTag("ServiceNotFoundError", Stream.die), + Stream.map((state) => new StackServiceState(state)), ), - - waitReady: (name, opts) => - withHttpTransportClient( - withAbortSignal((signal) => - Effect.gen(function* () { - const servicePath = yield* publicServicePath(name); - const path = `/services/${servicePath}/ready`; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify(opts ?? inheritReadyOptions), - }); - yield* expectDaemonOk(endpoint, path, response, name); - }), - ), + waitReady: (name: string, opts) => + call( + "WaitServiceReady", + client.WaitServiceReady({ name, options: opts ?? inheritReadyOptions }), ), - waitAllReady: (opts) => - withHttpTransportClient( - withAbortSignal((signal) => - Effect.gen(function* () { - const path = "/ready"; - const response = yield* httpResponse(endpoint, path, { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify(opts ?? inheritReadyOptions), - }); - yield* expectDaemonOk(endpoint, path, response, "stack").pipe( - Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), - ); - }), - ), - ), - + call("WaitStackReady", client.WaitStackReady({ options: opts ?? inheritReadyOptions })), subscribeLogs: (name: string) => - withHttpTransportClientStream( - sseStream(endpoint, `/logs/${encodeURIComponent(name)}`, (data) => - decodeLogEntryEvent(endpoint, `/logs/${encodeURIComponent(name)}`, data), + scopedRpcStream(streamRpc(endpoint, "WatchLogs", client.WatchLogs({ name }))), + subscribeAllLogs: (services) => + scopedRpcStream( + streamRpc( + endpoint, + "WatchLogs", + client.WatchLogs(services === undefined ? {} : { services }), ), ), - - subscribeAllLogs: (services) => { - const query = encodeSearchParams({ service: services }); - return withHttpTransportClientStream( - sseStream(endpoint, `/logs${query}`, (data) => - decodeLogEntryEvent(endpoint, `/logs${query}`, data), - ), - ); - }, - - logHistory: (name: string, limit?: number) => { - const query = limit !== undefined ? `?limit=${limit}` : ""; - return withHttpTransportClient( - fetchLogEntries(endpoint, `/logs/${encodeURIComponent(name)}/history${query}`), - ); - }, - - logHistoryAll: (limit?: number, services?: ReadonlyArray) => { - const query = encodeSearchParams({ limit, service: services }); - return withHttpTransportClient(fetchLogEntries(endpoint, `/logs/history${query}`)); - }, + logHistory: (name: string, limit?: number) => + fastCall( + "GetLogHistory", + client.GetLogHistory(limit === undefined ? { name } : { name, limit }), + ), + logHistoryAll: (limit?: number, services?: ReadonlyArray) => + fastCall( + "GetLogHistory", + client.GetLogHistory({ + ...(limit === undefined ? {} : { limit }), + ...(services === undefined ? {} : { services }), + }), + ), }; }), ), }; + +export const updateRemoteLaunch = ( + endpoint: ControlEndpoint, + options: RemoteStackOptions, + stackId: string, + launch: StackLaunchUpdateRpc, +): Effect.Effect< + void, + DaemonUpgradeRequired | StackBuildError | StackRpcTransportError | StackRpcProtocolError, + HttpTransportClient +> => + Effect.scoped( + makeRemoteRpcClient(endpoint, options).pipe( + Effect.flatMap(({ client }) => + callRpc(endpoint, "UpdateLaunch", client.UpdateLaunch({ stackId, launch })).pipe( + Effect.timeout("30 seconds"), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(transportError(endpoint, "UpdateLaunch", cause)), + ), + ), + ), + ), + ); diff --git a/packages/stack/src/ServiceExclusions.ts b/packages/stack/src/ServiceExclusions.ts new file mode 100644 index 0000000000..33b9def78b --- /dev/null +++ b/packages/stack/src/ServiceExclusions.ts @@ -0,0 +1,34 @@ +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; + +const excludedCompanions: Readonly>> = { + storage: ["imgproxy"], + pgmeta: ["studio"], + analytics: ["vector"], + postgres: [], + postgrest: [], + auth: [], + "edge-runtime": [], + realtime: [], + imgproxy: [], + mailpit: [], + studio: [], + vector: [], + pooler: [], +}; + +const isServiceName = (value: string): value is ServiceName => + SERVICE_NAMES.some((service) => service === value); + +/** Expands public exclusions to include services whose graph requires them. */ +export const expandExcludedServices = ( + services: ReadonlyArray, +): ReadonlySet => { + const expanded = new Set(); + for (const service of services) { + if (!isServiceName(service)) continue; + expanded.add(service); + for (const companion of excludedCompanions[service]) expanded.add(companion); + } + return expanded; +}; diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 1a53cdb069..4e28514b93 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -1,7 +1,14 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; -import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; +import { + StackBuildError, + StackNotRunningError, + StackReadinessError, + StackRpcProtocolError, + StackRpcTransportError, + StackUnavailableError, +} from "./errors.ts"; import { ResolvedFunctionsBundleSchema, type FunctionsReloadConfig, @@ -9,6 +16,13 @@ import { } from "./functions.ts"; import type { EdgeRuntimeConfig, ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; +import type { + ControlAddressConflictError, + ControlProtocolError, + ControlProtocolMismatchError, + ControlTransportError, +} from "./managed/control.ts"; +import type { StopTimeout } from "./errors.ts"; export interface StackInfo { readonly url: string; @@ -50,13 +64,35 @@ export interface EdgeRuntimeReloadConfig { export class Stack extends Context.Service< Stack, { - readonly getInfo: () => Effect.Effect; + readonly getInfo: () => Effect.Effect< + StackInfo, + StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; readonly start: () => Effect.Effect< void, - ServiceReadyError | StackBuildError | StackReadinessError + | ServiceReadyError + | StackBuildError + | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError + >; + readonly stop: () => Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout + >; + readonly dispose: () => Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout >; - readonly stop: () => Effect.Effect; - readonly dispose: () => Effect.Effect; readonly startService: ( name: string, ) => Effect.Effect< @@ -66,10 +102,21 @@ export class Stack extends Context.Service< | StackBuildError | StackNotRunningError | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError >; readonly stopService: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + | ServiceNotFoundError + | StackBuildError + | StackNotRunningError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError + >; readonly restartService: ( name: string, ) => Effect.Effect< @@ -79,6 +126,9 @@ export class Stack extends Context.Service< | StackBuildError | StackNotRunningError | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError >; readonly reloadFunctions: ( opts?: FunctionsReloadConfig, @@ -89,6 +139,9 @@ export class Stack extends Context.Service< | StackBuildError | StackNotRunningError | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError >; readonly reloadEdgeRuntime: ( opts: EdgeRuntimeReloadConfig, @@ -99,29 +152,85 @@ export class Stack extends Context.Service< | StackBuildError | StackNotRunningError | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError + >; + readonly getState: ( + name: string, + ) => Effect.Effect< + StackServiceState, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; + readonly getAllStates: () => Effect.Effect< + ReadonlyArray, + StackUnavailableError | StackRpcTransportError | StackRpcProtocolError >; - readonly getState: (name: string) => Effect.Effect; - readonly getAllStates: () => Effect.Effect>; readonly stateChanges: ( name: string, - ) => Effect.Effect, ServiceNotFoundError>; - readonly allStateChanges: () => Stream.Stream; + ) => Effect.Effect< + Stream.Stream< + StackServiceState, + | ServiceNotFoundError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError + >, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; + readonly allStateChanges: () => Stream.Stream< + StackServiceState, + StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; readonly waitReady: ( name: string, opts?: ReadyOptions, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError >; readonly waitAllReady: ( opts?: ReadyOptions, - ) => Effect.Effect; - readonly subscribeLogs: (name: string) => Stream.Stream; - readonly subscribeAllLogs: (services?: ReadonlyArray) => Stream.Stream; - readonly logHistory: (name: string, limit?: number) => Effect.Effect>; + ) => Effect.Effect< + void, + | ServiceReadyError + | StackBuildError + | StackReadinessError + | StackUnavailableError + | StackRpcTransportError + | StackRpcProtocolError + >; + readonly subscribeLogs: ( + name: string, + ) => Stream.Stream< + LogEntry, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; + readonly subscribeAllLogs: ( + services?: ReadonlyArray, + ) => Stream.Stream< + LogEntry, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; + readonly logHistory: ( + name: string, + limit?: number, + ) => Effect.Effect< + ReadonlyArray, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; readonly logHistoryAll: ( limit?: number, services?: ReadonlyArray, - ) => Effect.Effect>; + ) => Effect.Effect< + ReadonlyArray, + ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError + >; } >()("stack/Stack") {} diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index e35ef8a224..3970b0e99e 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -422,7 +422,7 @@ const enabledServiceConfig = ( config: Config | false | undefined, ): Config | undefined => (enabled && config !== false ? config : undefined); -const rawServiceEnabled = (config: StackConfig, service: ServiceName): boolean => { +export const rawServiceEnabled = (config: StackConfig, service: ServiceName): boolean => { switch (service) { case "postgres": return true; diff --git a/packages/stack/src/StackRpc.integration.test.ts b/packages/stack/src/StackRpc.integration.test.ts new file mode 100644 index 0000000000..dd7d2d6083 --- /dev/null +++ b/packages/stack/src/StackRpc.integration.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { StackRpc, STACK_RPC_PATH } from "./StackRpc.ts"; + +describe("Stack RPC contract", () => { + it("defines one shared runtime contract at the stable rpc endpoint", () => { + expect(STACK_RPC_PATH).toBe("/rpc"); + expect([...StackRpc.requests.keys()]).toEqual([ + "GetInfo", + "StartStack", + "StartService", + "StopService", + "RestartService", + "WaitStackReady", + "WaitServiceReady", + "ReloadFunctions", + "ReloadEdgeRuntime", + "UpdateLaunch", + "GetServiceState", + "GetAllServiceStates", + "WatchServiceStates", + "GetLogHistory", + "WatchLogs", + ]); + }); +}); diff --git a/packages/stack/src/StackRpc.ts b/packages/stack/src/StackRpc.ts new file mode 100644 index 0000000000..ea4f354d0e --- /dev/null +++ b/packages/stack/src/StackRpc.ts @@ -0,0 +1,266 @@ +import { Schema, SchemaTransformation } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { ServiceNotFoundError, ServiceReadyError } from "@supabase/process-compose"; +import { + StackBuildError, + StackNotRunningError, + StackReadinessError, + StackUnavailableError, +} from "./errors.ts"; +import { StackInfoSchema } from "./Stack.ts"; +import { StackServiceStatusSchema } from "./StackServiceState.ts"; +import { ResolvedFunctionsBundleSchema } from "./functions.ts"; +import { ReadyOptionsSchema } from "./StackConfig.ts"; + +/** Headers that fence same-version RPC calls to one observed supervisor session. */ +const STACK_RPC_FENCE_HEADERS = { + ownershipId: "x-supabase-stack-ownership-id", + ownerSessionId: "x-supabase-stack-owner-session-id", +} as const; + +export interface StackRpcFence { + readonly ownershipId: string; + readonly ownerSessionId: string; +} + +export const stackRpcFenceHeaders = (fence: StackRpcFence): Readonly> => ({ + [STACK_RPC_FENCE_HEADERS.ownershipId]: fence.ownershipId, + [STACK_RPC_FENCE_HEADERS.ownerSessionId]: fence.ownerSessionId, +}); + +export const matchesStackRpcFence = ( + headers: Readonly>, + expected: StackRpcFence, +): boolean => + headers[STACK_RPC_FENCE_HEADERS.ownershipId] === expected.ownershipId && + headers[STACK_RPC_FENCE_HEADERS.ownerSessionId] === expected.ownerSessionId; + +const StackUnavailableErrorSchema = Schema.TaggedStruct("StackUnavailableError", { + phase: Schema.Literals(["starting", "stopping", "failed", "deleting"]), + detail: Schema.optionalKey(Schema.String), +}).pipe( + Schema.decodeTo( + Schema.instanceOf(StackUnavailableError), + SchemaTransformation.transform({ + decode: (value) => new StackUnavailableError(value), + encode: (value) => value, + }), + ), +); + +const ServiceNotFoundErrorSchema = Schema.TaggedStruct("ServiceNotFoundError", { + name: Schema.String, +}).pipe( + Schema.decodeTo( + Schema.instanceOf(ServiceNotFoundError), + SchemaTransformation.transform({ + decode: (value) => new ServiceNotFoundError(value), + encode: (value) => value, + }), + ), +); + +const ServiceReadyErrorSchema = Schema.TaggedStruct("ServiceReadyError", { + name: Schema.String, + reason: Schema.String, + exitCode: Schema.optionalKey(Schema.Number), +}).pipe( + Schema.decodeTo( + Schema.instanceOf(ServiceReadyError), + SchemaTransformation.transform({ + decode: (value) => new ServiceReadyError(value), + encode: (value) => value, + }), + ), +); + +const StackBuildErrorSchema = Schema.TaggedStruct("StackBuildError", { + detail: Schema.String, + reason: Schema.optionalKey( + Schema.Literals(["invalid_config", "docker_not_running", "asset_preparation"]), + ), +}).pipe( + Schema.decodeTo( + Schema.instanceOf(StackBuildError), + SchemaTransformation.transform({ + decode: (value) => new StackBuildError(value), + encode: (value) => value, + }), + ), +); + +const StackNotRunningErrorSchema = Schema.TaggedStruct("StackNotRunningError", { + phase: Schema.String, +}).pipe( + Schema.decodeTo( + Schema.instanceOf(StackNotRunningError), + SchemaTransformation.transform({ + decode: (value) => new StackNotRunningError(value), + encode: (value) => value, + }), + ), +); + +const StackReadinessErrorSchema = Schema.TaggedStruct("StackReadinessError", { + target: Schema.String, + timeoutMs: Schema.Number, + detail: Schema.String, +}).pipe( + Schema.decodeTo( + Schema.instanceOf(StackReadinessError), + SchemaTransformation.transform({ + decode: (value) => new StackReadinessError(value), + encode: (value) => value, + }), + ), +); + +const buildReadyErrors = Schema.Union([ + StackUnavailableErrorSchema, + ServiceReadyErrorSchema, + StackBuildErrorSchema, + StackReadinessErrorSchema, +]); +const serviceReadyErrors = Schema.Union([ + StackUnavailableErrorSchema, + ServiceNotFoundErrorSchema, + ServiceReadyErrorSchema, + StackBuildErrorSchema, + StackReadinessErrorSchema, +]); +const serviceMutatingErrors = Schema.Union([ + StackUnavailableErrorSchema, + ServiceNotFoundErrorSchema, + ServiceReadyErrorSchema, + StackBuildErrorSchema, + StackNotRunningErrorSchema, + StackReadinessErrorSchema, +]); +const stopServiceErrors = Schema.Union([ + StackUnavailableErrorSchema, + ServiceNotFoundErrorSchema, + StackBuildErrorSchema, + StackNotRunningErrorSchema, +]); +const serviceStateErrors = Schema.Union([StackUnavailableErrorSchema, ServiceNotFoundErrorSchema]); + +const StackServiceStateSchema = Schema.Struct({ + name: Schema.String, + status: StackServiceStatusSchema, + pid: Schema.NullOr(Schema.Number), + exitCode: Schema.NullOr(Schema.Number), + restartCount: Schema.Number, + startedAt: Schema.NullOr(Schema.Number), + error: Schema.NullOr(Schema.String), +}); + +const StackLogEntrySchema = Schema.Struct({ + timestamp: Schema.Number, + service: Schema.String, + stream: Schema.Union([Schema.Literal("stdout"), Schema.Literal("stderr")]), + line: Schema.String, +}); + +const ReadyOptionsRpcSchema = ReadyOptionsSchema; + +const EdgeRuntimeReloadRpcSchema = Schema.Struct({ + edgeRuntime: Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + inspectorPort: Schema.optionalKey(Schema.Number), + policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"])), + env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }), + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), +}); + +const StackLaunchUpdateRpcSchema = Schema.Struct({ + versions: Schema.Record(Schema.String, Schema.String), + excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), + lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), +}); +export type StackLaunchUpdateRpc = typeof StackLaunchUpdateRpcSchema.Type; + +/** One same-version RPC contract for every runtime operation. */ +export const StackRpc = RpcGroup.make( + Rpc.make("GetInfo", { success: StackInfoSchema, error: StackUnavailableErrorSchema }), + Rpc.make("StartStack", { success: Schema.Void, error: buildReadyErrors }), + Rpc.make("StartService", { + payload: { name: Schema.String }, + success: Schema.Void, + error: serviceMutatingErrors, + }), + Rpc.make("StopService", { + payload: { name: Schema.String }, + success: Schema.Void, + error: stopServiceErrors, + }), + Rpc.make("RestartService", { + payload: { name: Schema.String }, + success: Schema.Void, + error: serviceMutatingErrors, + }), + Rpc.make("WaitStackReady", { + payload: { options: Schema.optionalKey(ReadyOptionsRpcSchema) }, + success: Schema.Void, + error: buildReadyErrors, + }), + Rpc.make("WaitServiceReady", { + payload: { name: Schema.String, options: Schema.optionalKey(ReadyOptionsRpcSchema) }, + success: Schema.Void, + error: serviceReadyErrors, + }), + Rpc.make("ReloadFunctions", { + payload: { + options: Schema.optionalKey( + Schema.Struct({ functions: Schema.optionalKey(ResolvedFunctionsBundleSchema) }), + ), + }, + success: Schema.Void, + error: serviceMutatingErrors, + }), + Rpc.make("ReloadEdgeRuntime", { + payload: EdgeRuntimeReloadRpcSchema, + success: Schema.Void, + error: serviceMutatingErrors, + }), + Rpc.make("UpdateLaunch", { + payload: { stackId: Schema.String, launch: StackLaunchUpdateRpcSchema }, + success: Schema.Void, + error: StackBuildErrorSchema, + }), + Rpc.make("GetServiceState", { + payload: { name: Schema.String }, + success: StackServiceStateSchema, + error: serviceStateErrors, + }), + Rpc.make("GetAllServiceStates", { + success: Schema.Array(StackServiceStateSchema), + error: StackUnavailableErrorSchema, + }), + Rpc.make("WatchServiceStates", { + payload: { name: Schema.optionalKey(Schema.String) }, + success: StackServiceStateSchema, + error: serviceStateErrors, + stream: true, + }), + Rpc.make("GetLogHistory", { + payload: { + name: Schema.optionalKey(Schema.String), + limit: Schema.optionalKey(Schema.Number), + services: Schema.optionalKey(Schema.Array(Schema.String)), + }, + success: Schema.Array(StackLogEntrySchema), + error: serviceStateErrors, + }), + Rpc.make("WatchLogs", { + payload: { + name: Schema.optionalKey(Schema.String), + services: Schema.optionalKey(Schema.Array(Schema.String)), + }, + success: StackLogEntrySchema, + error: serviceStateErrors, + stream: true, + }), +); + +export const STACK_RPC_PATH = "/rpc" as const; diff --git a/packages/stack/src/StackRpcHandlers.integration.test.ts b/packages/stack/src/StackRpcHandlers.integration.test.ts new file mode 100644 index 0000000000..7f91149c15 --- /dev/null +++ b/packages/stack/src/StackRpcHandlers.integration.test.ts @@ -0,0 +1,177 @@ +import { ServiceNotFoundError } from "@supabase/process-compose"; +import { it } from "@effect/vitest"; +import { Context, Effect, Layer, Stream } from "effect"; +import { expect } from "vitest"; +import { httpTransportClientLayer } from "./HttpTransportClient.ts"; +import { RemoteStack } from "./RemoteStack.ts"; +import { Stack } from "./Stack.ts"; +import { StackBuildError } from "./errors.ts"; +import { acquireControl, isControlOwnership } from "./managed/control.ts"; +import { controlTransportLayer } from "./platform-node.ts"; +import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { StackServiceState } from "./StackServiceState.ts"; +import { stackRpcFenceHeaders } from "./StackRpc.ts"; + +const OWNER_ID = "e".repeat(64); + +const serviceState = (name: string) => + new StackServiceState({ + name, + status: "Running", + pid: 1, + exitCode: null, + restartCount: 0, + startedAt: 1, + error: null, + }); + +const logs = [ + { timestamp: 1, service: "postgres", stream: "stdout" as const, line: "postgres starting" }, + { timestamp: 2, service: "auth", stream: "stdout" as const, line: "auth starting" }, + { timestamp: 3, service: "postgres", stream: "stdout" as const, line: "postgres ready" }, + { timestamp: 4, service: "auth", stream: "stdout" as const, line: "auth ready" }, + { timestamp: 5, service: "auth", stream: "stdout" as const, line: "auth accepting" }, + { timestamp: 6, service: "storage", stream: "stdout" as const, line: "storage ready" }, +]; + +const stack: Stack["Service"] = { + getInfo: () => + Effect.succeed({ + url: "http://127.0.0.1:54321", + dbUrl: "postgresql://localhost/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, + }), + start: () => Effect.void, + stop: () => Effect.void, + dispose: () => Effect.void, + startService: () => Effect.void, + stopService: () => Effect.void, + restartService: () => Effect.void, + reloadFunctions: () => + Effect.fail( + new StackBuildError({ + detail: "Invalid Edge Functions reload payload", + reason: "invalid_config", + }), + ), + reloadEdgeRuntime: () => Effect.void, + getState: (name) => + name === "postgres" || name === "auth" + ? Effect.succeed(serviceState(name)) + : Effect.fail(new ServiceNotFoundError({ name })), + getAllStates: () => Effect.succeed([serviceState("postgres"), serviceState("auth")]), + stateChanges: (name) => Effect.succeed(Stream.fromIterable([serviceState(name)])), + allStateChanges: () => Stream.fromIterable([serviceState("postgres"), serviceState("auth")]), + waitReady: () => Effect.void, + waitAllReady: () => Effect.void, + subscribeLogs: (name) => Stream.fromIterable(logs.filter((entry) => entry.service === name)), + subscribeAllLogs: (services) => + Stream.fromIterable( + services === undefined || services.length === 0 + ? logs + : logs.filter((entry) => services.includes(entry.service)), + ), + logHistory: (name, limit) => + Effect.succeed(logs.filter((entry) => entry.service === name).slice(-(limit ?? 100))), + logHistoryAll: (limit, services) => + Effect.succeed( + (services === undefined || services.length === 0 + ? logs + : logs.filter((entry) => services.includes(entry.service)) + ).slice(-(limit ?? 100)), + ), +}; + +it.live("serves handler behavior over the RPC boundary", () => + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: OWNER_ID, + ownerSessionId: "handler-session", + daemonCliVersion: "test", + }); + const application = { + app: yield* makeSupervisorControlApplication(lifecycle), + }; + const owner = yield* acquireControl({ + stackId: OWNER_ID, + initialStatus: yield* lifecycle.currentStatus, + application, + }); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); + yield* lifecycle.setClose(owner.close); + const status = yield* owner.ownerStatus; + const layer = RemoteStack.layer(owner.endpoint, { + cliVersion: "test", + owner: { + ownershipId: OWNER_ID, + ownerSessionId: status.ownerSessionId, + controlProtocolVersion: status.controlProtocolVersion, + daemonCliVersion: status.daemonCliVersion, + }, + }).pipe(Layer.provide(httpTransportClientLayer)); + const remote = yield* Layer.build(layer).pipe( + Effect.map((context) => Context.get(context, Stack)), + ); + + const unavailable = yield* Effect.flip(remote.getInfo()); + expect(unavailable).toMatchObject({ _tag: "StackUnavailableError", phase: "starting" }); + yield* lifecycle.publishStack(stack); + expect((yield* remote.getInfo()).url).toBe("http://127.0.0.1:54321"); + + const history = yield* remote.logHistoryAll(3, ["postgres", "auth"]); + expect(history.map((entry) => entry.line)).toEqual([ + "postgres ready", + "auth ready", + "auth accepting", + ]); + + const rawReload = yield* Effect.promise(() => + fetch(`${owner.endpoint.url}/rpc`, { + method: "POST", + headers: { + "content-type": "application/ndjson", + ...stackRpcFenceHeaders({ + ownershipId: status.ownershipId, + ownerSessionId: status.ownerSessionId, + }), + }, + body: `${JSON.stringify({ + _tag: "Request", + id: "redaction-test", + tag: "ReloadFunctions", + payload: { + options: { + functions: { + env: { SECRET: "must-not-appear-in-errors" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "relative/index.ts", + importMapPath: null, + staticFiles: [], + env: {}, + }, + ], + }, + }, + }, + headers: [], + })}\n`, + }), + ); + const rawBody = yield* Effect.promise(() => rawReload.text()); + expect(rawReload.status).toBe(200); + expect(rawBody.length).toBeGreaterThan(0); + expect(rawBody).toContain("entrypointPath"); + expect(rawBody).not.toContain("must-not-appear-in-errors"); + expect(rawBody).not.toContain("relative/index.ts"); + }).pipe(Effect.provide(controlTransportLayer)), + ), +); diff --git a/packages/stack/src/StackRpcHandlers.ts b/packages/stack/src/StackRpcHandlers.ts new file mode 100644 index 0000000000..0247354330 --- /dev/null +++ b/packages/stack/src/StackRpcHandlers.ts @@ -0,0 +1,140 @@ +import { Context, Effect, Stream } from "effect"; +import { + StackBuildError, + type StackRpcProtocolError, + type StackRpcTransportError, + type StackUnavailableError, +} from "./errors.ts"; +import { inheritReadyOptions } from "./StackConfig.ts"; +import { StackRpc } from "./StackRpc.ts"; +import type { Stack } from "./Stack.ts"; +import type { StackLaunchUpdateRpc } from "./StackRpc.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; + +type StackService = Stack["Service"]; + +const local = ( + lifecycle: SupervisorLifecycle["Service"], + operation: ( + stack: StackService, + ) => Effect.Effect, +): Effect.Effect => + lifecycle.runtimeStack.pipe( + Effect.flatMap(operation), + Effect.catchTag("StackRpcTransportError", (error) => Effect.die(error)), + Effect.catchTag("StackRpcProtocolError", (error) => Effect.die(error)), + ); + +const localStream = ( + lifecycle: SupervisorLifecycle["Service"], + operation: ( + stack: StackService, + ) => Effect.Effect< + Stream.Stream, + E | StackRpcTransportError | StackRpcProtocolError + >, +): Stream.Stream => + Stream.unwrap(lifecycle.runtimeStack.pipe(Effect.flatMap(operation))).pipe( + Stream.catchTag("StackRpcTransportError", (error) => Stream.die(error)), + Stream.catchTag("StackRpcProtocolError", (error) => Stream.die(error)), + ); + +export interface StackLaunchUpdater { + readonly update: ( + stackId: string, + launch: StackLaunchUpdateRpc, + ) => Effect.Effect; +} + +export const StackLaunchUpdater = Context.Reference( + "stack/StackLaunchUpdater", + { + defaultValue: () => ({ + update: () => + Effect.fail( + new StackBuildError({ detail: "Managed launch updates require a supervisor owner" }), + ), + }), + }, +); + +/** Runtime-backed implementations for the shared StackRpc contract. */ +export const StackRpcHandlers = StackRpc.toLayer( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle; + const launchUpdater = yield* StackLaunchUpdater; + return { + GetInfo: () => local(lifecycle, (stack) => stack.getInfo()), + StartStack: () => local(lifecycle, (stack) => stack.start()), + StartService: ({ name }: { readonly name: string }) => + local(lifecycle, (stack) => stack.startService(name)), + StopService: ({ name }: { readonly name: string }) => + local(lifecycle, (stack) => stack.stopService(name)), + RestartService: ({ name }: { readonly name: string }) => + local(lifecycle, (stack) => stack.restartService(name)), + WaitStackReady: ({ + options, + }: { + readonly options?: Parameters[0]; + }) => local(lifecycle, (stack) => stack.waitAllReady(options ?? inheritReadyOptions)), + WaitServiceReady: ({ + name, + options, + }: { + readonly name: string; + readonly options?: Parameters[1]; + }) => local(lifecycle, (stack) => stack.waitReady(name, options ?? inheritReadyOptions)), + ReloadFunctions: ({ + options, + }: { + readonly options?: Parameters[0]; + }) => local(lifecycle, (stack) => stack.reloadFunctions(options)), + ReloadEdgeRuntime: (options: Parameters[0]) => + local(lifecycle, (stack) => stack.reloadEdgeRuntime(options)), + UpdateLaunch: ({ + stackId, + launch, + }: { + readonly stackId: string; + readonly launch: StackLaunchUpdateRpc; + }) => launchUpdater.update(stackId, launch), + GetServiceState: ({ name }: { readonly name: string }) => + local(lifecycle, (stack) => stack.getState(name)), + GetAllServiceStates: () => local(lifecycle, (stack) => stack.getAllStates()), + WatchServiceStates: ({ name }: { readonly name?: string }) => + name === undefined + ? localStream(lifecycle, (stack) => Effect.succeed(stack.allStateChanges())) + : localStream(lifecycle, (stack) => stack.stateChanges(name)), + GetLogHistory: ({ + name, + limit, + services, + }: { + readonly name?: string; + readonly limit?: number; + readonly services?: ReadonlyArray; + }) => + name === undefined + ? local(lifecycle, (stack) => stack.logHistoryAll(limit, services)) + : local(lifecycle, (stack) => + stack.getState(name).pipe(Effect.flatMap(() => stack.logHistory(name, limit))), + ), + WatchLogs: ({ + name, + services, + }: { + readonly name?: string; + readonly services?: ReadonlyArray; + }) => + name === undefined + ? localStream(lifecycle, (stack) => + Effect.forEach(services ?? [], (service) => stack.getState(service), { + discard: true, + }).pipe(Effect.as(stack.subscribeAllLogs(services))), + ) + : localStream(lifecycle, (stack) => + stack.getState(name).pipe(Effect.as(stack.subscribeLogs(name))), + ), + }; + }), +); diff --git a/packages/stack/src/SupervisorControlServer.integration.test.ts b/packages/stack/src/SupervisorControlServer.integration.test.ts new file mode 100644 index 0000000000..2a5ee18286 --- /dev/null +++ b/packages/stack/src/SupervisorControlServer.integration.test.ts @@ -0,0 +1,145 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Predicate } from "effect"; +import { HttpServer } from "effect/unstable/http"; +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { SupervisorControlServer } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { makeTestStack } from "./testing.ts"; + +describe("SupervisorControlServer", () => { + it("publishes owner status from the lifecycle application", async () => { + const serverLayer = NodeHttpServer.layer(() => createServer(), { port: 0 }).pipe(Layer.orDie); + const runtime = ManagedRuntime.make(serverLayer); + try { + const server = await runtime.runPromise(HttpServer.HttpServer); + const result = await runtime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: "stack", + ownerSessionId: "session", + daemonCliVersion: "test", + close: Effect.void, + }); + const application = yield* SupervisorControlServer.make(lifecycle); + yield* server.serve(application); + const address = server.address; + if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("expected tcp address"); + const response = yield* Effect.tryPromise(() => + fetch(`http://127.0.0.1:${address.port}/owner`), + ); + return { status: response.status, body: yield* Effect.promise(() => response.json()) }; + }), + ), + ); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + ownershipId: "stack", + ownerSessionId: "session", + state: "starting", + }); + } finally { + await runtime.dispose(); + } + }); + + it("flushes the fenced stop response before shutdown completes", async () => { + const serverLayer = NodeHttpServer.layer(() => createServer(), { port: 0 }).pipe(Layer.orDie); + const runtime = ManagedRuntime.make(serverLayer); + try { + const result = await runtime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: "stack", + ownerSessionId: "session", + daemonCliVersion: "test", + close: Effect.void, + }); + const started = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + yield* lifecycle.publishStack( + makeTestStack({ + stop: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + }), + ); + const server = yield* HttpServer.HttpServer; + const application = yield* SupervisorControlServer.make(lifecycle); + yield* server.serve(application); + const address = server.address; + if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("expected tcp address"); + const response = yield* Effect.promise(() => + fetch(`http://127.0.0.1:${address.port}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ownershipId: "stack", ownerSessionId: "session" }), + }), + ); + const body = yield* Effect.promise(() => response.text()); + yield* Deferred.await(started); + const stopping = yield* lifecycle.currentStatus; + yield* Deferred.succeed(release, undefined); + yield* lifecycle.awaitShutdown; + return { status: response.status, body, stopping: stopping.state }; + }), + ), + ); + expect(result).toEqual({ + status: 202, + body: JSON.stringify({ ok: true }), + stopping: "stopping", + }); + } finally { + await runtime.dispose(); + } + }); + + it("projects deleting on /owner while destructive cleanup is gated", async () => { + const serverLayer = NodeHttpServer.layer(() => createServer(), { port: 0 }).pipe(Layer.orDie); + const runtime = ManagedRuntime.make(serverLayer); + try { + const result = await runtime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: "stack", + ownerSessionId: "session", + daemonCliVersion: "test", + close: Effect.void, + }); + const entered = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + const server = yield* HttpServer.HttpServer; + const application = yield* SupervisorControlServer.make(lifecycle); + yield* server.serve(application); + const address = server.address; + if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("expected tcp address"); + const deleting = yield* Effect.forkScoped( + lifecycle.beginDeleting.pipe( + Effect.andThen(Deferred.succeed(entered, undefined)), + Effect.andThen(Deferred.await(release)), + Effect.andThen(lifecycle.requestShutdown("dispose")), + ), + ); + yield* Deferred.await(entered); + const response = yield* Effect.tryPromise(() => + fetch(`http://127.0.0.1:${address.port}/owner`, { headers: { connection: "close" } }), + ); + const body = yield* Effect.promise(() => response.json()); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(deleting); + return { status: response.status, body }; + }), + ), + ); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ state: "deleting", ready: false }); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/packages/stack/src/SupervisorControlServer.ts b/packages/stack/src/SupervisorControlServer.ts new file mode 100644 index 0000000000..d3b517df78 --- /dev/null +++ b/packages/stack/src/SupervisorControlServer.ts @@ -0,0 +1,90 @@ +import { Effect, Layer } from "effect"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ControlStopRequestSchema } from "./DaemonProtocol.ts"; +import { matchesStackRpcFence, StackRpc } from "./StackRpc.ts"; +import { + StackLaunchUpdater, + StackRpcHandlers, + type StackLaunchUpdater as StackLaunchUpdaterService, +} from "./StackRpcHandlers.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; + +/** Builds the complete static supervisor application before listener binding. */ +export const makeSupervisorControlApplication = ( + lifecycle: SupervisorLifecycle["Service"], + launchUpdater?: StackLaunchUpdaterService, +): Effect.Effect< + Effect.Effect< + HttpServerResponse.HttpServerResponse, + never, + HttpServerRequest.HttpServerRequest | import("effect/Scope").Scope + >, + never, + import("effect/Scope").Scope +> => + Effect.gen(function* () { + const handlers = + launchUpdater === undefined + ? StackRpcHandlers + : StackRpcHandlers.pipe(Layer.provide(Layer.succeed(StackLaunchUpdater, launchUpdater))); + const rpc = yield* RpcServer.toHttpEffect(StackRpc).pipe( + Effect.provide(handlers.pipe(Layer.provide(Layer.succeed(SupervisorLifecycle, lifecycle)))), + Effect.provide(RpcSerialization.layerNdjson), + ); + const fencedRpc = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const status = yield* lifecycle.currentStatus; + if ( + !matchesStackRpcFence(request.headers, { + ownershipId: status.ownershipId, + ownerSessionId: status.ownerSessionId, + }) + ) { + // These headers fence a client to the observed owner; they are not an + // authentication mechanism and carry no secret material. + return HttpServerResponse.jsonUnsafe({ error: "rpc-fence-mismatch" }, { status: 409 }); + } + return yield* rpc; + }); + const routes = [ + HttpRouter.route( + "GET", + "/owner", + lifecycle.currentStatus.pipe(Effect.map(HttpServerResponse.jsonUnsafe)), + ), + HttpRouter.route( + "POST", + "/stop", + Effect.gen(function* () { + const request = yield* HttpServerRequest.schemaBodyJson(ControlStopRequestSchema); + const status = yield* lifecycle.currentStatus; + if ( + request.ownershipId !== status.ownershipId || + request.ownerSessionId !== status.ownerSessionId + ) { + return HttpServerResponse.jsonUnsafe({ error: "conflict" }, { status: 409 }); + } + // Submit ownership of the stop transaction before returning 202. The + // listener closes gracefully after the response is flushed. + yield* lifecycle.submitShutdown("stop"); + return HttpServerResponse.jsonUnsafe({ ok: true }, { status: 202 }); + }).pipe( + Effect.catchTags({ + SchemaError: () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "invalid" }, { status: 400 })), + HttpServerError: () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "invalid" }, { status: 400 })), + }), + ), + ), + HttpRouter.route("POST", "/rpc", fencedRpc), + ]; + const application = yield* HttpRouter.toHttpEffect(HttpRouter.addAll(routes)); + return application.pipe(Effect.orDie); + }); + +export const SupervisorControlServer = { + make: makeSupervisorControlApplication, +}; diff --git a/packages/stack/src/SupervisorLifecycle.integration.test.ts b/packages/stack/src/SupervisorLifecycle.integration.test.ts new file mode 100644 index 0000000000..c784b517e1 --- /dev/null +++ b/packages/stack/src/SupervisorLifecycle.integration.test.ts @@ -0,0 +1,248 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Scope, Stream } from "effect"; +import { describe, expect, it } from "vitest"; +import type { StackInfo } from "./Stack.ts"; +import type { Stack } from "./Stack.ts"; +import { StackServiceState } from "./StackServiceState.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { StopTimeout } from "./errors.ts"; + +const stackInfo: StackInfo = { + url: "http://127.0.0.1", + dbUrl: "postgresql://127.0.0.1/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, +}; +const stackState = new StackServiceState({ + name: "auth", + status: "Running", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, +}); +type StackCleanup = ReturnType; + +const makeStack = ( + stop: () => StackCleanup, + dispose: () => StackCleanup = () => Effect.void, +): Stack["Service"] => ({ + getInfo: () => Effect.succeed(stackInfo), + start: () => Effect.void, + stop, + dispose, + startService: () => Effect.void, + stopService: () => Effect.void, + restartService: () => Effect.void, + reloadFunctions: () => Effect.void, + reloadEdgeRuntime: () => Effect.void, + getState: () => Effect.succeed(stackState), + getAllStates: () => Effect.succeed([stackState]), + stateChanges: () => Effect.succeed(Stream.empty), + allStateChanges: () => Stream.empty, + waitReady: () => Effect.void, + waitAllReady: () => Effect.void, + subscribeLogs: () => Stream.empty, + subscribeAllLogs: () => Stream.empty, + logHistory: () => Effect.succeed([]), + logHistoryAll: () => Effect.succeed([]), +}); + +const makeLifecycle = async (close: Effect.Effect = Effect.void) => { + const scope = Scope.makeUnsafe(); + const lifecycle = await Effect.runPromise( + SupervisorLifecycle.make({ + ownershipId: "stack", + ownerSessionId: "session", + daemonCliVersion: "test", + close, + }).pipe(Effect.provideService(Scope.Scope, scope)), + ); + return { + lifecycle, + close: () => Effect.runPromise(Scope.close(scope, Exit.void)), + }; +}; + +describe("SupervisorLifecycle", () => { + it("keeps runtime unavailable until publication and shares shutdown", async () => { + const { lifecycle, close } = await makeLifecycle(); + try { + const before = await Effect.runPromise(lifecycle.currentStatus); + expect(before.state).toBe("starting"); + const unavailable = await Effect.runPromise(lifecycle.runtime.pipe(Effect.exit)); + expect(Exit.isFailure(unavailable)).toBe(true); + if (Exit.isFailure(unavailable)) { + expect(unavailable.cause).toBeDefined(); + } + const stopped = { count: 0 }; + await Effect.runPromise( + lifecycle.publishStack(makeStack(() => Effect.sync(() => void (stopped.count += 1)))), + ); + expect((await Effect.runPromise(lifecycle.currentStatus)).state).toBe("running"); + await Promise.all([ + Effect.runPromise(lifecycle.requestShutdown("stop")), + Effect.runPromise(lifecycle.requestShutdown("signal")), + ]); + expect(stopped.count).toBe(1); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("closed"); + } finally { + await close(); + } + }); + + it("rejects runtime calls while stopping", async () => { + const { lifecycle, close } = await makeLifecycle(); + try { + const stopping = Deferred.makeUnsafe(); + await Effect.runPromise( + lifecycle.publishStack(makeStack(() => Deferred.succeed(stopping, undefined))), + ); + const stop = Effect.runFork(lifecycle.requestShutdown("stop")); + await Effect.runPromise(Deferred.await(stopping)); + const runtime = await Effect.runPromise(lifecycle.runtime.pipe(Effect.exit)); + expect(Exit.isFailure(runtime)).toBe(true); + await Effect.runPromise(Fiber.join(stop)); + } finally { + await close(); + } + }); + + it("keeps shared shutdown running when one waiter is interrupted", async () => { + const { lifecycle, close } = await makeLifecycle(); + try { + const started = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + let stopCount = 0; + await Effect.runPromise( + lifecycle.publishStack( + makeStack(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.tap(() => Effect.sync(() => void (stopCount += 1))), + ), + ), + ), + ); + const owner = Effect.runFork(lifecycle.requestShutdown("stop")); + await Effect.runPromise(Deferred.await(started)); + const waiter = Effect.runFork(lifecycle.requestShutdown("signal")); + await Effect.runPromise(Fiber.interrupt(owner)); + await Effect.runPromise(Deferred.succeed(release, undefined)); + await Effect.runPromise(Fiber.join(waiter)); + await Effect.runPromise(lifecycle.awaitShutdown); + expect(stopCount).toBe(1); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("closed"); + } finally { + await close(); + } + }); + + it("ignores late runtime publication after shutdown owns the state", async () => { + const { lifecycle, close } = await makeLifecycle(); + try { + const started = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + await Effect.runPromise( + lifecycle.publishStack( + makeStack(() => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + ), + ), + ); + const shutdown = Effect.runFork(lifecycle.requestShutdown("stop")); + await Effect.runPromise(Deferred.await(started)); + await Effect.runPromise(lifecycle.publishStack(makeStack(() => Effect.void))); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("stopping"); + await Effect.runPromise(Deferred.succeed(release, undefined)); + await Effect.runPromise(Fiber.join(shutdown)); + } finally { + await close(); + } + }); + + it("runs every cleanup and preserves a typed stop failure", async () => { + const stopError = new StopTimeout({ endpoint: "test", ownerSessionId: "session" }); + const events: string[] = []; + const { lifecycle, close } = await makeLifecycle( + Effect.sync(() => { + events.push("close"); + }), + ); + try { + await Effect.runPromise( + lifecycle.publishStack( + makeStack( + () => Effect.fail(stopError), + () => + Effect.sync(() => { + events.push("dispose"); + }), + ), + ), + ); + const exit = await Effect.runPromise(lifecycle.requestShutdown("signal").pipe(Effect.exit)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(stopError); + expect(events).toEqual(["dispose", "close"]); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("closed"); + } finally { + await close(); + } + }); + + it("runs every cleanup and preserves a stop defect", async () => { + const stopDefect = new Error("stop defect"); + const events: string[] = []; + const { lifecycle, close } = await makeLifecycle( + Effect.sync(() => { + events.push("close"); + }), + ); + try { + await Effect.runPromise( + lifecycle.publishStack( + makeStack( + () => Effect.die(stopDefect), + () => + Effect.sync(() => { + events.push("dispose"); + }), + ), + ), + ); + const exit = await Effect.runPromise(lifecycle.requestShutdown("signal").pipe(Effect.exit)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(stopDefect); + expect(events).toEqual(["dispose", "close"]); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("closed"); + } finally { + await close(); + } + }); + + it("uses the first cleanup failure when stop succeeds", async () => { + const disposeError = new StopTimeout({ endpoint: "dispose", ownerSessionId: "session" }); + const closeError = new Error("close failed"); + const { lifecycle, close } = await makeLifecycle(Effect.fail(closeError)); + try { + await Effect.runPromise( + lifecycle.publishStack( + makeStack( + () => Effect.void, + () => Effect.fail(disposeError), + ), + ), + ); + const exit = await Effect.runPromise(lifecycle.requestShutdown("signal").pipe(Effect.exit)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(disposeError); + expect((await Effect.runPromise(lifecycle.currentState)).phase).toBe("closed"); + } finally { + await close(); + } + }); +}); diff --git a/packages/stack/src/SupervisorLifecycle.ts b/packages/stack/src/SupervisorLifecycle.ts new file mode 100644 index 0000000000..b6c97d046c --- /dev/null +++ b/packages/stack/src/SupervisorLifecycle.ts @@ -0,0 +1,189 @@ +import { Context, Deferred, Effect, Exit, Ref, Scope } from "effect"; +import { + CONTROL_PROTOCOL, + CONTROL_PROTOCOL_VERSION, + type ControlOwnerStatus, +} from "./DaemonProtocol.ts"; +import type { Stack } from "./Stack.ts"; +import { StackUnavailableError } from "./errors.ts"; + +export { StackUnavailableError } from "./errors.ts"; + +export type SupervisorRuntime = Pick; + +export type SupervisorState = + | { readonly phase: "starting" } + | { + readonly phase: "running"; + readonly stack: Stack["Service"]; + } + | { + readonly phase: "stopping"; + readonly stack?: Stack["Service"]; + } + | { readonly phase: "failed"; readonly detail: string } + | { readonly phase: "deleting" } + | { readonly phase: "closed" }; + +export class SupervisorLifecycle extends Context.Service< + SupervisorLifecycle, + { + readonly currentState: Effect.Effect; + readonly currentStatus: Effect.Effect; + readonly runtime: Effect.Effect; + readonly runtimeStack: Effect.Effect; + readonly publishStack: (stack: Stack["Service"]) => Effect.Effect; + readonly setClose: (close: Effect.Effect) => Effect.Effect; + /** Enters the owner-visible deleting projection before destructive cleanup. */ + readonly beginDeleting: Effect.Effect; + readonly fail: (detail: string) => Effect.Effect; + /** Publishes the single shutdown reason without waiting for teardown. */ + readonly submitShutdown: ( + reason: "stop" | "signal" | "startup-failure" | "dispose", + ) => Effect.Effect; + readonly requestShutdown: ( + reason: "stop" | "signal" | "startup-failure" | "dispose", + ) => Effect.Effect; + readonly awaitShutdown: Effect.Effect; + } +>()("stack/SupervisorLifecycle") { + static make(input: { + readonly ownershipId: string; + readonly ownerSessionId: string; + readonly daemonCliVersion: string; + readonly close?: Effect.Effect; + }): Effect.Effect { + return Effect.gen(function* () { + const lifecycleScope = yield* Effect.scope; + const stateRef = Ref.makeUnsafe({ phase: "starting" }); + const closeReady = Deferred.makeUnsafe(); + const closeRef = Ref.makeUnsafe | undefined>(input.close); + const shutdownReason = Deferred.makeUnsafe< + "stop" | "signal" | "startup-failure" | "dispose" + >(); + const shutdownExit = Deferred.makeUnsafe>(); + const status = (state: SupervisorState): ControlOwnerStatus => ({ + controlProtocol: CONTROL_PROTOCOL, + controlProtocolVersion: CONTROL_PROTOCOL_VERSION, + ownershipId: input.ownershipId, + ownerSessionId: input.ownerSessionId, + state: state.phase === "closed" ? "stopping" : state.phase, + ready: state.phase === "running", + daemonCliVersion: input.daemonCliVersion, + }); + const shutdown = (_reason: string) => + Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* Ref.get(stateRef); + const stack = + current.phase === "running" + ? current.stack + : current.phase === "stopping" + ? current.stack + : undefined; + yield* Ref.set(stateRef, { + phase: "stopping", + ...(stack === undefined ? {} : { stack }), + }); + + // Each owned cleanup is evaluated independently so a failed stop + // cannot strand disposal, listener close, or the terminal state + // publication. The first failure is the shared shutdown result; + // in particular, a stop failure preserves its exact Cause. + const stopExit = + stack === undefined ? Exit.succeed(undefined) : yield* Effect.exit(stack.stop()); + const disposeExit = + stack === undefined ? Exit.succeed(undefined) : yield* Effect.exit(stack.dispose()); + if (input.close === undefined) yield* Deferred.await(closeReady); + const close = yield* Ref.get(closeRef); + const closeExit = + close === undefined ? Exit.succeed(undefined) : yield* Effect.exit(close); + + yield* Ref.set(stateRef, { phase: "closed" }); + + const failure = Exit.isFailure(stopExit) + ? stopExit.cause + : Exit.isFailure(disposeExit) + ? disposeExit.cause + : Exit.isFailure(closeExit) + ? closeExit.cause + : undefined; + if (failure !== undefined) yield* Effect.failCause(failure); + }), + ); + const completeShutdown = (exit: Exit.Exit) => + Deferred.succeed(shutdownExit, exit).pipe(Effect.asVoid); + yield* Effect.forkIn( + Deferred.await(shutdownReason).pipe( + Effect.flatMap(shutdown), + Effect.exit, + Effect.tap(completeShutdown), + ), + lifecycleScope, + ); + const awaitShutdownExit = Deferred.await(shutdownExit).pipe( + Effect.flatMap((exit) => + Exit.match(exit, { onFailure: Effect.failCause, onSuccess: Effect.succeed }), + ), + ); + const submitShutdown = (reason: "stop" | "signal" | "startup-failure" | "dispose") => + Deferred.succeed(shutdownReason, reason).pipe(Effect.asVoid); + const requestShutdown = (reason: "stop" | "signal" | "startup-failure" | "dispose") => + Effect.gen(function* () { + yield* Deferred.succeed(shutdownReason, reason); + yield* awaitShutdownExit; + }); + return { + currentState: Ref.get(stateRef), + currentStatus: Ref.get(stateRef).pipe(Effect.map(status)), + runtime: Ref.get(stateRef).pipe( + Effect.flatMap((state) => + state.phase === "running" + ? Effect.succeed(state.stack) + : Effect.fail( + new StackUnavailableError({ + phase: state.phase === "closed" ? "stopping" : state.phase, + ...(state.phase === "failed" ? { detail: state.detail } : {}), + }), + ), + ), + ), + runtimeStack: Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + if (state.phase === "running") return state.stack; + return yield* Effect.fail( + new StackUnavailableError({ + phase: state.phase === "closed" ? "stopping" : state.phase, + ...(state.phase === "failed" ? { detail: state.detail } : {}), + }), + ); + }), + publishStack: (stack) => + Ref.modify(stateRef, (state): [undefined, SupervisorState] => + state.phase === "starting" + ? [undefined, { phase: "running", stack }] + : [undefined, state], + ).pipe(Effect.asVoid), + setClose: (close) => + Ref.set(closeRef, close).pipe( + Effect.andThen(Deferred.succeed(closeReady, undefined)), + Effect.asVoid, + ), + beginDeleting: Ref.modify(stateRef, (state): [undefined, SupervisorState] => + state.phase === "starting" || state.phase === "running" + ? [undefined, { phase: "deleting" }] + : [undefined, state], + ).pipe(Effect.asVoid), + fail: (detail) => + Ref.modify(stateRef, (state): [undefined, SupervisorState] => + state.phase === "starting" || state.phase === "running" + ? [undefined, { phase: "failed", detail }] + : [undefined, state], + ).pipe(Effect.asVoid), + submitShutdown, + requestShutdown, + awaitShutdown: awaitShutdownExit, + }; + }); + } +} diff --git a/packages/stack/src/SupervisorProtocol.ts b/packages/stack/src/SupervisorProtocol.ts new file mode 100644 index 0000000000..3688c24005 --- /dev/null +++ b/packages/stack/src/SupervisorProtocol.ts @@ -0,0 +1,71 @@ +import { Schema } from "effect"; +import { ControlOwnerDescriptorSchema, ControlOwnerStateSchema } from "./DaemonProtocol.ts"; +import { managedStackLaunchInputSchema } from "./managed/document.ts"; +import { PORT_FIELDS } from "./PortCatalog.ts"; + +const portIntentSchema = Schema.Struct({ + activeFields: Schema.Array(Schema.Literals(PORT_FIELDS)), + disabledFields: Schema.optionalKey(Schema.Array(Schema.Literals(PORT_FIELDS))), + document: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}); + +export const SupervisorStartCommandSchema = Schema.Struct({ + type: Schema.Literals(["start", "upgrade-restart"]), + cliVersion: Schema.String, + stackId: Schema.String, + workspacePath: Schema.String, + stackName: Schema.String, + stateRoot: Schema.String, + config: Schema.Record(Schema.String, Schema.Unknown), + portIntents: portIntentSchema, + launch: Schema.optionalKey(managedStackLaunchInputSchema), +}); +export type SupervisorStartMessage = Schema.Schema.Type; + +const SupervisorOwnerDescriptorSchema = Schema.Struct({ + ownershipId: ControlOwnerDescriptorSchema.fields.ownershipId, + ownerSessionId: ControlOwnerDescriptorSchema.fields.ownerSessionId, + controlProtocolVersion: ControlOwnerDescriptorSchema.fields.controlProtocolVersion, + daemonCliVersion: ControlOwnerDescriptorSchema.fields.daemonCliVersion, + state: ControlOwnerStateSchema, + ready: Schema.Boolean, +}); + +export const SupervisorStartedEventSchema = Schema.Struct({ + type: Schema.Literal("started"), + endpoint: Schema.Struct({ + hostname: Schema.String, + port: Schema.Number, + url: Schema.String, + }), + owner: SupervisorOwnerDescriptorSchema, + attached: Schema.optionalKey(Schema.Boolean), +}); +export type SupervisorStartedMessage = Schema.Schema.Type; + +export const SupervisorErrorEventSchema = Schema.Struct({ + type: Schema.Literal("error"), + message: Schema.String, + errorCode: Schema.optionalKey( + Schema.Literals([ + "DAEMON_UPGRADE_REQUIRED", + "UPGRADE_PREFLIGHT", + "UPGRADE_RESTART", + "STOP_TIMEOUT", + ]), + ), + stackId: Schema.optionalKey(Schema.String), + oldCliVersion: Schema.optionalKey(Schema.String), + newCliVersion: Schema.optionalKey(Schema.String), + state: Schema.optionalKey(ControlOwnerStateSchema), + ready: Schema.optionalKey(Schema.Boolean), + detail: Schema.optionalKey(Schema.String), + endpoint: Schema.optionalKey(Schema.String), + ownerSessionId: Schema.optionalKey(Schema.String), +}); +export type SupervisorErrorMessage = Schema.Schema.Type; + +export const SupervisorEventSchema = Schema.Union([ + SupervisorStartedEventSchema, + SupervisorErrorEventSchema, +]); diff --git a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts new file mode 100644 index 0000000000..eb2ef56392 --- /dev/null +++ b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts @@ -0,0 +1,303 @@ +import { NodeServices } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Cause, Effect, Exit, Fiber, Option } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { afterEach, describe, expect } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ControlBindError, + ControlTransportError, + type ControlAttached, + type ControlOwnerStatus, + type ControlTransportShape, +} from "./managed/control.ts"; +import type { ManagedStack, ManagedStackManagerShape } from "./managed/manager.ts"; +import type { SupervisorStartMessage } from "./SupervisorProtocol.ts"; +import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; +import { restartIncompatibleOwner } from "./SupervisorUpgradeRestart.ts"; +import { UpgradePreflightError, UpgradeRestartError } from "./errors.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { fillServiceVersionManifest } from "./versions.ts"; + +const roots: Array = []; + +const allPersistedVersions = fillServiceVersionManifest({}); + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const setup = (persistedVersions: Partial> = { auth: "v-old" }) => { + const root = mkdtempSync(join(tmpdir(), "supervisor-upgrade-restart-")); + roots.push(root); + const workspacePath = join(root, "workspace"); + const stateRoot = join(root, "state"); + mkdirSync(workspacePath); + mkdirSync(stateRoot); + const stackId = "a".repeat(64); + const endpoint = { + hostname: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + } as const; + const status: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: stackId, + ownerSessionId: "old-session", + state: "running", + ready: true, + daemonCliVersion: "old", + }; + const oldOwner: ControlAttached = { + _tag: "Attached", + ownershipId: stackId, + endpoint, + observedStatus: status, + ownerStatus: Effect.succeed(status), + requestStop: Effect.void, + }; + const document: ManagedStack = { + format: "supabase-stack", + formatVersion: 1, + id: stackId, + identity: { + workspaceId: "workspace", + checkoutId: "checkout", + contextId: "context", + localProjectKey: ".", + name: "default", + }, + workspace: { + kind: "folder", + checkoutKind: "folder", + path: workspacePath, + branch: "main", + }, + ports: [], + lifecycle: "running", + launch: { + mode: "native", + versions: persistedVersions, + excludedServices: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const unused = () => Effect.die("unused manager operation"); + const manager: ManagedStackManagerShape = { + stateRoot, + discoverWorkspace: unused, + ensureWorkspace: unused, + acquireControl: unused, + probeControl: unused, + readStack: unused, + startStack: unused, + inspectStack: () => Effect.succeed(document), + listStacks: unused, + allocateManagedPorts: unused, + recordLifecycle: unused, + updateLaunch: unused, + repairWorkspace: unused, + deleteStack: unused, + }; + let stopped = false; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused bind"), + read: (readEndpoint) => + stopped + ? Effect.fail( + new ControlTransportError({ + endpoint: readEndpoint, + reason: "unreachable", + cause: new Error("old owner ended"), + }), + ) + : Effect.succeed(status), + requestStop: () => + Effect.sync(() => { + stopped = true; + }), + }; + const configInput: DaemonConfigInput = { + cwd: workspacePath, + projectDir: workspacePath, + mode: "native", + auth: false, + postgrest: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }; + const input: SupervisorStartMessage = { + type: "start", + cliVersion: "new", + stackId, + workspacePath, + stackName: "default", + stateRoot, + config: configInput, + portIntents: { activeFields: ["apiPort", "dbPort"], document: {} }, + launch: { + mode: "native", + versions: { auth: "v-new" }, + excludedServices: ["auth"], + }, + }; + return { configInput, endpoint, input, manager, oldOwner, stackId, transport }; +}; + +describe("incompatible supervisor upgrade restart", () => { + it.effect("bounds preflight before stopping the old owner", () => { + const context = setup(); + return Effect.gen(function* () { + const pending = yield* restartIncompatibleOwner({ + ...context, + configInput: context.configInput, + manager: { ...context.manager, inspectStack: () => Effect.never }, + controlTransport: context.transport, + resolutionTimeout: "30 seconds", + reacquire: () => Effect.succeed(context.oldOwner), + }).pipe( + Effect.provide(NodeServices.layer), + Effect.scoped, + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + const exit = yield* Fiber.join(pending); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(UpgradePreflightError); + expect(error.value).toMatchObject({ + detail: "Timed out preflighting upgrade restart", + }); + } + } + }); + }); + + it.live("uses the persisted launch instead of the restart invocation", () => { + const context = setup(); + return restartIncompatibleOwner({ + ...context, + configInput: context.configInput, + controlTransport: context.transport, + reacquire: () => Effect.succeed(context.oldOwner), + }).pipe( + Effect.provide(NodeServices.layer), + Effect.scoped, + Effect.tap((result) => + Effect.sync(() => { + expect(result.effectiveConfigInput.auth).toEqual({ version: "v-old" }); + expect(result.effectiveConfigInput.servicePolicies?.auth).not.toBe("off"); + }), + ), + Effect.asVoid, + ); + }); + + it.live("pins enabled services to persisted launch versions", () => { + const context = setup(); + const configInput = { ...context.configInput, auth: { version: "v-new" } }; + const launch = context.input.launch ?? { versions: {} }; + const input = { + ...context.input, + launch: { ...launch, excludedServices: [] }, + }; + return restartIncompatibleOwner({ + ...context, + input, + configInput, + controlTransport: context.transport, + reacquire: () => Effect.succeed(context.oldOwner), + }).pipe( + Effect.provide(NodeServices.layer), + Effect.scoped, + Effect.tap((result) => + Effect.sync(() => { + expect(result.effectiveConfigInput.auth).toEqual({ version: "v-old" }); + }), + ), + Effect.asVoid, + ); + }); + + it.live("keeps restart-request exclusions from enabling native Docker-only services", () => { + const context = setup(allPersistedVersions); + const launch = context.input.launch ?? { versions: {} }; + const input = { + ...context.input, + launch: { ...launch, excludedServices: ["studio", "analytics"] }, + }; + return restartIncompatibleOwner({ + ...context, + input, + configInput: context.configInput, + controlTransport: context.transport, + reacquire: () => Effect.succeed(context.oldOwner), + }).pipe( + Effect.provide(NodeServices.layer), + Effect.scoped, + Effect.tap((result) => + Effect.sync(() => { + for (const service of SERVICE_NAMES) { + if (SERVICE_CATALOG[service].runtimeSupport === "docker-only") { + const configKey = SERVICE_CATALOG[service].configKey; + expect(result.effectiveConfigInput[configKey]).toEqual( + context.configInput[configKey], + ); + } + } + }), + ), + Effect.asVoid, + ); + }); + + it.live("reports an upgrade restart failure after the old session ends", () => { + const context = setup(); + return Effect.gen(function* () { + const exit = yield* restartIncompatibleOwner({ + ...context, + configInput: context.configInput, + controlTransport: context.transport, + reacquire: () => + Effect.fail( + new ControlBindError({ + endpoint: context.endpoint, + reason: "failed", + cause: new Error("restart endpoint unavailable"), + }), + ), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(UpgradeRestartError); + expect(error.value).toMatchObject({ + stackId: context.stackId, + newCliVersion: context.input.cliVersion, + detail: "restart endpoint unavailable", + }); + } + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }); +}); diff --git a/packages/stack/src/SupervisorUpgradeRestart.ts b/packages/stack/src/SupervisorUpgradeRestart.ts new file mode 100644 index 0000000000..b402e23693 --- /dev/null +++ b/packages/stack/src/SupervisorUpgradeRestart.ts @@ -0,0 +1,416 @@ +import { Duration, Effect, FileSystem, Path, Scope } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { validateStackRuntime, type StackRuntimeSelection } from "./ContainerRuntime.ts"; +import type { SupervisorStartMessage } from "./SupervisorProtocol.ts"; +import { + makeControlClient, + type ControlAcquisition, + type ControlAddressConflictError, + type ControlBindError, + type ControlAttached, + type ControlProtocolError, + type ControlProtocolMismatchError, + type ControlTransportError, + type ControlTransportShape, + type InvalidControlOwnershipIdError, +} from "./managed/control.ts"; +import type { ManagedStackManagerShape } from "./managed/manager.ts"; +import { managedStackPathsEffect } from "./managed/paths.ts"; +import type { ManagedStackLaunch } from "./managed/document.ts"; +import { PORT_CATALOG, type PortField } from "./PortCatalog.ts"; +import { portFieldsForConfigInput } from "./ServicePorts.ts"; +import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { expandExcludedServices } from "./ServiceExclusions.ts"; +import { + portRequestsForConfig, + rawServiceEnabled, + resolveConfig, + type DaemonConfigInput, +} from "./StackConfigResolver.ts"; +import { + StopTimeout, + SupervisorStartError, + UpgradePreflightError, + UpgradeRestartError, +} from "./errors.ts"; + +export interface UpgradeRestartContext { + readonly stackId: string; + readonly oldOwner: ControlAttached; + readonly input: SupervisorStartMessage; + readonly configInput: DaemonConfigInput; + readonly manager: ManagedStackManagerShape; + readonly controlTransport: ControlTransportShape; + readonly resolutionTimeout?: Duration.Input; + /** Reclaims the deterministic endpoint after the captured owner disappears. */ + readonly reacquire: () => Effect.Effect< + ControlAcquisition, + | InvalidControlOwnershipIdError + | ControlBindError + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError, + Scope.Scope + >; +} + +export interface UpgradeRestartResult { + readonly acquisition: ControlAcquisition; + readonly effectiveConfigInput: DaemonConfigInput; + readonly oldSessionEnded: true; + readonly attachedOwnerWasStopping: boolean; +} + +export const UPGRADE_RESTART_PHASE_TIMEOUT = Duration.seconds(30); + +const runtimeSelectionForLaunch = (launch: ManagedStackLaunch): StackRuntimeSelection => + launch.mode === "native" + ? { mode: "native", containerRuntime: null } + : { mode: "docker", containerRuntime: launch.containerRuntime }; + +const persistedPortField = (key: string): PortField | undefined => { + switch (key) { + case "api.port": + return "apiPort"; + case "db.port": + return "dbPort"; + case "edge_runtime.inspector_port": + return "edgeRuntimeInspectorPort"; + case "local_smtp.port": + return "mailpitPort"; + case "local_smtp.smtp_port": + return "mailpitSmtpPort"; + case "local_smtp.pop3_port": + return "mailpitPop3Port"; + case "studio.port": + return "studioPort"; + case "analytics.port": + return "analyticsPort"; + case "db.pooler.port": + return "poolerPort"; + default: + return undefined; + } +}; + +const isCatalogDefaultServiceConfig = (value: unknown): boolean => { + if (value === undefined) return true; + if (typeof value !== "object" || value === null) return false; + return Object.keys(value).every((key) => key === "version"); +}; + +export const applyNativeDefaults = (config: DaemonConfigInput): DaemonConfigInput => { + const servicePolicies = { ...config.servicePolicies }; + for (const service of SERVICE_NAMES) { + const metadata = SERVICE_CATALOG[service]; + if ( + metadata.runtimeSupport === "docker-only" && + servicePolicies[service] === undefined && + isCatalogDefaultServiceConfig(config[metadata.configKey]) + ) { + servicePolicies[service] = "off"; + } + } + return { ...config, servicePolicies }; +}; + +const enableService = ( + config: DaemonConfigInput, + service: (typeof SERVICE_NAMES)[number], + version: string | undefined, +): DaemonConfigInput => { + const versionField = version === undefined ? {} : { version }; + switch (service) { + case "postgres": + return { ...config, postgres: { ...config.postgres, ...versionField } }; + case "postgrest": + return { + ...config, + postgrest: { ...(config.postgrest === false ? {} : config.postgrest), ...versionField }, + }; + case "auth": + return { + ...config, + auth: { ...(config.auth === false ? {} : config.auth), ...versionField }, + }; + case "edge-runtime": + return { + ...config, + edgeRuntime: { + ...(config.edgeRuntime === false ? {} : config.edgeRuntime), + ...versionField, + }, + }; + case "realtime": + return { + ...config, + realtime: { ...(config.realtime === false ? {} : config.realtime), ...versionField }, + }; + case "storage": + return { + ...config, + storage: { ...(config.storage === false ? {} : config.storage), ...versionField }, + }; + case "imgproxy": + return { + ...config, + imgproxy: { ...(config.imgproxy === false ? {} : config.imgproxy), ...versionField }, + }; + case "mailpit": + return { + ...config, + mailpit: { ...(config.mailpit === false ? {} : config.mailpit), ...versionField }, + }; + case "pgmeta": + return { + ...config, + pgmeta: { ...(config.pgmeta === false ? {} : config.pgmeta), ...versionField }, + }; + case "studio": + return { + ...config, + studio: { ...(config.studio === false ? {} : config.studio), ...versionField }, + }; + case "analytics": + return { + ...config, + analytics: { ...(config.analytics === false ? {} : config.analytics), ...versionField }, + }; + case "vector": + return { + ...config, + vector: { ...(config.vector === false ? {} : config.vector), ...versionField }, + }; + case "pooler": + return { + ...config, + pooler: { ...(config.pooler === false ? {} : config.pooler), ...versionField }, + }; + } +}; + +/** Persisted exclusions are authoritative; restored services keep their pinned version. */ +const applyPersistedLaunch = ( + config: DaemonConfigInput, + persisted: ManagedStackLaunch, + requested: SupervisorStartMessage["launch"], +): DaemonConfigInput => { + let effective = config; + const restartRequestExclusions = expandExcludedServices(requested?.excludedServices ?? []); + const persistedExclusions = expandExcludedServices(persisted.excludedServices ?? []); + const servicesToEnable = new Set<(typeof SERVICE_NAMES)[number]>(); + for (const service of restartRequestExclusions) { + if (persisted.mode !== "native" || SERVICE_CATALOG[service].runtimeSupport !== "docker-only") { + servicesToEnable.add(service); + } + } + for (const service of SERVICE_NAMES) { + if ( + !persistedExclusions.has(service) && + persisted.versions[service] !== undefined && + rawServiceEnabled(config, service) + ) { + servicesToEnable.add(service); + } + } + for (const service of servicesToEnable) { + if (!persistedExclusions.has(service)) { + effective = enableService(effective, service, persisted.versions[service]); + } + } + const servicePolicies = { ...effective.servicePolicies }; + for (const service of servicesToEnable) { + if (!persistedExclusions.has(service) && servicePolicies[service] === "off") { + delete servicePolicies[service]; + } + } + for (const excluded of persistedExclusions) { + servicePolicies[excluded] = "off"; + } + return { ...effective, servicePolicies }; +}; + +const preflightError = (context: UpgradeRestartContext, detail: string): UpgradePreflightError => + new UpgradePreflightError({ + stackId: context.stackId, + oldCliVersion: context.oldOwner.observedStatus.daemonCliVersion, + newCliVersion: context.input.cliVersion, + detail, + }); + +const causeMessage = (cause: unknown): string => { + if ( + typeof cause === "object" && + cause !== null && + "detail" in cause && + typeof cause.detail === "string" + ) { + return cause.detail; + } + if ( + typeof cause === "object" && + cause !== null && + "cause" in cause && + cause.cause !== undefined && + cause.cause !== cause + ) { + return causeMessage(cause.cause); + } + if (cause instanceof Error && cause.message.length > 0) return cause.message; + return typeof cause === "string" ? cause : String(cause); +}; + +const preflight = ( + context: UpgradeRestartContext, +): Effect.Effect< + DaemonConfigInput, + UpgradePreflightError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const existing = yield* context.manager + .inspectStack(context.stackId) + .pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); + if (existing === undefined) + return yield* Effect.fail(preflightError(context, "Managed stack document is missing")); + + const persistedRuntime = runtimeSelectionForLaunch(existing.launch); + yield* validateStackRuntime(persistedRuntime).pipe( + Effect.mapError((cause) => preflightError(context, causeMessage(cause))), + ); + + const withExclusions = applyPersistedLaunch( + context.configInput, + existing.launch, + context.input.launch, + ); + const effectiveConfigInput = + persistedRuntime.mode === "native" && context.configInput.mode === undefined + ? applyNativeDefaults(withExclusions) + : withExclusions; + yield* portRequestsForConfig(effectiveConfigInput, { runtime: persistedRuntime }).pipe( + Effect.mapError((cause) => preflightError(context, causeMessage(cause))), + ); + + const paths = yield* managedStackPathsEffect(context.input.stateRoot, existing.id).pipe( + Effect.mapError((cause) => preflightError(context, causeMessage(cause))), + ); + const syntheticPorts: Partial> = {}; + for (const assignment of existing.ports) { + const field = persistedPortField(assignment.key); + if (field !== undefined) syntheticPorts[field] = assignment.port; + } + const activeFields = portFieldsForConfigInput({ + ...effectiveConfigInput, + mode: persistedRuntime.mode, + }); + for (const [index, field] of activeFields.entries()) { + if (syntheticPorts[field] === undefined) + syntheticPorts[field] = PORT_CATALOG[field].preferred ?? 60_000 + index; + } + yield* resolveConfig( + { + ...effectiveConfigInput, + projectDir: effectiveConfigInput.projectDir ?? context.input.workspacePath, + mode: persistedRuntime.mode, + }, + { + runtime: persistedRuntime, + stackRoot: paths.root, + runtimeRoot: paths.runtime, + ports: syntheticPorts, + }, + ).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); + return effectiveConfigInput; + }); + +/** + * Performs the complete incompatible-owner upgrade restart transaction. The outer supervisor + * retains only IPC/listener ownership and startup composition concerns. + */ +export const restartIncompatibleOwner = ( + context: UpgradeRestartContext, +): Effect.Effect< + UpgradeRestartResult, + | UpgradePreflightError + | SupervisorStartError + | StopTimeout + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | InvalidControlOwnershipIdError + | ControlBindError + | UpgradeRestartError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const phaseTimeout = context.resolutionTimeout ?? UPGRADE_RESTART_PHASE_TIMEOUT; + const effectiveConfigInput = yield* preflight(context).pipe( + Effect.timeout(phaseTimeout), + Effect.catchTag("TimeoutError", () => + Effect.fail(preflightError(context, "Timed out preflighting upgrade restart")), + ), + ); + const attachedOwnerWasStopping = context.oldOwner.observedStatus.state === "stopping"; + const client = makeControlClient(context.controlTransport); + yield* client + .stopSession( + context.oldOwner.endpoint, + context.oldOwner.observedStatus.ownershipId, + context.oldOwner.observedStatus.ownerSessionId, + ) + .pipe( + Effect.timeoutOrElse({ + duration: phaseTimeout, + orElse: () => + client + .readOwner(context.oldOwner.endpoint, context.oldOwner.observedStatus.ownershipId) + .pipe( + Effect.flatMap((status) => + Effect.fail( + new StopTimeout({ + endpoint: context.oldOwner.endpoint.url, + ownerSessionId: context.oldOwner.observedStatus.ownerSessionId, + lastState: + status.ownerSessionId === context.oldOwner.observedStatus.ownerSessionId + ? status.state + : context.oldOwner.observedStatus.state, + }), + ), + ), + Effect.catch(() => + Effect.fail( + new StopTimeout({ + endpoint: context.oldOwner.endpoint.url, + ownerSessionId: context.oldOwner.observedStatus.ownerSessionId, + lastState: context.oldOwner.observedStatus.state, + }), + ), + ), + ), + }), + ); + const acquisition = yield* context.reacquire().pipe( + Effect.timeout(phaseTimeout), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new SupervisorStartError({ + message: "Timed out waiting for upgrade restart", + }), + ), + ), + Effect.mapError( + (error) => + new UpgradeRestartError({ + stackId: context.stackId, + newCliVersion: context.input.cliVersion, + detail: causeMessage(error), + }), + ), + ); + return { acquisition, effectiveConfigInput, oldSessionEnded: true, attachedOwnerWasStopping }; + }); + +export { runtimeSelectionForLaunch }; diff --git a/packages/stack/src/compiled-supervisor.integration.test.ts b/packages/stack/src/compiled-supervisor.integration.test.ts new file mode 100644 index 0000000000..cc37ca7deb --- /dev/null +++ b/packages/stack/src/compiled-supervisor.integration.test.ts @@ -0,0 +1,421 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fork, type ChildProcess } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Effect, Schedule, Schema } from "effect"; +import { describe, expect, test, beforeAll, afterAll } from "vitest"; +import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; +import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; +import { managedStackDocumentPathEffect } from "./managed/paths.ts"; +import { + CompiledSupervisorParentEventSchema, + type CompiledSupervisorStartMessage, +} from "../tests/helpers/compiled-supervisor-parent.ts"; +import { SupervisorStartCommandSchema } from "./SupervisorProtocol.ts"; + +const execFileAsync = promisify(execFile); +const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; +const parentEntryPoint = fileURLToPath( + new URL("../tests/helpers/compiled-supervisor-parent.ts", import.meta.url), +); + +interface TestRoots { + readonly root: string; + readonly stateRoot: string; + readonly stackId: string; +} + +interface CompiledParent { + readonly child: ChildProcess; + readonly ready: Promise; + readonly exited: Promise; +} + +let artifactRoot: string; +let compiledParentPath: string; + +const makeWorkspace = (): TestRoots => { + const root = mkdtempSync(join(tmpdir(), "sup-stack-compiled-workspace-")); + const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-compiled-state-")); + const identity: EnvironmentIdentity = { + workspaceId: crypto.randomUUID(), + checkoutId: crypto.randomUUID(), + contextId: crypto.randomUUID(), + localProjectKey: ".", + }; + mkdirSync(join(root, ".supabase"), { recursive: true }); + writeFileSync( + join(root, ".supabase", "identity.json"), + `${JSON.stringify({ version: 1, ...identity }, null, 2)}\n`, + ); + return { root, stateRoot, stackId: deriveStackId(identity, "default") }; +}; + +const messageFor = ( + roots: TestRoots, + overrides: Partial = {}, +): CompiledSupervisorStartMessage => ({ + type: "start", + cliVersion: "2.61.0", + stackId: roots.stackId, + workspacePath: roots.root, + stackName: "default", + stateRoot: roots.stateRoot, + config: { + cwd: roots.root, + projectDir: roots.root, + mode: "native", + auth: false, + postgrest: false, + realtime: false, + storage: false, + imgproxy: false, + localSmtp: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + portIntents: { activeFields: ["apiPort", "dbPort"], document: {} }, + launch: { + mode: "native", + versions: { postgres: "pinned-postgres" }, + excludedServices: ["analytics"], + }, + ...overrides, +}); + +const waitForExit = (child: ChildProcess): Promise => + new Promise((resolve) => { + if (child.exitCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }); + +const spawnCompiledParent = ( + input: CompiledSupervisorStartMessage, + environment: Readonly> = {}, +): CompiledParent => { + const child = fork(parentEntryPoint, [], { + execPath: compiledParentPath, + detached: false, + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: { + ...process.env, + SUPABASE_STACK_TEST_PLATFORM: "bun", + ...environment, + }, + }); + let stderr = ""; + child.stderr?.on("data", (chunk: Uint8Array) => { + stderr += new TextDecoder().decode(chunk); + }); + const ready = new Promise((resolve, reject) => { + const onMessage = (raw: unknown) => { + let event: Schema.Schema.Type; + try { + event = Schema.decodeUnknownSync(CompiledSupervisorParentEventSchema)(raw); + } catch { + return; + } + if (event.type === "ready") { + cleanup(); + resolve(); + } else { + cleanup(); + reject(new Error(`${event.message}\n${stderr}`)); + } + }; + const onError = (cause: Error) => { + cleanup(); + reject(cause); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject( + new Error( + `compiled supervisor parent exited (${String(code)}, ${String(signal)})\n${stderr}`, + ), + ); + }; + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }); + const encoded = Schema.encodeSync(SupervisorStartCommandSchema)(input); + child.send(encoded); + return { child, ready, exited: waitForExit(child) }; +}; + +const owner = async (endpoint: ControlEndpoint) => { + const response = await fetch(`${endpoint.url}/owner`); + expect(response.status).toBe(200); + return (await response.json()) as { + readonly ownershipId: string; + readonly ownerSessionId: string; + readonly daemonCliVersion: string; + readonly state: string; + readonly ready: boolean; + }; +}; + +const stop = async ( + endpoint: ControlEndpoint, + ownershipId: string, + ownerSessionId: string, +): Promise => + fetch(`${endpoint.url}/stop`, { + method: "POST", + headers: { "content-type": "application/json", connection: "close" }, + body: JSON.stringify({ ownershipId, ownerSessionId }), + }); + +const waitForProcessExit = (pid: number): Promise => { + const attempt = Effect.try({ + try: () => { + process.kill(pid, 0); + return false; + }, + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(true))); + const probe = attempt.pipe( + Effect.flatMap((exited) => (exited ? Effect.succeed(true) : Effect.fail(new Error("alive")))), + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), + Effect.asVoid, + ); + return Effect.runPromise(probe); +}; + +const documentFor = (roots: TestRoots) => { + const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, roots.stackId)); + return { path, value: JSON.parse(readFileSync(path, "utf8")) as Record }; +}; + +const waitForDocumentLifecycle = (roots: TestRoots, lifecycle: string): Promise => { + const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, roots.stackId)); + const probe = Effect.try({ + try: () => { + const value = JSON.parse(readFileSync(path, "utf8")) as { readonly lifecycle?: string }; + if (value.lifecycle !== lifecycle) throw new Error("document lifecycle has not settled"); + }, + catch: (cause) => cause, + }).pipe( + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), + Effect.asVoid, + ); + return Effect.runPromise(probe); +}; + +class EndpointStillAliveError extends Error {} + +const waitForEndpointUnavailable = (endpoint: ControlEndpoint): Promise => { + const attempt = Effect.tryPromise({ + try: async () => { + const response = await fetch(`${endpoint.url}/owner`); + if (response.ok) throw new EndpointStillAliveError(); + }, + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + cause instanceof EndpointStillAliveError ? Effect.fail(cause) : Effect.succeed(undefined), + ), + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), + Effect.asVoid, + ); + return Effect.runPromise(attempt); +}; + +const endpointFor = (roots: TestRoots): Promise => + Effect.runPromise(controlEndpoint(roots.stackId)); + +const cleanup = (roots: TestRoots): void => { + rmSync(roots.root, { recursive: true, force: true }); + rmSync(roots.stateRoot, { recursive: true, force: true }); +}; + +const killPid = (pid: number): void => { + try { + process.kill(pid, "SIGKILL"); + } catch {} +}; + +describe("compiled Bun detached supervisor", () => { + beforeAll(async () => { + artifactRoot = mkdtempSync(join(tmpdir(), "sup-stack-compiled-artifact-")); + compiledParentPath = join(artifactRoot, "compiled-supervisor-parent"); + await execFileAsync(bunExecutable, [ + "build", + parentEntryPoint, + "--compile", + `--outfile=${compiledParentPath}`, + ]); + }, 120_000); + + afterAll(() => { + rmSync(artifactRoot, { recursive: true, force: true }); + }); + + test("starts, attaches, session-stops, and upgrade-restarts through compiled child re-entry", async () => { + const roots = makeWorkspace(); + let first: CompiledParent | undefined; + let attached: CompiledParent | undefined; + let upgradeRestart: CompiledParent | undefined; + const runtimePids = new Set(); + try { + const endpoint = await endpointFor(roots); + first = spawnCompiledParent(messageFor(roots)); + await first.ready; + const firstOwner = await owner(endpoint); + expect(firstOwner).toMatchObject({ + ownershipId: roots.stackId, + daemonCliVersion: "2.61.0", + state: "running", + ready: true, + }); + const before = documentFor(roots).value; + const runtime = before["runtime"] as { readonly pid: number }; + runtimePids.add(runtime.pid); + const pathsRoot = dirname(documentFor(roots).path); + const sentinel = join(pathsRoot, "data", "compiled-preservation.txt"); + mkdirSync(dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, "compiled-preserve"); + + attached = spawnCompiledParent(messageFor(roots)); + await attached.ready; + expect(await owner(endpoint)).toMatchObject({ + ownerSessionId: firstOwner.ownerSessionId, + daemonCliVersion: firstOwner.daemonCliVersion, + state: "running", + }); + await attached.exited; + + const stopped = await stop(endpoint, firstOwner.ownershipId, firstOwner.ownerSessionId); + expect(stopped.status).toBe(202); + await waitForProcessExit(runtime.pid); + await first.exited; + await waitForDocumentLifecycle(roots, "stopped"); + await waitForEndpointUnavailable(endpoint); + expect(documentFor(roots).value["lifecycle"] as string).toBe("stopped"); + + first = spawnCompiledParent( + messageFor(roots, { + cliVersion: "2.60.0", + launch: { + mode: "native", + versions: { postgres: "old-pinned" }, + excludedServices: ["analytics"], + }, + }), + ); + await first.ready; + const oldOwner = await owner(endpoint); + const oldDocument = documentFor(roots).value; + const oldRuntime = oldDocument["runtime"] as { readonly pid: number }; + runtimePids.add(oldRuntime.pid); + const oldLaunch = oldDocument["launch"]; + const oldPorts = oldDocument["ports"]; + writeFileSync(sentinel, "upgrade-restart-preserve"); + + upgradeRestart = spawnCompiledParent( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "2.61.0", + launch: { + mode: "native", + versions: { postgres: "new-default" }, + excludedServices: [], + }, + }), + ); + await upgradeRestart.ready; + await waitForProcessExit(oldRuntime.pid); + await first.exited; + const currentOwner = await owner(endpoint); + expect(currentOwner).toMatchObject({ + daemonCliVersion: "2.61.0", + state: "running", + ready: true, + }); + expect(currentOwner.ownerSessionId).not.toBe(oldOwner.ownerSessionId); + const staleStop = await stop(endpoint, oldOwner.ownershipId, oldOwner.ownerSessionId); + expect(staleStop.status).toBe(409); + const after = documentFor(roots).value; + expect(after["id"]).toBe(oldDocument["id"]); + expect(after["createdAt"]).toBe(oldDocument["createdAt"]); + expect(after["launch"]).toEqual(oldLaunch); + expect(after["ports"]).toEqual(oldPorts); + expect(readFileSync(sentinel, "utf8")).toBe("upgrade-restart-preserve"); + + const restartedRuntime = after["runtime"] as { readonly pid: number }; + runtimePids.add(restartedRuntime.pid); + expect( + await stop(endpoint, currentOwner.ownershipId, currentOwner.ownerSessionId), + ).toMatchObject({ + status: 202, + }); + await waitForProcessExit(restartedRuntime.pid); + await upgradeRestart.exited; + } finally { + for (const pid of runtimePids) killPid(pid); + for (const handle of [first, attached, upgradeRestart]) { + if (handle?.child.exitCode === null) handle.child.kill("SIGKILL"); + } + cleanup(roots); + } + }, 120_000); + + test("recovers a stale current-build owner through compiled re-entry", async () => { + const roots = makeWorkspace(); + let ownerParent: CompiledParent | undefined; + let recovery: CompiledParent | undefined; + const runtimePids = new Set(); + try { + const endpoint = await endpointFor(roots); + ownerParent = spawnCompiledParent(messageFor(roots)); + await ownerParent.ready; + const stale = await owner(endpoint); + const document = documentFor(roots).value; + const staleRuntime = document["runtime"] as { readonly pid: number }; + runtimePids.add(staleRuntime.pid); + process.kill(staleRuntime.pid, "SIGKILL"); + await waitForProcessExit(staleRuntime.pid); + await ownerParent.exited; + + recovery = spawnCompiledParent(messageFor(roots)); + await recovery.ready; + const current = await owner(endpoint); + expect(current).toMatchObject({ + ownershipId: roots.stackId, + daemonCliVersion: "2.61.0", + state: "running", + ready: true, + }); + expect(current.ownerSessionId).not.toBe(stale.ownerSessionId); + const currentDocument = documentFor(roots).value; + const currentRuntime = currentDocument["runtime"] as { readonly pid: number }; + runtimePids.add(currentRuntime.pid); + expect(await stop(endpoint, current.ownershipId, current.ownerSessionId)).toMatchObject({ + status: 202, + }); + await waitForProcessExit(currentRuntime.pid); + await recovery.exited; + } finally { + for (const pid of runtimePids) killPid(pid); + for (const handle of [ownerParent, recovery]) { + if (handle?.child.exitCode === null) handle.child.kill("SIGKILL"); + } + cleanup(roots); + } + }, 120_000); +}); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 794428b771..eaaa07d4f7 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -68,9 +68,9 @@ export interface ForegroundStackHandle { serviceReady(name: string, opts?: ReadyOptions): Effect.Effect; getStatus(): Effect.Effect, StackError>; getServiceStatus(name: string): Effect.Effect; - statusChanges(): Stream.Stream; - logs(): Stream.Stream; - serviceLogs(name: string): Stream.Stream; + statusChanges(): Stream.Stream; + logs(): Stream.Stream; + serviceLogs(name: string): Stream.Stream; logHistory(name: string, limit?: number): Effect.Effect, StackError>; } @@ -234,9 +234,10 @@ const createStackAttempt = ( run(localStack.waitReady(name, opts)), getStatus: () => run(localStack.getAllStates()), getServiceStatus: (name: string) => run(localStack.getState(name)), - statusChanges: () => localStack.allStateChanges(), - logs: () => localStack.subscribeAllLogs(), - serviceLogs: (name: string) => localStack.subscribeLogs(name), + statusChanges: () => localStack.allStateChanges().pipe(Stream.mapError(toStackError)), + logs: () => localStack.subscribeAllLogs().pipe(Stream.mapError(toStackError)), + serviceLogs: (name: string) => + localStack.subscribeLogs(name).pipe(Stream.mapError(toStackError)), logHistory: (name: string, limit?: number) => run(localStack.logHistory(name, limit)), } satisfies ForegroundStackHandle; }); diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts index 41645b4136..0fe1b13846 100644 --- a/packages/stack/src/discovery.ts +++ b/packages/stack/src/discovery.ts @@ -14,6 +14,13 @@ import { NoRunningStackError } from "./managed/model.ts"; import type { ManagedPortDrift, ManagedPortIntentDocument } from "./managed/model.ts"; import { managedStackDocumentPathEffect } from "./managed/paths.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; +import type { ControlTransport } from "./managed/control.ts"; +import { + DaemonUpgradeRequired, + StackRpcProtocolError, + StackRpcTransportError, + StopTimeout, +} from "./errors.ts"; import type { Stack } from "./Stack.ts"; export interface StackSummary { @@ -147,8 +154,8 @@ export const stopDaemon = (opts: { readonly projectDir?: string; }): Effect.Effect< void, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager | HttpTransportClient + NoRunningStackError | ManagedStackManagerError | StopTimeout, + ManagedStackManager | HttpTransportClient | ControlTransport > => stopManagedStack({ workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), @@ -161,7 +168,11 @@ export const deleteManagedStackPersistence = (opts: { readonly cwd?: string; readonly cacheRoot: string; readonly projectDir?: string; -}): Effect.Effect => +}): Effect.Effect< + void, + NoRunningStackError | ManagedStackManagerError, + ManagedStackManager | ControlTransport +> => deleteManagedStack({ workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), ...(opts.name === undefined ? {} : { stackName: opts.name }), @@ -191,13 +202,18 @@ export const connectManagedLayer = (opts: { readonly cwd?: string; readonly cacheRoot: string; readonly projectDir?: string; + readonly cliVersion: string; }): Effect.Effect< - import("effect").Layer.Layer, - NoRunningStackError | ManagedStackManagerError, + import("effect").Layer.Layer< + Stack, + DaemonUpgradeRequired | StackRpcProtocolError | StackRpcTransportError + >, + NoRunningStackError | ManagedStackManagerError | DaemonUpgradeRequired, ManagedStackManager | HttpTransportClient > => connectManagedStack({ workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), ...(opts.name === undefined ? {} : { stackName: opts.name }), cwd: opts.cwd, + cliVersion: opts.cliVersion, }); diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts index 409b895276..5fa3e7af7a 100644 --- a/packages/stack/src/effect-bun.ts +++ b/packages/stack/src/effect-bun.ts @@ -10,6 +10,7 @@ import type { ResolvedStackConfig } from "./StackConfig.ts"; import type { ManagedDaemonConfigInput } from "./layers.ts"; import { daemonLayer as daemonLayerForPlatform, + restartManagedStackForUpgrade as restartManagedStackForUpgradeForPlatform, foregroundLayer as foregroundLayerForPlatform, } from "./layers.ts"; import { daemonEntryPoint, platformFactory } from "./platform-bun.ts"; @@ -38,6 +39,9 @@ export const foregroundLayer = ( export const daemonLayer = (input: ManagedDaemonConfigInput) => daemonLayerForPlatform(input, daemonEntryPoint); +export const restartManagedStackForUpgrade = (input: ManagedDaemonConfigInput) => + restartManagedStackForUpgradeForPlatform(input, daemonEntryPoint); + const managedLayer = (cacheRoot: string) => managedStackManagerLayer({ stateRoot: join(cacheRoot, "managed") }); @@ -66,6 +70,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; + readonly cliVersion: string; readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts index 1959dbce15..21415a1767 100644 --- a/packages/stack/src/effect-node.ts +++ b/packages/stack/src/effect-node.ts @@ -10,6 +10,7 @@ import type { ResolvedStackConfig } from "./StackConfig.ts"; import type { ManagedDaemonConfigInput } from "./layers.ts"; import { daemonLayer as daemonLayerForPlatform, + restartManagedStackForUpgrade as restartManagedStackForUpgradeForPlatform, foregroundLayer as foregroundLayerForPlatform, } from "./layers.ts"; import { daemonEntryPoint, platformFactory } from "./platform-node.ts"; @@ -38,6 +39,9 @@ export const foregroundLayer = ( export const daemonLayer = (input: ManagedDaemonConfigInput) => daemonLayerForPlatform(input, daemonEntryPoint); +export const restartManagedStackForUpgrade = (input: ManagedDaemonConfigInput) => + restartManagedStackForUpgradeForPlatform(input, daemonEntryPoint); + const managedLayer = (cacheRoot: string) => managedStackManagerLayer({ stateRoot: join(cacheRoot, "managed") }); @@ -66,6 +70,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; + readonly cliVersion: string; readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index f77e4fcfee..4f5590f2a9 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -10,6 +10,7 @@ export { BinaryNotFoundError, BinaryRuntimeError, ChecksumMismatchError, + DaemonUpgradeRequired, DockerPullError, DownloadError, isDockerDaemonDownMessage, @@ -18,12 +19,18 @@ export { StackError, StackNotRunningError, StackReadinessError, + StackUnavailableError, + StopTimeout, + UpgradePreflightError, + UpgradeRestartError, toStackError, } from "./errors.ts"; export type { NativeTarget, PlatformInfo } from "./Platform.ts"; export { detectPlatform, nativeTargetForPlatform } from "./Platform.ts"; +export { expandExcludedServices } from "./ServiceExclusions.ts"; + export type { ContainerRuntime, StackRuntimeSelection } from "./ContainerRuntime.ts"; export { selectStackRuntime, validateStackRuntime } from "./ContainerRuntime.ts"; diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 49018edc13..4d3d5b9c4a 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -1,4 +1,5 @@ import { Data, Predicate } from "effect"; +import type { ControlOwnerState } from "./DaemonProtocol.ts"; export class BinaryNotFoundError extends Data.TaggedError("BinaryNotFoundError")<{ readonly service: string; @@ -80,6 +81,27 @@ export class StackBuildError extends Data.TaggedError("StackBuildError")<{ readonly reason?: "invalid_config" | "docker_not_running" | "asset_preparation"; }> {} +/** Runtime RPC is unavailable until the supervisor publishes a running stack. */ +export class StackUnavailableError extends Data.TaggedError("StackUnavailableError")<{ + readonly phase: "starting" | "stopping" | "failed" | "deleting"; + readonly detail?: string; +}> {} + +/** A remote RPC request could not reach the owner endpoint. */ +export class StackRpcTransportError extends Data.TaggedError("StackRpcTransportError")<{ + readonly endpoint: string; + readonly procedure: string; + readonly cause: unknown; +}> {} + +/** A same-version RPC response violated the framed/schema protocol. */ +export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolError")<{ + readonly endpoint: string; + readonly procedure: string; + readonly detail: string; + readonly cause?: unknown; +}> {} + export class StackNotRunningError extends Data.TaggedError("StackNotRunningError")<{ readonly phase: string; }> {} @@ -90,6 +112,39 @@ export class StackReadinessError extends Data.TaggedError("StackReadinessError") readonly detail: string; }> {} +/** The owner is healthy but belongs to another immutable CLI version. */ +export class DaemonUpgradeRequired extends Data.TaggedError("DaemonUpgradeRequired")<{ + readonly stackId: string; + readonly oldCliVersion: string; + readonly newCliVersion: string; + readonly state: ControlOwnerState; + readonly ready: boolean; +}> {} + +export class SupervisorStartError extends Data.TaggedError("SupervisorStartError")<{ + readonly message: string; + readonly reason?: "owner-stopped" | "build-mismatch"; +}> {} + +export class UpgradePreflightError extends Data.TaggedError("UpgradePreflightError")<{ + readonly stackId: string; + readonly oldCliVersion: string; + readonly newCliVersion: string; + readonly detail: string; +}> {} + +export class UpgradeRestartError extends Data.TaggedError("UpgradeRestartError")<{ + readonly stackId: string; + readonly newCliVersion: string; + readonly detail: string; +}> {} + +export class StopTimeout extends Data.TaggedError("StopTimeout")<{ + readonly endpoint: string; + readonly ownerSessionId: string; + readonly lastState?: string; +}> {} + export class PortConflictError extends Data.TaggedError("PortConflictError")<{ readonly port: number; readonly service: string; @@ -109,6 +164,13 @@ const taggedStackErrorCodes = [ ["StackBuildError", "BUILD_ERROR"], ["StackNotRunningError", "STACK_NOT_RUNNING"], ["StackReadinessError", "STACK_READINESS_TIMEOUT"], + ["StackUnavailableError", "STACK_UNAVAILABLE"], + ["StackRpcTransportError", "STACK_RPC_TRANSPORT"], + ["StackRpcProtocolError", "STACK_RPC_PROTOCOL"], + ["DaemonUpgradeRequired", "DAEMON_UPGRADE_REQUIRED"], + ["UpgradePreflightError", "UPGRADE_PREFLIGHT"], + ["UpgradeRestartError", "UPGRADE_RESTART"], + ["StopTimeout", "STOP_TIMEOUT"], ["BinaryNotFoundError", "BINARY_NOT_FOUND"], ["ChecksumMismatchError", "CHECKSUM_MISMATCH"], ["BinaryManifestError", "BINARY_MANIFEST"], diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index 46204768de..deee8c28bb 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -19,6 +19,14 @@ import type { ManagedStackLaunchInput } from "./managed/document.ts"; import type { ManagedPortIntentDocument } from "./managed/model.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; +import { + DaemonUpgradeRequired, + StackRpcProtocolError, + StackRpcTransportError, + StopTimeout, + UpgradePreflightError, + UpgradeRestartError, +} from "./errors.ts"; /** * Inputs owned by the process that will boot the runtime. The lease is passed @@ -106,6 +114,7 @@ export class DaemonStartError extends Data.TaggedError("DaemonStartError")<{ /** Managed-only additions kept outside the generic daemon config resolver. */ export type ManagedDaemonConfigInput = DaemonConfigInput & { + readonly cliVersion: string; readonly portIntents: ManagedPortIntentDocument; readonly launch?: ManagedStackLaunchInput; }; @@ -114,13 +123,17 @@ export type ManagedDaemonConfigInput = DaemonConfigInput & { // Daemon-backed mode // --------------------------------------------------------------------------- -/** Fork the unified supervisor and return a RemoteStack layer connected to it. */ -export const daemonLayer = ( +const managedSupervisorLayer = ( input: ManagedDaemonConfigInput, daemonEntryPoint: string, + type: SupervisorStartMessage["type"], ): Effect.Effect< - Layer.Layer, - DaemonStartError, + Layer.Layer, + | DaemonStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout, FileSystem.FileSystem | Path.Path | HttpTransportClient > => Effect.gen(function* () { @@ -145,10 +158,15 @@ export const daemonLayer = ( const httpTransportClient = yield* HttpTransportClient; const discovery = yield* ensureEnvironment(projectDir).pipe( Effect.provide(gitConfigStoreLayer), - Effect.mapError((error) => new DaemonStartError({ message: error.message })), + Effect.mapError((error) => + error instanceof DaemonUpgradeRequired + ? error + : new DaemonStartError({ message: error.message }), + ), ); const startMsg: SupervisorStartMessage = { - type: "start", + type, + cliVersion: input.cliVersion, stackId: deriveStackId(discovery.identity, name), workspacePath: projectDir, stackName: name, @@ -159,6 +177,31 @@ export const daemonLayer = ( }; return yield* supervisorLayer(startMsg, daemonEntryPoint).pipe( Effect.provideService(HttpTransportClient, httpTransportClient), - Effect.mapError((error) => new DaemonStartError({ message: error.message })), + Effect.mapError((error) => + error instanceof DaemonUpgradeRequired || + error instanceof UpgradePreflightError || + error instanceof UpgradeRestartError || + error instanceof StopTimeout + ? error + : new DaemonStartError({ message: error.message }), + ), ); }); + +/** Fork the unified supervisor and return a RemoteStack layer connected to it. */ +export const daemonLayer = (input: ManagedDaemonConfigInput, daemonEntryPoint: string) => + managedSupervisorLayer(input, daemonEntryPoint, "start"); + +/** Explicitly authorize a full stop/start when the current owner is incompatible. */ +export const restartManagedStackForUpgrade = ( + input: ManagedDaemonConfigInput, + daemonEntryPoint: string, +): Effect.Effect< + Layer.Layer, + | DaemonStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout, + FileSystem.FileSystem | Path.Path | HttpTransportClient +> => managedSupervisorLayer(input, daemonEntryPoint, "upgrade-restart"); diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index ee28046791..e98108ad45 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -10,6 +10,7 @@ import { gitConfigStoreLayer } from "./managed/git.ts"; import { controlTransportLayer } from "./platform-bun.ts"; export * from "./managed.ts"; +export { controlTransportLayer }; export { managedDaemonEntryPoint }; export type { ManagedDaemonStartInput } from "./supervisor.ts"; diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts index a23cf38e7c..d3fc00514c 100644 --- a/packages/stack/src/managed-control.integration.test.ts +++ b/packages/stack/src/managed-control.integration.test.ts @@ -1,26 +1,30 @@ import { it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, ManagedRuntime, Predicate, Result, Stream } from "effect"; -import { HttpServer } from "effect/unstable/http"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Predicate, Result, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { spawn } from "node:child_process"; import { createServer, type Server } from "node:http"; +import { createServer as createTcpServer, type Server as TcpServer, type Socket } from "node:net"; import { describe, expect } from "vitest"; -import { DaemonServer } from "./DaemonServer.ts"; import { acquireControl, CONTROL_CANDIDATE_COUNT, controlEndpoint, controlEndpointCandidates, ControlBindError, + type ControlOwnerStatus, ControlTransport, + type ControlTransportShape, ControlTransportError, isControlAttached, isControlOwnership, probeControl, + readControlOwnerStatus, + requestControlStopForSession, } from "./managed/control.ts"; import { controlTransportLayer } from "./platform-node.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { RemoteStack } from "./RemoteStack.ts"; import { Stack } from "./Stack.ts"; +import { SupervisorControlServer } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; const STACK_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const COLLIDING_STACK_ID = `${STACK_ID.slice(0, 10)}${"f".repeat(54)}`; @@ -59,6 +63,37 @@ const makeStack = (started: { value: boolean }): Stack["Service"] => ({ logHistoryAll: () => Effect.succeed([]), }); +const makeStaticOwner = (stackId: string, stack: Stack["Service"]) => + Effect.gen(function* () { + const ownerSessionId = crypto.randomUUID(); + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId, + daemonCliVersion: "test", + close: Effect.void, + }); + const application = { + app: yield* SupervisorControlServer.make(lifecycle), + }; + const owner = yield* acquireControl({ + stackId, + initialStatus: { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: stackId, + ownerSessionId, + state: "starting", + ready: false, + daemonCliVersion: "test", + }, + application, + }); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); + yield* lifecycle.setClose(owner.close); + yield* lifecycle.publishStack(stack); + return { lifecycle, owner }; + }); + const listenRawResponse = (port: number, body: string): Promise => new Promise((resolve, reject) => { const server = createServer((_request, response) => { @@ -71,6 +106,29 @@ const listenRawResponse = (port: number, body: string): Promise => const listenRaw = (port: number): Promise => listenRawResponse(port, "not-supabase"); +const listenNonHttp = ( + port: number, +): Promise<{ readonly server: TcpServer; readonly close: () => Promise }> => + new Promise((resolve, reject) => { + const sockets = new Set(); + const server = createTcpServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.end("not-http\r\n"); + }); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => + resolve({ + server, + close: () => + new Promise((resolveClose, rejectClose) => { + for (const socket of sockets) socket.destroy(); + server.close((error) => (error === undefined ? resolveClose() : rejectClose(error))); + }), + }), + ); + }); + const closeRaw = (server: Server): Promise => new Promise((resolve, reject) => { if (!server.listening) { @@ -80,6 +138,27 @@ const closeRaw = (server: Server): Promise => server.close((error) => (error === undefined ? resolve() : reject(error))); }); +it.effect("canonical owner reads retain foreign-owner conflict diagnostics", () => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const result = yield* readControlOwnerStatus(endpoint, STACK_ID, () => + Effect.succeed({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: "f".repeat(64), + ownerSessionId: "foreign-session", + state: "running", + ready: true, + daemonCliVersion: "foreign", + }), + ).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(Predicate.isTagged(result.failure, "ControlAddressConflictError")).toBe(true); + } + }), +); + const spawnBoundChild = (port: number) => { const child = spawn( process.execPath, @@ -120,84 +199,63 @@ describe("managed control endpoint", () => { }); }); - it.live("serves DaemonServer and RemoteStack on the owned listener", () => + it.live("serves the static supervisor application on the owned listener", () => Effect.scoped( live( Effect.gen(function* () { - const owner = yield* acquireControl({ stackId: STACK_ID }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const started = { value: false }; - const stackLayer = Layer.succeed(Stack, makeStack(started)); - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { - includeOwnerRoute: false, - }).pipe( - Layer.provide(stackLayer), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); - const remoteRuntime = ManagedRuntime.make( - RemoteStack.layer(owner.endpoint).pipe(Layer.provide(httpTransportClientLayer)), - ); - yield* Effect.promise(() => - remoteRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.start())), - ); - expect(started.value).toBe(true); - expect( - yield* Effect.promise(() => - remoteRuntime.runPromise(Effect.flatMap(Stack, (stack) => stack.getInfo())), - ), - ).toMatchObject({ publishableKey: "publishable" }); - yield* Effect.promise(() => remoteRuntime.dispose()); - yield* Effect.promise(() => daemonRuntime.dispose()); - }), - ), - ), - ); - - it.live("publishes owner status before and after DaemonServer uses the same listener", () => - Effect.scoped( - live( - Effect.gen(function* () { - const owner = yield* acquireControl({ stackId: STACK_ID }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - const before = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); - expect(before.status).toBe(200); - expect(yield* Effect.promise(() => before.json())).toMatchObject({ state: "starting" }); - const beforeRoutes = yield* Effect.promise(() => - fetch(`${owner.endpoint.url}/status`, { signal: AbortSignal.timeout(500) }), - ); - expect(beforeRoutes.status).toBe(503); - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { - includeOwnerRoute: false, - }).pipe( - Layer.provide(Layer.succeed(Stack, makeStack({ value: false }))), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); - const status = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/status`)); - expect(status.status).toBe(200); - yield* owner.setState("running"); - const after = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); - expect(yield* Effect.promise(() => after.json())).toMatchObject({ + const stack = makeStack(started); + const { owner } = yield* makeStaticOwner(STACK_ID, stack); + expect(started.value).toBe(false); + const response = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + ownershipId: STACK_ID, state: "running", ready: true, }); - yield* Effect.promise(() => daemonRuntime.dispose()); }), ), ), ); - it.live("hands ready-owner stop requests to DaemonServer exactly once", () => + it.live( + "publishes owner status before and after runtime publication on the static listener", + () => + Effect.scoped( + live( + Effect.gen(function* () { + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: STACK_ID, + ownerSessionId: crypto.randomUUID(), + daemonCliVersion: "test", + close: Effect.void, + }); + const application = { + app: yield* SupervisorControlServer.make(lifecycle), + }; + const owner = yield* acquireControl({ stackId: STACK_ID, application }); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); + yield* lifecycle.setClose(owner.close); + const before = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); + expect(before.status).toBe(200); + expect(yield* Effect.promise(() => before.json())).toMatchObject({ state: "starting" }); + yield* lifecycle.publishStack(makeStack({ value: false })); + const after = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); + expect(yield* Effect.promise(() => after.json())).toMatchObject({ + state: "running", + ready: true, + }); + yield* owner.close; + }), + ), + ), + ); + + it.live("hands a fenced stop request to the supervisor shutdown transaction exactly once", () => Effect.scoped( live( Effect.gen(function* () { - const owner = yield* acquireControl({ stackId: STACK_ID }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); const stopCalls = { value: 0 }; const stack = { ...makeStack({ value: false }), @@ -206,26 +264,23 @@ describe("managed control endpoint", () => { stopCalls.value += 1; }), } satisfies Stack["Service"]; - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown( - owner.setState("stopping", false), - owner.ownerStatus, - ).pipe( - Layer.provide(Layer.succeed(Stack, stack)), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); - yield* owner.setState("running"); + const { owner, lifecycle } = yield* makeStaticOwner(STACK_ID, stack); + const ownerStatus = yield* lifecycle.currentStatus; const response = yield* Effect.promise(() => - fetch(`${owner.endpoint.url}/stop`, { method: "POST" }), + fetch(`${owner.endpoint.url}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ownershipId: STACK_ID, + ownerSessionId: ownerStatus.ownerSessionId, + }), + }), ); - expect(response.status).toBe(200); + expect(response.status).toBe(202); expect(yield* Effect.promise(() => response.json())).toEqual({ ok: true }); + yield* lifecycle.awaitShutdown; expect(stopCalls.value).toBe(1); - - yield* Effect.promise(() => daemonRuntime.dispose()); }), ), ), @@ -235,30 +290,14 @@ describe("managed control endpoint", () => { Effect.scoped( live( Effect.gen(function* () { - const owner = yield* acquireControl({ stackId: STACK_ID }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { - includeOwnerRoute: false, - }).pipe( - Layer.provide(Layer.succeed(Stack, makeStack({ value: false }))), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); + yield* makeStaticOwner(STACK_ID, makeStack({ value: false })); const contender = yield* acquireControl({ stackId: STACK_ID }); expect(isControlAttached(contender)).toBe(true); expect(yield* contender.ownerStatus).toMatchObject({ - protocolVersion: 1, - state: "starting", - }); - yield* owner.setState("running"); - expect(yield* contender.ownerStatus).toMatchObject({ - protocolVersion: 1, + controlProtocolVersion: 1, state: "running", ready: true, }); - yield* Effect.promise(() => daemonRuntime.dispose()); }), ), ), @@ -273,15 +312,6 @@ describe("managed control endpoint", () => { expect(secondEndpoint.port).toBe(firstEndpoint.port); const owner = yield* acquireControl({ stackId: STACK_ID }); if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown(Effect.void, owner.ownerStatus, { - includeOwnerRoute: false, - }).pipe( - Layer.provide(Layer.succeed(Stack, makeStack({ value: false }))), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); if (!isControlOwnership(contender)) throw new Error("expected contender ownership"); expect(contender.endpoint.port).not.toBe(owner.endpoint.port); @@ -296,7 +326,6 @@ describe("managed control endpoint", () => { const attached = yield* acquireControl({ stackId: COLLIDING_STACK_ID }); expect(isControlAttached(attached)).toBe(true); expect(attached.endpoint.port).toBe(contender.endpoint.port); - yield* Effect.promise(() => daemonRuntime.dispose()); }), ), ), @@ -341,6 +370,24 @@ describe("managed control endpoint", () => { ), ); + it.live("starts on the next candidate when another stack service is not HTTP", () => + live( + Effect.scoped( + Effect.gen(function* () { + const candidates = yield* controlEndpointCandidates(STACK_ID); + const unrelated = yield* Effect.acquireRelease( + Effect.promise(() => listenNonHttp(candidates[0]!.port)), + (listener) => Effect.promise(() => listener.close()), + ); + const owner = yield* acquireControl({ stackId: STACK_ID }); + if (!isControlOwnership(owner)) throw new Error("expected control ownership"); + expect(owner.endpoint.port).toBe(candidates[1]!.port); + expect(unrelated.server.listening).toBe(true); + }), + ), + ), + ); + it.live("fails once every candidate is occupied by unrelated listeners", () => live( Effect.scoped( @@ -429,6 +476,297 @@ describe("managed control endpoint", () => { ), ); + it.effect("observes the original session after an ambiguous stop delivery", () => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const ownerSessionId = "owner-session"; + const status: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: STACK_ID, + ownerSessionId, + state: "running", + ready: true, + daemonCliVersion: "test", + }; + let requestCalls = 0; + let reads = 0; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + read: () => + Effect.sync(() => { + reads += 1; + return status; + }), + requestStop: (requestEndpoint) => + Effect.sync(() => { + requestCalls += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new ControlTransportError({ + endpoint: requestEndpoint, + reason: "transport", + cause: new Error("simulated connection reset after POST delivery"), + }), + ), + ), + ), + }; + const pending = yield* requestControlStopForSession( + endpoint, + STACK_ID, + ownerSessionId, + transport, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + yield* TestClock.adjust("30 seconds"); + yield* Effect.yieldNow; + + const result = yield* Fiber.join(pending).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect( + Predicate.isTagged(result.failure, "StopTimeout"), + `expected StopTimeout, received ${String(result.failure)}`, + ).toBe(true); + if (Predicate.isTagged(result.failure, "StopTimeout")) { + expect(result.failure.lastState).toBe("running"); + } + } + expect(requestCalls).toBe(1); + expect(reads).toBeGreaterThan(0); + }), + ); + + it.effect("retries an ambiguous observation until the exact session changes", () => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const ownerSessionId = "owner-session"; + const readStarted = yield* Deferred.make(); + const status: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: STACK_ID, + ownerSessionId, + state: "stopping", + ready: false, + daemonCliVersion: "test", + }; + const replacementStatus = { ...status, ownerSessionId: "replacement-session" }; + let reads = 0; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + read: (readEndpoint) => { + return Effect.sync(() => { + reads += 1; + return reads; + }).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Deferred.succeed(readStarted, void 0).pipe( + Effect.andThen( + Effect.fail( + new ControlTransportError({ + endpoint: readEndpoint, + reason: "transport", + cause: new Error("simulated observation reset"), + }), + ), + ), + ) + : Effect.succeed(replacementStatus), + ), + ); + }, + requestStop: () => Effect.void, + }; + const pending = yield* requestControlStopForSession( + endpoint, + STACK_ID, + ownerSessionId, + transport, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readStarted); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + for (let attempt = 0; attempt < 8 && reads < 2; attempt += 1) { + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + } + const result = yield* Fiber.join(pending).pipe(Effect.result); + expect(Result.isSuccess(result)).toBe(true); + expect(reads).toBe(2); + }), + ); + + it.effect("completes when another stack rebinds the stopped owner's endpoint", () => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const ownerSessionId = "owner-session"; + const foreignStatus: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: "f".repeat(64), + ownerSessionId: "foreign-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + read: () => Effect.succeed(foreignStatus), + requestStop: () => Effect.void, + }; + + const result = yield* requestControlStopForSession( + endpoint, + STACK_ID, + ownerSessionId, + transport, + ).pipe(Effect.result); + + expect(Result.isSuccess(result)).toBe(true); + }), + ); + + it.live("treats a post-stop non-control response as proof that the captured session ended", () => + Effect.forEach(["malformed", "protocol-mismatch"] as const, (replacementKind) => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const ownerSessionId = "owner-session"; + const oldListenerClosed = yield* Deferred.make(); + const replacementBound = yield* Deferred.make(); + let stopRequests = 0; + let reads = 0; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + requestStop: () => + Effect.sync(() => { + stopRequests += 1; + }).pipe( + // Model the supervisor's ordered teardown and the unrelated + // listener rebinding before the first post-stop read. + Effect.andThen(Deferred.succeed(oldListenerClosed, undefined)), + Effect.andThen(Deferred.succeed(replacementBound, undefined)), + ), + read: () => + Effect.gen(function* () { + yield* Deferred.await(oldListenerClosed); + yield* Deferred.await(replacementBound); + reads += 1; + if (replacementKind === "malformed") { + return "not-supabase"; + } + return { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 2, + ownershipId: STACK_ID, + ownerSessionId: "replacement-session", + state: "running", + ready: true, + daemonCliVersion: "foreign", + }; + }), + }; + + const result = yield* requestControlStopForSession( + endpoint, + STACK_ID, + ownerSessionId, + transport, + ).pipe(Effect.result); + expect(Result.isSuccess(result)).toBe(true); + expect(stopRequests).toBe(1); + expect(reads).toBe(1); + }), + ).pipe(Effect.asVoid), + ); + + it.effect("retains the verified attach status when a later live read is unreachable", () => + Effect.gen(function* () { + const endpoint = yield* controlEndpoint(STACK_ID); + const status: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: STACK_ID, + ownerSessionId: "owner-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }; + let reads = 0; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + read: (readEndpoint) => + Effect.suspend(() => { + reads += 1; + return reads === 1 + ? Effect.succeed(status) + : Effect.fail( + new ControlTransportError({ + endpoint: readEndpoint, + reason: "unreachable", + cause: new Error("owner closed after attach handshake"), + }), + ); + }), + requestStop: () => Effect.void, + }; + const attached = yield* acquireControl({ stackId: STACK_ID }).pipe( + Effect.provideService(ControlTransport, transport), + ); + expect(isControlAttached(attached)).toBe(true); + if (!isControlAttached(attached)) return; + expect(attached.observedStatus).toEqual(status); + const liveStatus = yield* attached.ownerStatus.pipe(Effect.result); + expect(Result.isFailure(liveStatus)).toBe(true); + expect(reads).toBe(2); + expect(endpoint.port).toBe(attached.endpoint.port); + }), + ); + + it.effect("stops only the owner session verified by the attach handshake", () => + Effect.gen(function* () { + const attachedStatus: ControlOwnerStatus = { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: STACK_ID, + ownerSessionId: "attached-session", + state: "running", + ready: true, + daemonCliVersion: "old", + }; + const replacementStatus: ControlOwnerStatus = { + ...attachedStatus, + ownerSessionId: "replacement-session", + daemonCliVersion: "new", + }; + let reads = 0; + let requestedSession: string | undefined; + const transport: ControlTransportShape = { + bind: () => Effect.die("unused"), + read: () => Effect.sync(() => (reads++ === 0 ? attachedStatus : replacementStatus)), + requestStop: (_requestEndpoint, request) => + Effect.sync(() => { + requestedSession = request.ownerSessionId; + }), + }; + const attached = yield* acquireControl({ stackId: STACK_ID }).pipe( + Effect.provideService(ControlTransport, transport), + ); + expect(isControlAttached(attached)).toBe(true); + if (!isControlAttached(attached)) return; + + yield* attached.requestStop; + + expect(requestedSession).toBe("attached-session"); + }), + ); + it.live("preserves an explicit owner protocol mismatch", () => live( Effect.scoped( @@ -438,7 +776,15 @@ describe("managed control endpoint", () => { Effect.promise(() => listenRawResponse( endpoint.port, - JSON.stringify({ protocolVersion: 2, state: "running", ready: true }), + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 2, + ownershipId: STACK_ID, + ownerSessionId: "foreign", + state: "running", + ready: true, + daemonCliVersion: "foreign", + }), ), ), (server) => Effect.promise(() => closeRaw(server)), diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index ec9cedf045..f07e509836 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -1,17 +1,6 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Layer, - ManagedRuntime, - Schedule, -} from "effect"; -import { HttpServer } from "effect/unstable/http"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer } from "effect"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; @@ -23,8 +12,15 @@ import { controlTransportLayer } from "./platform-node.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; import { Stack } from "./Stack.ts"; -import { DaemonServer } from "./DaemonServer.ts"; -import { deleteManagedStack, stopManagedStack, updateManagedLaunch } from "./managed/lifecycle.ts"; +import { SupervisorControlServer } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { + connectManagedStack, + deleteManagedStack, + stopManagedStack, + updateManagedLaunch, +} from "./managed/lifecycle.ts"; +import { DaemonUpgradeRequired } from "./errors.ts"; import { automaticDocument, cleanupRoots, @@ -69,6 +65,7 @@ describe("managed stack lifecycle journeys", () => { const input = { workspacePath: workspace, stackName: "default", + cliVersion: "test", launch: { versions: { postgres: "17.6.1" }, excludedServices: [], @@ -117,6 +114,7 @@ describe("managed stack lifecycle journeys", () => { const updated = yield* updateManagedLaunch({ workspacePath: workspace, stackName: "default", + cliVersion: "test", launch: { versions: { postgres: "17.6.1" }, excludedServices: ["studio"], @@ -148,8 +146,40 @@ describe("managed stack lifecycle journeys", () => { const manager = yield* ManagedStackManager; const environment = yield* ensureEnvironment(workspace); const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const stopped = { value: false }; + const localStack = { + ...controlStack(), + stop: () => Effect.sync(() => void (stopped.value = true)), + } satisfies Stack["Service"]; + const ownerSessionId = crypto.randomUUID(); + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId, + daemonCliVersion: "test", + close: Effect.void, + }); + const application = { + app: yield* SupervisorControlServer.make(lifecycle), + }; + const owner = yield* acquireControl({ + stackId, + initialStatus: { + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId: stackId, + ownerSessionId, + state: "starting", + ready: false, + daemonCliVersion: "test", + }, + application, + }); + if (!isControlOwnership(owner)) throw new Error("expected static owner"); + yield* lifecycle.setClose( + manager + .recordLifecycle(owner, { stackId, lifecycle: "stopped" }) + .pipe(Effect.asVoid, Effect.andThen(owner.close)), + ); const started = yield* startManagedStack(manager, { workspacePath: workspace, portDocument: automaticDocument(), @@ -157,43 +187,63 @@ describe("managed stack lifecycle journeys", () => { lifecycle: "starting", }); yield* releaseLease(started); - const stopped = { value: false }; - const localStack = { - ...controlStack(), - stop: () => Effect.sync(() => void (stopped.value = true)), - } satisfies Stack["Service"]; - const daemonRuntime = ManagedRuntime.make( - DaemonServer.layerWithShutdown( - Effect.gen(function* () { - yield* localStack.stop(); - yield* manager.recordLifecycle(owner, { stackId, lifecycle: "stopped" }); - }).pipe(Effect.asVoid, Effect.orDie), - owner.ownerStatus, - { includeOwnerRoute: false }, - ).pipe( - Layer.provide(Layer.succeed(Stack, localStack)), - Layer.provide(Layer.succeed(HttpServer.HttpServer, owner.server)), - ), - ); - yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer)); - yield* owner.setState("running", true); + yield* lifecycle.publishStack(localStack); const stopFiber = yield* Effect.forkScoped(stopManagedStack({ workspacePath: workspace })); - yield* manager.inspectStack(stackId).pipe( - Effect.flatMap((current) => - current?.lifecycle === "stopped" - ? Effect.succeed(current) - : Effect.fail(new Error("stop pending")), - ), - Effect.retry( - Schedule.spaced("10 millis").pipe(Schedule.upTo({ duration: "10 seconds" })), - ), - ); - yield* owner.close; yield* Fiber.join(stopFiber); expect(stopped.value).toBe(true); expect((yield* manager.inspectStack(stackId))?.lifecycle).toBe("stopped"); - yield* Effect.promise(() => daemonRuntime.dispose()); + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + Effect.provide(httpTransportClientLayer), + ); + }); + + it.live("reports a CLI mismatch before rejecting an incompatible starting owner", () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId: crypto.randomUUID(), + daemonCliVersion: "old-cli", + }); + const owner = yield* acquireControl({ + stackId, + initialStatus: yield* lifecycle.currentStatus, + application: { app: yield* SupervisorControlServer.make(lifecycle) }, + }); + if (!isControlOwnership(owner)) throw new Error("expected static owner"); + const started = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: owner, + lifecycle: "starting", + }); + yield* releaseLease(started); + yield* lifecycle.setClose(owner.close); + + const result = yield* connectManagedStack({ + workspacePath: workspace, + cliVersion: "new-cli", + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(DaemonUpgradeRequired); + if (error instanceof DaemonUpgradeRequired) { + expect(error).toMatchObject({ state: "starting", ready: false }); + } + } + yield* lifecycle.requestShutdown("dispose").pipe(Effect.ignore); }), ).pipe( Effect.provide(layer), @@ -370,6 +420,87 @@ describe("managed stack lifecycle journeys", () => { ); }); + it.live("keeps delete ownership bound until destructive cleanup finishes", () => { + const { layer, stateRoot, workspace } = setup(); + let armed = false; + let documentPath: string | undefined; + let entered!: Deferred.Deferred; + let release!: Deferred.Deferred; + const gatedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return { + ...base, + remove: (path: string, options?: Parameters[1]) => { + if (armed && documentPath === path) { + armed = false; + return Effect.gen(function* () { + yield* Deferred.succeed(entered, void 0); + yield* Deferred.await(release); + return yield* base.remove(path, options); + }); + } + return base.remove(path, options); + }, + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); + const managerLayer = layer.pipe(Layer.provide(gatedFileSystemLayer)); + return Effect.scoped( + Effect.gen(function* () { + entered = yield* Deferred.make(); + release = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); + const owner = yield* acquireControl({ stackId }); + if (!isControlOwnership(owner)) throw new Error("expected ownership"); + const started = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: owner, + lifecycle: "stopped", + }); + yield* releaseLease(started); + yield* owner.close; + + armed = true; + const deleting = yield* Effect.forkScoped(deleteManagedStack({ workspacePath: workspace })); + yield* Deferred.await(entered); + + const probe = yield* manager.probeControl(stackId); + if (probe === undefined) throw new Error("expected deleting owner"); + const response = yield* Effect.tryPromise(() => + fetch(`${probe.endpoint.url}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ownershipId: stackId, + ownerSessionId: probe.status.ownerSessionId, + }), + }), + ); + expect(response.status).toBe(202); + yield* Effect.promise(() => response.arrayBuffer()); + + const replacement = yield* manager.acquireControl(stackId); + expect(isControlOwnership(replacement)).toBe(false); + + yield* Deferred.succeed(release, void 0); + yield* Fiber.join(deleting); + expect(yield* manager.inspectStack(stackId)).toBeUndefined(); + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live( "keeps a stack document when its identity changes before delete ownership settles", () => { diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts index 3d6aadc528..f7e27c3072 100644 --- a/packages/stack/src/managed-manager-ports.integration.test.ts +++ b/packages/stack/src/managed-manager-ports.integration.test.ts @@ -30,7 +30,6 @@ import { releaseLease, setupManagedManager, startManagedStack, - startWithOwner, } from "../tests/helpers/managed-manager.ts"; const roots: Array = []; @@ -73,6 +72,41 @@ describe("managed stack ports journeys", () => { ); }); + it.live("can preserve sticky ports while a replacement request changes its exact intent", () => { + const { layer, workspace: base } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const { workspace, ownership } = yield* acquireWorkspaceControl(base); + if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); + const first = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + const api = first.stack.ports.find((assignment) => assignment.key === "api.port"); + if (api === undefined) throw new Error("expected API assignment"); + yield* releaseLease(first); + const second = yield* startManagedStack(manager, { + workspacePath: workspace, + portDocument: exactDocument("apiPort", api.port === 65_000 ? 65_001 : 65_000), + ownership, + lifecycle: "stopped", + preservePersistedPorts: true, + }); + expect(second.stack.ports).toContainEqual(api); + yield* releaseLease(second); + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live("reserves exact durable and automatic runtime ports through one lease", () => { const { layer, workspace: base } = setup(); return Effect.scoped( @@ -104,38 +138,44 @@ describe("managed stack ports journeys", () => { }); it.live("allows stopped exact siblings and rejects a live owner", () => { - const { layer } = setup(); + const { layer, workspace: base } = setup(); return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; const port = yield* freePort(); - const firstWorkspace = setup().workspace; - const secondWorkspace = setup().workspace; - const first = yield* startWithOwner( - manager, - firstWorkspace, - exactDocument("apiPort", port), - ); + const firstOwner = yield* acquireWorkspaceControl(base, "first"); + if (!isControlOwnership(firstOwner.ownership)) throw new Error("expected first ownership"); + const first = yield* startManagedStack(manager, { + workspacePath: firstOwner.workspace, + portDocument: exactDocument("apiPort", port), + ownership: firstOwner.ownership, + }); yield* releaseLease(first); - const second = yield* startWithOwner( - manager, - secondWorkspace, - exactDocument("apiPort", port), - ); + const secondOwner = yield* acquireWorkspaceControl(base, "second"); + if (!isControlOwnership(secondOwner.ownership)) + throw new Error("expected second ownership"); + const second = yield* startManagedStack(manager, { + workspacePath: secondOwner.workspace, + portDocument: exactDocument("apiPort", port), + ownership: secondOwner.ownership, + }); yield* releaseLease(second); - const liveWorkspace = setup().workspace; - const live = yield* startWithOwner( - manager, - liveWorkspace, - exactDocument("apiPort", port), - "running", - ); - const rejectedWorkspace = setup().workspace; - const rejected = yield* startWithOwner( - manager, - rejectedWorkspace, - exactDocument("apiPort", port), - ).pipe(Effect.exit); + const liveOwner = yield* acquireWorkspaceControl(base, "live"); + if (!isControlOwnership(liveOwner.ownership)) throw new Error("expected live ownership"); + const live = yield* startManagedStack(manager, { + workspacePath: liveOwner.workspace, + portDocument: exactDocument("apiPort", port), + ownership: liveOwner.ownership, + lifecycle: "running", + }); + const rejectedOwner = yield* acquireWorkspaceControl(base, "rejected"); + if (!isControlOwnership(rejectedOwner.ownership)) + throw new Error("expected rejected ownership"); + const rejected = yield* startManagedStack(manager, { + workspacePath: rejectedOwner.workspace, + portDocument: exactDocument("apiPort", port), + ownership: rejectedOwner.ownership, + }).pipe(Effect.exit); expect(Exit.isFailure(rejected)).toBe(true); if (Exit.isFailure(rejected)) { expect(Cause.squash(rejected.cause)).toBeInstanceOf(ManagedExactPortOccupiedError); diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index 01e3c82482..57ef7492d9 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -190,10 +190,13 @@ describe("managed stack projects journeys", () => { const owner = yield* acquireControl({ stackId, initialStatus: { - protocolVersion: 1, + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, ownershipId: stackId, + ownerSessionId: "projects-test-session", state: "running", ready: true, + daemonCliVersion: "test", }, }); if (!isControlOwnership(owner)) throw new Error("status probe took control ownership"); diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index ba6ff7227a..59b3d217bb 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -1,17 +1,6 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Layer, - ManagedRuntime, - PlatformError, -} from "effect"; -import { HttpServer } from "effect/unstable/http"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, PlatformError } from "effect"; import { randomBytes } from "node:crypto"; import { cpSync, mkdirSync, mkdtempSync, realpathSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -28,15 +17,12 @@ import { acquireControl, ControlTransport, isControlOwnership } from "./managed/ import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; -import { Stack } from "./Stack.ts"; -import { DaemonServer } from "./DaemonServer.ts"; import { makeRepository } from "../tests/helpers/git-workspace.ts"; import { deleteManagedStack } from "./managed/lifecycle.ts"; import { listStacks as listStackSummaries } from "./discovery.ts"; import { automaticDocument, cleanupRoots, - controlStack, releaseLease, setupManagedManager, startManagedStack, @@ -200,10 +186,13 @@ describe("managed stack recovery journeys", () => { return Deferred.succeed(repairRead, void 0).pipe( Effect.andThen( Effect.succeed({ - protocolVersion: 1, + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, ownershipId: ownerId, + ownerSessionId: "repair-session", state: "running" as const, ready: true, + daemonCliVersion: "test", }), ), ); @@ -218,13 +207,6 @@ describe("managed stack recovery journeys", () => { const repairOwner = yield* acquireControl({ stackId: repairId }); if (!isControlOwnership(repairOwner)) throw new Error("expected repair ownership"); repairEndpointUrl = repairOwner.endpoint.url; - const repairDaemon = ManagedRuntime.make( - DaemonServer.layerWithShutdown(Effect.void, repairOwner.ownerStatus).pipe( - Layer.provide(Layer.succeed(Stack, controlStack())), - Layer.provide(Layer.succeed(HttpServer.HttpServer, repairOwner.server)), - ), - ); - yield* Effect.promise(() => repairDaemon.runPromise(DaemonServer)); const stackOwner = yield* acquireIsolatedStackOwner(workspace); const stackId = deriveStackId(environment.identity, stackOwner.stackName); const startFiber = yield* startManagedStack(manager, { @@ -237,7 +219,6 @@ describe("managed stack recovery journeys", () => { expect(yield* manager.inspectStack(stackId)).toBeUndefined(); yield* repairOwner.close; repairEndpointUrl = undefined; - yield* Effect.promise(() => repairDaemon.dispose()); const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("60 seconds")); expect(started.stack.id).toBe(stackId); yield* releaseLease(started); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index b1ff2962de..b349c930d9 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -10,6 +10,7 @@ import { gitConfigStoreLayer } from "./managed/git.ts"; import { controlTransportLayer } from "./platform-node.ts"; export * from "./managed.ts"; +export { controlTransportLayer }; export { managedDaemonEntryPoint }; export type { ManagedDaemonStartInput } from "./supervisor.ts"; diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts index b36b63edf0..876e075c7c 100644 --- a/packages/stack/src/managed/control.ts +++ b/packages/stack/src/managed/control.ts @@ -1,19 +1,27 @@ -import { Data, Deferred, Effect, Context, Predicate, Ref, Result, Schedule, Schema } from "effect"; -import { HttpServer } from "effect/unstable/http"; +import { Data, Effect, Context, Predicate, Ref, Result, Schedule, Schema } from "effect"; +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { + CONTROL_PROTOCOL, + CONTROL_PROTOCOL_VERSION, ControlOwnerStatusSchema, type ControlOwnerStatus, type ControlOwnerState, + type ControlStopRequest, } from "../DaemonProtocol.ts"; +import { StopTimeout } from "../errors.ts"; -export type { ControlOwnerState, ControlOwnerStatus } from "../DaemonProtocol.ts"; +export type { + ControlOwnerState, + ControlOwnerStatus, + ControlStopRequest, +} from "../DaemonProtocol.ts"; +export { ControlStopRequestSchema } from "../DaemonProtocol.ts"; /** The owner status path exposed once the daemon routes are installed. */ export const CONTROL_STATUS_PATH = "/owner"; /** The early shutdown path exposed by the deterministic control listener. */ export const CONTROL_STOP_PATH = "/stop"; -const CONTROL_PROTOCOL_VERSION = 1 as const; const CONTROL_ID_PATTERN = /^[0-9a-f]{64}$/; /** Reserved loopback TCP range for deterministic managed control endpoints. */ @@ -25,6 +33,14 @@ export interface ControlEndpoint { readonly url: string; } +export interface ControlApplication { + readonly app: Effect.Effect< + HttpServerResponse.HttpServerResponse, + never, + HttpServerRequest.HttpServerRequest | import("effect/Scope").Scope + >; +} + const controlOwnershipBrand: unique symbol = Symbol("stack/ControlOwnership"); export class InvalidControlOwnershipIdError extends Data.TaggedError( @@ -54,13 +70,20 @@ export class ControlProtocolError extends Data.TaggedError("ControlProtocolError readonly cause: unknown; }> {} +/** A fenced stop reached an owner other than the captured session. */ +export class ControlStopConflictError extends Data.TaggedError("ControlStopConflictError")<{ + readonly endpoint: ControlEndpoint; +}> {} + export class ControlProtocolMismatchError extends Data.TaggedError("ControlProtocolMismatchError")<{ readonly endpoint: ControlEndpoint; readonly expectedVersion: 1; readonly observedVersion: number | undefined; + readonly expectedProtocol: typeof CONTROL_PROTOCOL; + readonly observedProtocol: string | undefined; }> { override get message(): string { - return `Control protocol mismatch: expected ${this.expectedVersion}, observed ${String(this.observedVersion)}`; + return `Control protocol mismatch: expected ${this.expectedProtocol}/${this.expectedVersion}, observed ${String(this.observedProtocol)}/${String(this.observedVersion)}`; } } @@ -78,23 +101,32 @@ class ControlUnavailableError extends Data.TaggedError("ControlUnavailableError" readonly cause: unknown; }> {} +class ControlStopPending extends Data.TaggedError("ControlStopPending")<{ + readonly state: ControlOwnerState; +}> {} + interface ControlListener { readonly server: HttpServer.HttpServer["Service"]; readonly close: Effect.Effect; } -export interface ControlTransportShape { - readonly bind: ( - endpoint: ControlEndpoint, - ownerStatus: () => ControlOwnerStatus, - onStop: () => void, - ) => Effect.Effect; +export interface ControlClientTransport { readonly read: ( endpoint: ControlEndpoint, ) => Effect.Effect; readonly requestStop: ( endpoint: ControlEndpoint, - ) => Effect.Effect; + request: ControlStopRequest, + ) => Effect.Effect; +} + +export interface ControlTransportShape extends ControlClientTransport { + readonly bind: ( + endpoint: ControlEndpoint, + ownerStatus: () => ControlOwnerStatus, + onStop: (request: ControlStopRequest) => "accepted" | "conflict" | "invalid", + application?: ControlApplication, + ) => Effect.Effect; } /** Runtime-specific loopback bind/connect operations supplied by Node or Bun. */ @@ -105,6 +137,7 @@ export class ControlTransport extends Context.Service; - readonly setOwnerStatus: (status: ControlOwnerStatus) => Effect.Effect; - readonly setState: (state: ControlOwnerState, ready?: boolean) => Effect.Effect; - readonly requestStop: Effect.Effect; - readonly stopRequested: Effect.Effect; readonly close: Effect.Effect; } @@ -125,6 +153,8 @@ export interface ControlAttached { readonly _tag: "Attached"; readonly ownershipId: string; readonly endpoint: ControlEndpoint; + /** Status decoded during the ownership handshake before the result escaped. */ + readonly observedStatus: ControlOwnerStatus; readonly ownerStatus: Effect.Effect< ControlOwnerStatus, | ControlTransportError @@ -132,7 +162,14 @@ export interface ControlAttached { | ControlProtocolMismatchError | ControlAddressConflictError >; - readonly requestStop: Effect.Effect; + readonly requestStop: Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout + >; } export type ControlAcquisition = ControlOwnership | ControlAttached; @@ -191,24 +228,137 @@ export const controlEndpoint = ( ): Effect.Effect => Effect.map(controlEndpointCandidates(ownershipId), (candidates) => candidates[0]!); +/** Waits until the exact owner session disappears after an accepted stop. */ +const waitForControlSessionEnd = ( + endpoint: ControlEndpoint, + ownershipId: string, + ownerSessionId: string, + read: Effect.Effect< + ControlOwnerStatus, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + >, +): Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout +> => + Effect.gen(function* () { + const lastState = yield* Ref.make(undefined); + const observe = read.pipe( + Effect.flatMap((current) => + current.ownershipId === ownershipId && current.ownerSessionId === ownerSessionId + ? Ref.set(lastState, current.state).pipe( + Effect.andThen(Effect.fail(new ControlStopPending({ state: current.state }))), + ) + : Effect.void, + ), + Effect.catchTag("ControlTransportError", (error) => + error.reason === "unreachable" + ? Effect.void + : Ref.get(lastState).pipe( + Effect.flatMap((state) => + Effect.fail(new ControlStopPending({ state: state ?? "stopping" })), + ), + ), + ), + // A valid owner for another identity can claim this candidate after the + // captured session releases it. That proves the captured session ended. + Effect.catchTag("ControlAddressConflictError", () => Effect.void), + // Once the captured listener has closed, an unrelated listener may bind + // the same endpoint before this observer runs. A malformed response or + // a different control protocol therefore proves that the old session is + // gone just like a foreign owner response does. + Effect.catchTags({ + ControlProtocolError: () => Effect.void, + ControlProtocolMismatchError: () => Effect.void, + }), + ); + return yield* observe.pipe( + Effect.retry({ + schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), + while: (error) => Predicate.isTagged(error, "ControlStopPending"), + }), + Effect.catchTag("ControlStopPending", (error) => + Effect.fail( + new StopTimeout({ endpoint: endpoint.url, ownerSessionId, lastState: error.state }), + ), + ), + ); + }); + +/** + * Sends a fenced stop to one already-verified owner session and waits for that + * exact session to disappear. Callers that have only an ownership id should + * re-probe before invoking this helper; the session fence must never be + * refreshed after the stop request is accepted. + */ +export const requestControlStopForSession = ( + endpoint: ControlEndpoint, + ownershipId: string, + ownerSessionId: string, + transport: ControlClientTransport, +): Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout +> => + transport.requestStop(endpoint, { ownershipId, ownerSessionId }).pipe( + // The stop POST has an ambiguous delivery result: the peer may have + // accepted it and closed the connection before the response arrived. + // Observe the exact captured session instead of failing or refreshing the + // descriptor; a still-live session will reach the existing timeout. + Effect.catchTags({ + ControlTransportError: () => Effect.void, + ControlStopConflictError: () => Effect.void, + }), + Effect.flatMap(() => + waitForControlSessionEnd( + endpoint, + ownershipId, + ownerSessionId, + readControlOwnerStatus(endpoint, ownershipId, transport.read), + ), + ), + ); + const decodeOwnerStatus = ( endpoint: ControlEndpoint, value: unknown, ): Effect.Effect => { - if ( - typeof value === "object" && - value !== null && - "protocolVersion" in value && - typeof value.protocolVersion === "number" && - value.protocolVersion !== CONTROL_PROTOCOL_VERSION - ) { - return Effect.fail( - new ControlProtocolMismatchError({ - endpoint, - expectedVersion: CONTROL_PROTOCOL_VERSION, - observedVersion: value.protocolVersion, - }), - ); + if (typeof value === "object" && value !== null) { + const observedVersion = + "controlProtocolVersion" in value && typeof value.controlProtocolVersion === "number" + ? value.controlProtocolVersion + : undefined; + const observedProtocol = + "controlProtocol" in value && typeof value.controlProtocol === "string" + ? value.controlProtocol + : undefined; + const hasVersion = "controlProtocolVersion" in value; + const hasProtocol = "controlProtocol" in value; + if ( + (hasVersion && observedVersion !== CONTROL_PROTOCOL_VERSION) || + (hasProtocol && observedProtocol !== CONTROL_PROTOCOL) + ) { + return Effect.fail( + new ControlProtocolMismatchError({ + endpoint, + expectedVersion: CONTROL_PROTOCOL_VERSION, + observedVersion, + expectedProtocol: CONTROL_PROTOCOL, + observedProtocol, + }), + ); + } } return Schema.decodeUnknownEffect(ControlOwnerStatusSchema)(value).pipe( Effect.mapError(() => new ControlProtocolError({ endpoint, cause: value })), @@ -220,16 +370,34 @@ const defaultStatus = ( status: ControlOwnerStatus | undefined, ): ControlOwnerStatus => status === undefined - ? { protocolVersion: CONTROL_PROTOCOL_VERSION, ownershipId, state: "starting", ready: false } - : { ...status, ownershipId }; + ? { + controlProtocol: CONTROL_PROTOCOL, + controlProtocolVersion: CONTROL_PROTOCOL_VERSION, + ownershipId, + ownerSessionId: crypto.randomUUID(), + state: "starting", + ready: false, + daemonCliVersion: "unknown", + } + : { + ...status, + controlProtocol: CONTROL_PROTOCOL, + controlProtocolVersion: CONTROL_PROTOCOL_VERSION, + ownershipId, + }; const unavailable = (endpoint: ControlEndpoint, cause: unknown): ControlUnavailableError => new ControlUnavailableError({ endpoint, cause }); -const readOwnerStatus = ( +export type ControlOwnerReader = ( + endpoint: ControlEndpoint, +) => Effect.Effect; + +/** Reads and verifies one exact owner through a supplied control transport. */ +export const readControlOwnerStatus = ( endpoint: ControlEndpoint, ownershipId: string, - transport: ControlTransportShape, + read: ControlOwnerReader, ): Effect.Effect< ControlOwnerStatus, | ControlTransportError @@ -237,7 +405,7 @@ const readOwnerStatus = ( | ControlProtocolMismatchError | ControlAddressConflictError > => - transport.read(endpoint).pipe( + read(endpoint).pipe( Effect.flatMap((value) => decodeOwnerStatus(endpoint, value)), Effect.flatMap((status) => status.ownershipId === ownershipId @@ -253,6 +421,39 @@ const readOwnerStatus = ( ), ); +export interface ControlClientShape { + readonly readOwner: ( + endpoint: ControlEndpoint, + ownershipId: string, + ) => Effect.Effect< + ControlOwnerStatus, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + >; + readonly stopSession: ( + endpoint: ControlEndpoint, + ownershipId: string, + ownerSessionId: string, + ) => Effect.Effect< + void, + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | ControlAddressConflictError + | StopTimeout + >; +} + +/** Stable owner/session client shared by platform and remote HTTP transports. */ +export const makeControlClient = (transport: ControlClientTransport): ControlClientShape => ({ + readOwner: (endpoint, ownershipId) => + readControlOwnerStatus(endpoint, ownershipId, transport.read), + stopSession: (endpoint, ownershipId, ownerSessionId) => + requestControlStopForSession(endpoint, ownershipId, ownerSessionId, transport), +}); + /** A located owner: its published status and the candidate it bound. */ export interface ControlProbe { readonly status: ControlOwnerStatus; @@ -267,7 +468,7 @@ export const probeControl = ( const candidates = yield* controlEndpointCandidates(ownershipId); const transport = yield* ControlTransport; for (const endpoint of candidates) { - const status = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( + const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( Effect.catch(() => Effect.succeed(undefined)), ); if (status !== undefined) return { status, endpoint }; @@ -279,13 +480,20 @@ const makeAttached = ( endpoint: ControlEndpoint, ownershipId: string, transport: ControlTransportShape, -): ControlAttached => ({ - _tag: "Attached", - ownershipId, - endpoint, - ownerStatus: readOwnerStatus(endpoint, ownershipId, transport), - requestStop: transport.requestStop(endpoint), -}); + observedStatus: ControlOwnerStatus, +): ControlAttached => { + const client = makeControlClient(transport); + const ownerStatus = client.readOwner(endpoint, ownershipId); + const requestStop = client.stopSession(endpoint, ownershipId, observedStatus.ownerSessionId); + return { + _tag: "Attached", + ownershipId, + endpoint, + observedStatus, + ownerStatus, + requestStop, + }; +}; const attach = ( endpoint: ControlEndpoint, @@ -298,8 +506,8 @@ const attach = ( | ControlProtocolMismatchError | ControlAddressConflictError > => - readOwnerStatus(endpoint, ownershipId, transport).pipe( - Effect.map(() => makeAttached(endpoint, ownershipId, transport)), + readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( + Effect.map((status) => makeAttached(endpoint, ownershipId, transport, status)), ); const makeOwned = ( @@ -307,7 +515,6 @@ const makeOwned = ( ownershipId: string, listener: ControlListener, statusRef: Ref.Ref, - stopRequested: Deferred.Deferred, ): Effect.Effect => { let closed = false; const close = Effect.suspend(() => { @@ -320,18 +527,7 @@ const makeOwned = ( [controlOwnershipBrand]: true, ownershipId, endpoint, - server: listener.server, ownerStatus: Ref.get(statusRef), - setOwnerStatus: (next) => Ref.set(statusRef, { ...next, ownershipId }), - setState: (state, ready = state === "running") => - Ref.set(statusRef, { - protocolVersion: CONTROL_PROTOCOL_VERSION, - ownershipId, - state, - ready, - }), - requestStop: Deferred.succeed(stopRequested, void 0).pipe(Effect.asVoid), - stopRequested: Deferred.await(stopRequested), close, }); }; @@ -347,21 +543,18 @@ const scanForOwner = ( candidates: ReadonlyArray, ownershipId: string, transport: ControlTransportShape, -): Effect.Effect< - ControlEndpoint | undefined, - ControlProtocolMismatchError | ControlTransportError -> => +): Effect.Effect => Effect.gen(function* () { for (const endpoint of candidates) { - const found = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe( - Effect.map(() => true), + const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( + Effect.map((status) => status), Effect.catchTag("ControlTransportError", (cause) => - cause.reason === "unreachable" ? Effect.succeed(false) : Effect.fail(cause), + cause.reason === "unreachable" ? Effect.succeed(undefined) : Effect.fail(cause), ), - Effect.catchTag("ControlProtocolError", () => Effect.succeed(false)), - Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(false)), + Effect.catchTag("ControlProtocolError", () => Effect.succeed(undefined)), + Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(undefined)), ); - if (found) return endpoint; + if (status !== undefined) return { endpoint, status }; } return undefined; }); @@ -371,6 +564,7 @@ const acquireAtCandidates = ( ownershipId: string, status: ControlOwnerStatus, transport: ControlTransportShape, + application?: ControlApplication, ): Effect.Effect< ControlAcquisition, | ControlBindError @@ -381,7 +575,6 @@ const acquireAtCandidates = ( import("effect/Scope").Scope > => { const statusRef = Ref.makeUnsafe(status); - const stopRequested = Deferred.makeUnsafe(); const attempt: Effect.Effect< ControlAcquisition, | ControlBindError @@ -398,7 +591,7 @@ const acquireAtCandidates = ( // read doubles as the attach handshake, so an owner is read exactly once. const ownerEndpoint = yield* scanForOwner(candidates, ownershipId, transport); if (ownerEndpoint !== undefined) { - return makeAttached(ownerEndpoint, ownershipId, transport); + return makeAttached(ownerEndpoint.endpoint, ownershipId, transport, ownerEndpoint.status); } let pending: ControlUnavailableError | undefined; let conflict: ControlAddressConflictError | undefined; @@ -407,19 +600,22 @@ const acquireAtCandidates = ( .bind( endpoint, () => Ref.getUnsafe(statusRef), - () => { - Deferred.doneUnsafe(stopRequested, Effect.succeed(undefined)); + (request) => { + if (request === undefined) return "invalid"; + const current = Ref.getUnsafe(statusRef); + if ( + request.ownershipId !== ownershipId || + request.ownerSessionId !== current.ownerSessionId + ) { + return "conflict"; + } + return "accepted"; }, + application, ) .pipe(Effect.result); if (Result.isSuccess(bound)) { - const owned = yield* makeOwned( - endpoint, - ownershipId, - bound.success, - statusRef, - stopRequested, - ); + const owned = yield* makeOwned(endpoint, ownershipId, bound.success, statusRef); yield* Effect.addFinalizer(() => owned.close); return owned; } @@ -504,5 +700,6 @@ export const acquireControl = ( input.stackId, defaultStatus(input.stackId, input.initialStatus), transport, + input.application, ); }); diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index ef7ff3a57c..520682a313 100644 --- a/packages/stack/src/managed/document.ts +++ b/packages/stack/src/managed/document.ts @@ -51,6 +51,8 @@ export interface ManagedStackDocument { }; readonly ports: ReadonlyArray; readonly lifecycle: ManagedStackDocumentLifecycle; + /** A durable fence written by the identity-scoped public stop operation. */ + readonly stopIntent?: "explicit"; readonly runtime?: { readonly pid: number; readonly controlEndpoint: string; @@ -104,6 +106,7 @@ const managedStackDocumentSchema = Schema.Struct({ }), ports: Schema.Array(managedPortAssignmentSchema), lifecycle: Schema.Literals(["stopped", "starting", "running", "deleting", "failed"]), + stopIntent: Schema.optionalKey(Schema.Literal("explicit")), runtime: Schema.optionalKey( Schema.Struct({ pid: Schema.Number, diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index ca78d99568..bc1bf1c1ab 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -1,6 +1,6 @@ import { Data, Effect, Layer, Schedule } from "effect"; import { NoRunningStackError } from "./model.ts"; -import { RemoteStack } from "../RemoteStack.ts"; +import { RemoteStack, updateRemoteLaunch } from "../RemoteStack.ts"; import { Stack } from "../Stack.ts"; import { dockerForceRemove } from "../cleanup.ts"; import { dockerContainerName } from "../StackIdentity.ts"; @@ -15,7 +15,15 @@ import { type ManagedStackManagerError, type ManagedStackLaunchUpdateRequest, } from "./manager.ts"; -import { ControlTransportError, isControlOwnership } from "./control.ts"; +import { acquireControl, ControlTransport, isControlOwnership } from "./control.ts"; +import { makeSupervisorControlApplication } from "../SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "../SupervisorLifecycle.ts"; +import { + DaemonUpgradeRequired, + StackBuildError, + StackRpcProtocolError, + StackRpcTransportError, +} from "../errors.ts"; import { ManagedStackNotStoppedError, type ManagedPortIntentDocument, @@ -29,6 +37,7 @@ export interface ManagedLifecycleInput { readonly stackName?: string; readonly cwd?: string; readonly portDocument?: ManagedPortIntentDocument; + readonly cliVersion?: string; } const emptyPortDocument = (): ManagedPortIntentDocument => ({ @@ -71,15 +80,14 @@ export const resolveManagedDocument = ( }); class ManagedStopPending extends Data.TaggedError("ManagedStopPending")<{}> {} -class ManagedStopOwnerTerminal extends Data.TaggedError("ManagedStopOwnerTerminal")<{}> {} class ManagedDeletePending extends Data.TaggedError("ManagedDeletePending")<{}> {} /** Connect to the control endpoint the managed supervisor actually bound. */ export const connectManagedStack = ( - input: ManagedLifecycleInput, + input: ManagedLifecycleInput & { readonly cliVersion: string }, ): Effect.Effect< - Layer.Layer, - NoRunningStackError | ManagedStackManagerError, + Layer.Layer, + NoRunningStackError | ManagedStackManagerError | DaemonUpgradeRequired, ManagedStackManager | HttpTransportClient > => Effect.gen(function* () { @@ -92,13 +100,33 @@ export const connectManagedStack = ( } const manager = yield* ManagedStackManager; const probe = yield* manager.probeControl(document.id); - if (probe === undefined || probe.status.state !== "running" || !probe.status.ready) { + if (probe === undefined) { + return yield* Effect.fail(noRunningStack(input)); + } + if (probe.status.daemonCliVersion !== input.cliVersion) { + return yield* Effect.fail( + new DaemonUpgradeRequired({ + stackId: document.id, + oldCliVersion: probe.status.daemonCliVersion, + newCliVersion: input.cliVersion, + state: probe.status.state, + ready: probe.status.ready, + }), + ); + } + if (probe.status.state !== "running" || !probe.status.ready) { return yield* Effect.fail(noRunningStack(input)); } const client = yield* HttpTransportClient; - return RemoteStack.layer(probe.endpoint).pipe( - Layer.provide(Layer.succeed(HttpTransportClient, client)), - ); + return RemoteStack.layer(probe.endpoint, { + cliVersion: input.cliVersion, + owner: { + ownershipId: probe.status.ownershipId, + ownerSessionId: probe.status.ownerSessionId, + controlProtocolVersion: probe.status.controlProtocolVersion, + daemonCliVersion: probe.status.daemonCliVersion, + }, + }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, client))); }); /** Ask the owner to stop; the supervisor clears runtime state before exiting. */ @@ -106,7 +134,7 @@ export const stopManagedStack = ( input: ManagedLifecycleInput, ): Effect.Effect< void, - NoRunningStackError | ManagedStackManagerError, + NoRunningStackError | ManagedStackManagerError | import("../errors.ts").StopTimeout, ManagedStackManager | HttpTransportClient > => Effect.scoped( @@ -114,9 +142,6 @@ export const stopManagedStack = ( const manager = yield* ManagedStackManager; const document = yield* resolveManagedDocument(input); const stackId = document.id; - const containerRuntime = - document.launch.mode === "docker" ? document.launch.containerRuntime : null; - const acquisition = yield* manager.acquireControl(stackId); const revalidatedStackId = yield* stackIdForInput(manager, input); if (revalidatedStackId !== stackId) { return yield* Effect.fail( @@ -125,148 +150,95 @@ export const stopManagedStack = ( }), ); } - if (isControlOwnership(acquisition)) { - if ( - document.lifecycle === "running" || - document.lifecycle === "starting" || - document.lifecycle === "failed" - ) { - if (containerRuntime !== null) { - yield* dockerForceRemove( - containerRuntime, - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); - } - yield* manager.recordLifecycle(acquisition, { stackId, lifecycle: "stopped" }); - } - yield* acquisition.close; - return; - } - if (document.lifecycle !== "running" && document.lifecycle !== "starting") { - return yield* Effect.fail(new ManagedStackAttachedError({ stackId })); - } - const client = yield* HttpTransportClient; const cleanupOwned = (owned: import("./control.ts").ControlOwnership) => Effect.ensuring( Effect.gen(function* () { + const current = yield* manager.inspectStack(stackId); + const containerRuntime = + current?.launch.mode === "docker" ? current.launch.containerRuntime : null; if (containerRuntime !== null) { yield* dockerForceRemove( containerRuntime, SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), ); } - yield* manager.recordLifecycle(owned, { stackId, lifecycle: "stopped" }); + if ( + current !== undefined && + (current.lifecycle !== "stopped" || current.stopIntent !== "explicit") + ) { + yield* manager.recordLifecycle(owned, { + stackId, + lifecycle: "stopped", + stopIntent: "explicit", + }); + } }), owned.close, ); - let stopRequested = false; - const awaitOwnerReady: Effect.Effect< - "ready", - | ManagedStopPending - | ManagedStopOwnerTerminal - | ControlTransportError - | import("./control.ts").ControlProtocolError - | import("./control.ts").ControlProtocolMismatchError - | import("./control.ts").ControlAddressConflictError - > = acquisition.ownerStatus.pipe( - Effect.flatMap( - ( - status, - ): Effect.Effect< - "ready", - | ManagedStopPending - | ManagedStopOwnerTerminal - | ControlTransportError - | import("./control.ts").ControlProtocolError - | import("./control.ts").ControlProtocolMismatchError - | import("./control.ts").ControlAddressConflictError - > => { - if (status.state === "running" && status.ready) return Effect.succeed<"ready">("ready"); - if (status.state === "starting") { - return Effect.gen(function* () { - if (!stopRequested) { - stopRequested = true; - yield* acquisition.requestStop; - } - return yield* Effect.fail(new ManagedStopPending()); - }); - } - if (status.state === "stopping") { - return Effect.fail(new ManagedStopPending()); - } - return Effect.fail(new ManagedStopOwnerTerminal()); - }, - ), + + /** + * Stop the exact session currently observed, then probe again. A new + * supervisor can bind immediately after the old session disappears; a + * public identity-scoped stop must follow and fence that successor + * rather than returning with a running document. + */ + const stopCurrentOwner = Effect.gen(function* () { + const current = yield* manager.inspectStack(stackId); + if (current === undefined) return; + const acquisition = yield* manager.acquireControl(stackId); + const currentStackId = yield* stackIdForInput(manager, input); + if (currentStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed while stopping", + }), + ); + } + if (isControlOwnership(acquisition)) { + yield* cleanupOwned(acquisition); + return; + } + yield* acquisition.requestStop; + return yield* Effect.fail(new ManagedStopPending()); + }).pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), while: (error) => error instanceof ManagedStopPending, }), - ); - const ready = yield* awaitOwnerReady.pipe( - Effect.catchTag("ManagedStopOwnerTerminal", () => - Effect.fail(new ManagedStackAttachedError({ stackId })), - ), - Effect.catch((error) => - error instanceof ControlTransportError && error.reason === "unreachable" - ? Effect.succeed<"dead">("dead") - : Effect.fail(error), + Effect.catchTag("ManagedStopPending", () => + Effect.fail(new ManagedStackNotStoppedError({ stackId })), ), - Effect.mapError(() => new ManagedStackNotStoppedError({ stackId })), ); - if (ready === "dead") { - const released = yield* manager.acquireControl(stackId).pipe( - Effect.flatMap((candidate) => - isControlOwnership(candidate) - ? Effect.succeed(candidate) - : Effect.fail(new ManagedStopPending()), - ), - Effect.retry( - Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), - ), - Effect.mapError(() => new ManagedStackNotStoppedError({ stackId })), - ); - yield* cleanupOwned(released); - return; - } - const layer = RemoteStack.layer(acquisition.endpoint).pipe( - Layer.provide(Layer.succeed(HttpTransportClient, client)), - ); - yield* Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.stop(); - }).pipe(Effect.provide(layer)); - yield* manager.inspectStack(stackId).pipe( - Effect.flatMap((current) => - current?.lifecycle === "stopped" - ? Effect.succeed(current) - : Effect.fail(new ManagedStopPending()), - ), - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), - Effect.mapError(() => new ManagedStackNotStoppedError({ stackId })), - ); - const released = yield* manager.acquireControl(stackId).pipe( - Effect.flatMap((candidate) => - isControlOwnership(candidate) - ? Effect.succeed(candidate) - : Effect.fail(new ManagedStopPending()), - ), - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), - Effect.mapError(() => new ManagedStackNotStoppedError({ stackId })), - ); - yield* released.close; + yield* stopCurrentOwner; }), ); /** Remove a stopped document while holding its deterministic control owner. */ export const deleteManagedStack = ( input: ManagedLifecycleInput, -): Effect.Effect => +): Effect.Effect< + void, + NoRunningStackError | ManagedStackManagerError, + ManagedStackManager | ControlTransport +> => Effect.gen(function* () { const manager = yield* ManagedStackManager; const stackId = yield* stackIdForInput(manager, input); yield* Effect.scoped( Effect.gen(function* () { - const acquisition = yield* manager.acquireControl(stackId).pipe( + const lifecycle = yield* SupervisorLifecycle.make({ + ownershipId: stackId, + ownerSessionId: crypto.randomUUID(), + daemonCliVersion: "managed", + }); + const application = { + app: yield* makeSupervisorControlApplication(lifecycle), + }; + const acquisition = yield* acquireControl({ + stackId, + initialStatus: yield* lifecycle.currentStatus, + application, + }).pipe( Effect.flatMap((candidate) => isControlOwnership(candidate) ? Effect.succeed(candidate) @@ -277,15 +249,27 @@ export const deleteManagedStack = ( ), Effect.mapError(() => new ManagedStackAttachedError({ stackId })), ); - const revalidatedStackId = yield* stackIdForInput(manager, input); - if (revalidatedStackId !== stackId) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed before delete", - }), - ); - } - const result = yield* manager.deleteStack(stackId, acquisition); + // Keep the control listener bound until the destructive delete has + // completed. A concurrent fenced /stop may transition this lifecycle + // to closed, but must not release the endpoint while the document and + // backing data are still being removed. Install the no-op close before + // revalidating identity as well, so every path after acquisition has + // the same ownership fence and cleanup guarantee. + yield* lifecycle.setClose(Effect.void); + const result = yield* Effect.gen(function* () { + const revalidatedStackId = yield* stackIdForInput(manager, input); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before delete", + }), + ); + } + yield* lifecycle.beginDeleting; + return yield* manager.deleteStack(stackId, acquisition); + }) + .pipe(Effect.ensuring(acquisition.close)) + .pipe(Effect.ensuring(lifecycle.requestShutdown("dispose").pipe(Effect.ignore))); if (result.outcome === "already-absent") return yield* Effect.fail(noRunningStack(input)); }), ); @@ -293,10 +277,19 @@ export const deleteManagedStack = ( /** Persist launch selections in the managed document, owner-gated. */ export const updateManagedLaunch = ( - input: ManagedLifecycleInput & { readonly launch: ManagedStackLaunchUpdate }, + input: ManagedLifecycleInput & { + readonly launch: ManagedStackLaunchUpdate; + readonly cliVersion: string; + }, ): Effect.Effect< ManagedStackDocument, - NoRunningStackError | ManagedStackManagerError | HttpTransportClientError, + | NoRunningStackError + | ManagedStackManagerError + | HttpTransportClientError + | DaemonUpgradeRequired + | StackBuildError + | StackRpcProtocolError + | StackRpcTransportError, ManagedStackManager | HttpTransportClient > => Effect.scoped( @@ -308,15 +301,21 @@ export const updateManagedLaunch = ( if (document.lifecycle !== "running" || document.runtime?.controlEndpoint === undefined) { return yield* Effect.fail(new ManagedStackAttachedError({ stackId: document.id })); } - const client = yield* HttpTransportClient; - const response = yield* client.request(acquisition.endpoint, "/managed/launch", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input.launch), - }); - if (!response.ok) { - return yield* Effect.fail(new ManagedStackNotStoppedError({ stackId: document.id })); - } + const status = yield* acquisition.ownerStatus; + yield* updateRemoteLaunch( + acquisition.endpoint, + { + cliVersion: input.cliVersion, + owner: { + ownershipId: status.ownershipId, + ownerSessionId: status.ownerSessionId, + controlProtocolVersion: status.controlProtocolVersion, + daemonCliVersion: status.daemonCliVersion, + }, + }, + document.id, + input.launch, + ); const next = yield* manager.inspectStack(document.id); if (next === undefined) return yield* Effect.fail(noRunningStack(input)); return next; diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index a8903f15db..11153db265 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -88,12 +88,16 @@ export interface StartStackRequest { readonly lifecycle?: ManagedStackDocument["lifecycle"]; readonly runtime?: ManagedStackDocument["runtime"]; readonly launch: ManagedStackDocument["launch"]; + /** Incompatible replacement must retain the target's sticky assignments. */ + readonly preservePersistedPorts?: boolean; } export interface AllocateManagedPortsRequest { readonly stackId: string; readonly portDocument: ManagedPortIntentDocument; readonly persisted?: ReadonlyArray; + /** During an upgrade restart, keep the target's sticky assignments. */ + readonly preservePersisted?: boolean; } export interface ManagedPortAllocation { @@ -113,6 +117,7 @@ export type ManagedStackStartResult = ManagedStackStartResultBase & { export interface ManagedStackLifecycleUpdate { readonly stackId: string; readonly lifecycle: ManagedStackDocument["lifecycle"]; + readonly stopIntent?: "explicit"; /** A running runtime descriptor, or `null` to clear stale runtime state. */ readonly runtime?: ManagedStackDocument["runtime"] | null; } @@ -510,6 +515,7 @@ const makeManager = ( intents: resolvePortIntents(request.portDocument), persisted, preferCatalogDefaults, + preservePersisted: request.preservePersisted, }); const invalidPersistedAutomatic = plan.durable.find( (entry) => @@ -762,6 +768,7 @@ const makeManager = ( stackId: refreshedStackId, portDocument: request.portDocument, persisted: current?.ports, + preservePersisted: request.preservePersistedPorts, }); const timestamp = now(); const document: ManagedStackDocument = { @@ -802,22 +809,17 @@ const makeManager = ( if (current === undefined) { return yield* Effect.fail(new ManagedStackNotFoundError({ stackId: update.stackId })); } - const ownerState = - update.lifecycle === "running" - ? "running" - : update.lifecycle === "starting" - ? "starting" - : update.lifecycle === "deleting" - ? "deleting" - : update.lifecycle === "failed" - ? "failed" - : "stopping"; - yield* ownership.setState(ownerState, update.lifecycle === "running"); let next: ManagedStackDocument = { ...current, lifecycle: update.lifecycle, updatedAt: now(), }; + if (update.stopIntent === "explicit") { + next = { ...next, stopIntent: "explicit" }; + } else if (update.lifecycle !== "stopped") { + const { stopIntent: _stopIntent, ...withoutStopIntent } = next; + next = withoutStopIntent; + } if ( update.runtime !== undefined || update.lifecycle === "stopped" || @@ -989,7 +991,6 @@ const makeManager = ( ); if (current === undefined) return { outcome: "already-absent", stackId }; if ("outcome" in current) return current; - yield* acquisition.setState("deleting", false); if (current.launch.mode === "docker") { yield* dockerForceRemove( current.launch.containerRuntime, diff --git a/packages/stack/src/managed/port-plan.ts b/packages/stack/src/managed/port-plan.ts index 0c249e2a97..5609fbdfca 100644 --- a/packages/stack/src/managed/port-plan.ts +++ b/packages/stack/src/managed/port-plan.ts @@ -55,6 +55,8 @@ export interface ManagedPortPlanInput { * defaults, which sticky reuse later re-reserves exactly. */ readonly preferCatalogDefaults?: boolean; + /** Replacements keep the target stack's existing sticky assignments. */ + readonly preservePersisted?: boolean; } const automaticSelection = (preferred: number | undefined): PortSelection => @@ -78,7 +80,15 @@ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan = const configured = intentsByField.get(field); const persistedAssignment = persistedByKey.get(entry.configKey); const intent = configured?.intent ?? "automatic"; - if (configured?.intent === "exact") { + if (input.preservePersisted && persistedAssignment !== undefined) { + durable.push({ + field, + key: entry.configKey, + intent: persistedAssignment.intent, + selection: { kind: "exact", port: persistedAssignment.port }, + newlyAllocatedAutomatic: false, + }); + } else if (configured?.intent === "exact") { durable.push({ field, key: entry.configKey, diff --git a/packages/stack/src/platform-bun.integration.test.ts b/packages/stack/src/platform-bun.integration.test.ts index 14674b4b8c..a0593f1d14 100644 --- a/packages/stack/src/platform-bun.integration.test.ts +++ b/packages/stack/src/platform-bun.integration.test.ts @@ -1,10 +1,103 @@ -import { Cause, Effect, Exit } from "effect"; +import { Cause, Deferred, Effect, Exit, Layer, Predicate, Scope } from "effect"; import { describe, expect, test } from "vitest"; -import { ControlTransport, ControlTransportError } from "./managed/control.ts"; +import { + ControlStopConflictError, + ControlProtocolError, + ControlTransport, + ControlTransportError, + makeControlClient, +} from "./managed/control.ts"; +import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { makeTestStack } from "./testing.ts"; const isBun = typeof Bun !== "undefined"; describe("Bun control transport", () => { + (isBun ? test : test.skip)( + "classifies a fenced stop conflict distinctly from transport failure", + async () => { + const { controlTransportLayer } = await import("./platform-bun.ts"); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => Response.json({ error: "conflict" }, { status: 409 }), + }); + try { + const port = server.port; + expect(port).toBeTypeOf("number"); + if (port === undefined) return; + const endpoint = { + hostname: "127.0.0.1", + port, + url: `http://127.0.0.1:${port}`, + }; + const exit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.requestStop(endpoint, { + ownershipId: "0".repeat(64), + ownerSessionId: "captured-session", + }), + ).pipe(Effect.provide(controlTransportLayer), Effect.exit), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(ControlStopConflictError); + } + } finally { + await server.stop(true); + } + }, + ); + + (isBun ? test : test.skip)( + "stable client completes the captured stop after a replacement conflict", + async () => { + const { controlTransportLayer } = await import("./platform-bun.ts"); + const ownershipId = "0".repeat(64); + const ownerSessionId = "captured-session"; + const stopBodies: Array = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url); + if (url.pathname === "/owner") + return Response.json({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId, + ownerSessionId: "replacement-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }); + stopBodies.push(await request.text()); + return Response.json({ error: "conflict" }, { status: 409 }); + }, + }); + try { + const port = server.port; + expect(port).toBeTypeOf("number"); + if (port === undefined) return; + const endpoint = { + hostname: "127.0.0.1", + port, + url: `http://127.0.0.1:${port}`, + }; + const exit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + makeControlClient(transport).stopSession(endpoint, ownershipId, ownerSessionId), + ).pipe(Effect.provide(controlTransportLayer), Effect.exit), + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(stopBodies).toEqual([JSON.stringify({ ownershipId, ownerSessionId })]); + } finally { + await server.stop(true); + } + }, + ); + (isBun ? test : test.skip)("classifies an owner status timeout as transport", async () => { const { controlTransportLayer } = await import("./platform-bun.ts"); const server = Bun.serve({ @@ -42,4 +135,282 @@ describe("Bun control transport", () => { await server.stop(true); } }); + + (isBun ? test : test.skip)("classifies a non-HTTP owner response as protocol", async () => { + const { controlTransportLayer } = await import("./platform-bun.ts"); + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + socket.end("not-http\r\n"); + }, + }, + }); + try { + const endpoint = { + hostname: "127.0.0.1", + port: server.port, + url: `http://127.0.0.1:${server.port}`, + }; + const exit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(ControlProtocolError); + } + } finally { + server.stop(true); + } + }); + + (isBun ? test : test.skip)("installs the complete owner app before bind returns", async () => { + const scope = Scope.makeUnsafe(); + const lifecycle = await Effect.runPromise( + SupervisorLifecycle.make({ + ownershipId: "a".repeat(64), + ownerSessionId: "session", + daemonCliVersion: "test", + close: Effect.void, + }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), + ); + const application = { + app: await Effect.runPromise( + makeSupervisorControlApplication(lifecycle).pipe( + Effect.provide(Layer.succeed(Scope.Scope, scope)), + ), + ), + }; + const listener = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.bind( + { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, + () => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId: "a".repeat(64), + ownerSessionId: "session", + state: "starting" as const, + ready: false, + daemonCliVersion: "test", + }), + () => "accepted" as const, + application, + ), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Scope.Scope, scope), + (await import("./platform-bun.ts")).controlTransportLayer, + ), + ), + ), + ); + try { + const address = listener.server.address; + expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); + if (!Predicate.isTagged(address, "TcpAddress")) return; + const response = await fetch(`http://127.0.0.1:${address.port}/owner`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ daemonCliVersion: "test" }); + } finally { + await Effect.runPromise(listener.close); + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + }); + + (isBun ? test : test.skip)("returns a JSON error for malformed /stop requests", async () => { + const scope = Scope.makeUnsafe(); + const endpoint = { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }; + const listener = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.bind( + endpoint, + () => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId: "b".repeat(64), + ownerSessionId: "session", + state: "running" as const, + ready: true, + daemonCliVersion: "test", + }), + () => "accepted" as const, + ), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Scope.Scope, scope), + (await import("./platform-bun.ts")).controlTransportLayer, + ), + ), + ), + ); + try { + const address = listener.server.address; + expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); + if (!Predicate.isTagged(address, "TcpAddress")) return; + const response = await fetch(`http://127.0.0.1:${address.port}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid stop request" }); + } finally { + await Effect.runPromise(listener.close); + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + }); + + (isBun ? test : test.skip)("flushes /stop before graceful Bun close", async () => { + const scope = Scope.makeUnsafe(); + const lifecycle = await Effect.runPromise( + SupervisorLifecycle.make({ + ownershipId: "c".repeat(64), + ownerSessionId: "session", + daemonCliVersion: "test", + close: Effect.void, + }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), + ); + const started = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + await Effect.runPromise( + lifecycle.publishStack( + makeTestStack({ + stop: () => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + }), + ), + ); + const application = { + app: await Effect.runPromise( + makeSupervisorControlApplication(lifecycle).pipe( + Effect.provide(Layer.succeed(Scope.Scope, scope)), + ), + ), + }; + const listener = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.bind( + { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, + () => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId: "c".repeat(64), + ownerSessionId: "session", + state: "running" as const, + ready: true, + daemonCliVersion: "test", + }), + () => "accepted" as const, + application, + ), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Scope.Scope, scope), + (await import("./platform-bun.ts")).controlTransportLayer, + ), + ), + ), + ); + try { + await Effect.runPromise(lifecycle.setClose(listener.close)); + const address = listener.server.address; + expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); + if (!Predicate.isTagged(address, "TcpAddress")) return; + const response = await fetch(`http://127.0.0.1:${address.port}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ownershipId: "c".repeat(64), ownerSessionId: "session" }), + }); + const body = await response.text(); + await Effect.runPromise(Deferred.await(started)); + expect(response.status).toBe(202); + expect(body).toBe(JSON.stringify({ ok: true })); + await Effect.runPromise(Deferred.succeed(release, undefined)); + await Effect.runPromise(lifecycle.awaitShutdown); + await expect(fetch(`http://127.0.0.1:${address.port}/owner`)).rejects.toThrow(); + } finally { + await Effect.runPromise(listener.close); + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + }); + + (isBun ? test : test.skip)( + "completes an immediate fenced stop while the response body is consumed", + async () => { + const scope = Scope.makeUnsafe(); + const ownershipId = "d".repeat(64); + const ownerSessionId = "immediate-stop-session"; + const lifecycle = await Effect.runPromise( + SupervisorLifecycle.make({ + ownershipId, + ownerSessionId, + daemonCliVersion: "test", + }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), + ); + await Effect.runPromise(lifecycle.publishStack(makeTestStack())); + const application = { + app: await Effect.runPromise( + makeSupervisorControlApplication(lifecycle).pipe( + Effect.provide(Layer.succeed(Scope.Scope, scope)), + ), + ), + }; + const listener = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + transport.bind( + { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, + () => ({ + controlProtocol: "supabase-stack-control" as const, + controlProtocolVersion: 1 as const, + ownershipId, + ownerSessionId, + state: "running" as const, + ready: true, + daemonCliVersion: "test", + }), + () => "accepted" as const, + application, + ), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Scope.Scope, scope), + (await import("./platform-bun.ts")).controlTransportLayer, + ), + ), + ), + ); + try { + await Effect.runPromise(lifecycle.setClose(listener.close)); + const address = listener.server.address; + expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); + if (!Predicate.isTagged(address, "TcpAddress")) return; + const endpoint = { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }; + const { controlTransportLayer } = await import("./platform-bun.ts"); + const stopExit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + makeControlClient(transport).stopSession(endpoint, ownershipId, ownerSessionId), + ).pipe(Effect.provide(controlTransportLayer), Effect.exit), + ); + expect(Exit.isSuccess(stopExit)).toBe(true); + await Effect.runPromise(lifecycle.awaitShutdown); + await expect(fetch(`http://127.0.0.1:${address.port}/owner`)).rejects.toThrow(); + } finally { + await Effect.runPromise(listener.close); + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + }, + ); }); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts index c2cfc4b87b..e817c05925 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -2,17 +2,27 @@ import { BunServices } from "@effect/platform-bun"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; import { fileURLToPath } from "node:url"; import { Effect, Exit, Layer, Scope } from "effect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + HttpEffect, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; import type { PlatformFactory } from "./createStack.ts"; +import { readControlOwner } from "./ControlHttpReader.ts"; +import { STACK_RPC_PATH } from "./StackRpc.ts"; import { CONTROL_STATUS_PATH, CONTROL_STOP_PATH, + ControlStopRequestSchema, ControlBindError, - ControlProtocolError, + ControlStopConflictError, ControlTransport, ControlTransportError, type ControlOwnerStatus, + type ControlStopRequest, type ControlEndpoint, + type ControlApplication, } from "./managed/control.ts"; const errorCode = (cause: unknown): string | undefined => { if (typeof cause !== "object" || cause === null) return undefined; @@ -26,8 +36,23 @@ const isDefinitivelyUnreachable = (cause: unknown): boolean => { return code === "ECONNREFUSED" || code === "ConnectionRefused"; }; +const consumeControlResponse = ( + endpoint: ControlEndpoint, + response: Response, +): Effect.Effect => + Effect.tryPromise({ + try: () => + (response.body === null ? Promise.resolve() : response.arrayBuffer()).then(() => undefined), + catch: (cause) => new ControlTransportError({ endpoint, reason: "transport", cause }), + }); + const controlTransport: ControlTransport["Service"] = { - bind: (endpoint: ControlEndpoint, ownerStatus: () => ControlOwnerStatus, onStop: () => void) => + bind: ( + endpoint: ControlEndpoint, + ownerStatus: () => ControlOwnerStatus, + onStop: (request: ControlStopRequest) => "accepted" | "conflict" | "invalid", + application?: ControlApplication, + ) => // Bun.serve starts synchronously inside BunHttpServer.make, before that // constructor yields to register its scope finalizer. Keep only this // acquisition window uninterruptible; request handling and listener close @@ -35,6 +60,106 @@ const controlTransport: ControlTransport["Service"] = { Effect.uninterruptibleMask(() => Effect.gen(function* () { const parentScope = yield* Effect.scope; + if (application !== undefined) { + const webHandler = HttpEffect.toWebHandler(application.app); + const activeRpcRequests = new Set<{ readonly interrupt: () => void }>(); + const handler = async (request: Request): Promise => { + const path = new URL(request.url).pathname; + if (path !== STACK_RPC_PATH && path !== `${STACK_RPC_PATH}/`) { + return webHandler(request); + } + const controller = new AbortController(); + let cancelBody: (() => Promise) | undefined; + const onClientAbort = () => controller.abort(request.signal.reason); + const active = { + interrupt: () => { + controller.abort(); + void cancelBody?.(); + }, + }; + const release = () => { + request.signal.removeEventListener("abort", onClientAbort); + activeRpcRequests.delete(active); + }; + activeRpcRequests.add(active); + request.signal.addEventListener("abort", onClientAbort, { once: true }); + if (request.signal.aborted) onClientAbort(); + try { + const response = await webHandler( + new Request(request, { signal: controller.signal }), + ); + if (response.body === null) { + release(); + return response; + } + const reader = response.body.getReader(); + cancelBody = () => reader.cancel(); + const body = new ReadableStream({ + pull: async (streamController) => { + try { + const next = await reader.read(); + if (next.done) { + release(); + streamController.close(); + } else { + streamController.enqueue(next.value); + } + } catch (cause) { + release(); + streamController.error(cause); + } + }, + cancel: async (reason) => { + release(); + await reader.cancel(reason); + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (cause) { + release(); + throw cause; + } + }; + const server = yield* Effect.try({ + try: () => + Bun.serve({ + hostname: endpoint.hostname, + port: endpoint.port, + idleTimeout: 0, + fetch: handler, + }), + catch: (cause) => + new ControlBindError({ + endpoint, + reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", + cause, + }), + }); + const close = yield* Effect.cached( + Effect.tryPromise({ + try: () => { + const stopped = server.stop(false); + for (const request of activeRpcRequests) request.interrupt(); + return stopped; + }, + catch: (cause) => cause, + }).pipe(Effect.asVoid, Effect.orDie), + ); + const service = HttpServer.make({ + address: { + _tag: "TcpAddress", + hostname: endpoint.hostname, + port: server.port ?? endpoint.port, + }, + serve: () => Effect.void, + }); + yield* Scope.addFinalizer(parentScope, close); + return { server: service, close }; + } const serverScope = yield* Scope.fork(parentScope); const server = yield* BunHttpServer.make({ hostname: endpoint.hostname, @@ -44,15 +169,19 @@ const controlTransport: ControlTransport["Service"] = { // the control connection while the stack continues starting. idleTimeout: 0, disablePreemptiveShutdown: true, - routes: { - [CONTROL_STATUS_PATH]: { - GET: () => - new Response(JSON.stringify(ownerStatus()), { - status: 200, - headers: { "content-type": "application/json" }, - }), - }, - }, + ...(application === undefined + ? { + routes: { + [CONTROL_STATUS_PATH]: { + GET: () => + new Response(JSON.stringify(ownerStatus()), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }, + }, + } + : {}), }).pipe( Scope.provide(serverScope), Effect.catchDefect((cause) => @@ -65,63 +194,66 @@ const controlTransport: ControlTransport["Service"] = { ), ), ); - yield* server - .serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.url === CONTROL_STOP_PATH && request.method === "POST") { - onStop(); - return HttpServerResponse.jsonUnsafe({ ok: true }, { status: 202 }); - } - return HttpServerResponse.jsonUnsafe( - { error: "Stack supervisor is starting" }, - { status: 503 }, - ); - }), - ) - .pipe(Scope.provide(serverScope)); + yield* ( + application === undefined + ? server.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url === CONTROL_STOP_PATH && request.method === "POST") { + return yield* HttpServerRequest.schemaBodyJson(ControlStopRequestSchema).pipe( + Effect.map((stopRequest) => { + const decision = onStop(stopRequest); + const status = + decision === "accepted" ? 202 : decision === "conflict" ? 409 : 400; + return HttpServerResponse.jsonUnsafe( + decision === "accepted" ? { ok: true } : { error: decision }, + { status }, + ); + }), + Effect.catchTags({ + SchemaError: () => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: "Invalid stop request" }, + { status: 400 }, + ), + ), + HttpServerError: () => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: "Invalid stop request" }, + { status: 400 }, + ), + ), + }), + ); + } + return HttpServerResponse.jsonUnsafe( + { error: "Stack supervisor is starting" }, + { status: 503 }, + ); + }), + ) + : Effect.void + ).pipe(Scope.provide(serverScope)); return { server, close: Scope.close(serverScope, Exit.void), }; }), ), - read: (endpoint: ControlEndpoint) => - Effect.tryPromise({ - try: (signal) => - fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { - signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), - // One-shot connection: a pooled keep-alive connection would let a - // closed listener keep answering status probes while the probes - // themselves keep the connection alive. - headers: { connection: "close" }, - }).then((response) => { - if (!response.ok) throw new Error(`Control status request returned ${response.status}`); - return response.json(); - }), - catch: (cause) => { - if ( - cause instanceof SyntaxError || - (cause instanceof Error && cause.message.startsWith("Control status request returned")) - ) { - return new ControlProtocolError({ endpoint, cause }); - } - return new ControlTransportError({ - endpoint, - reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", - cause, - }); - }, - }), - requestStop: (endpoint: ControlEndpoint) => + read: readControlOwner, + requestStop: (endpoint: ControlEndpoint, stopRequest: ControlStopRequest) => Effect.tryPromise({ try: (signal) => fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { method: "POST", signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), - headers: { connection: "close" }, - }).then((response) => { - if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); + headers: { + connection: "close", + "content-type": "application/json", + }, + body: JSON.stringify(stopRequest), }), catch: (cause) => new ControlTransportError({ @@ -129,7 +261,25 @@ const controlTransport: ControlTransport["Service"] = { reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", cause, }), - }), + }).pipe( + Effect.flatMap((response) => + consumeControlResponse(endpoint, response).pipe(Effect.as(response)), + ), + Effect.flatMap( + (response): Effect.Effect => { + if (response.ok) return Effect.void; + if (response.status === 409) + return Effect.fail(new ControlStopConflictError({ endpoint })); + return Effect.fail( + new ControlTransportError({ + endpoint, + reason: "transport", + cause: new Error(`Control stop request returned ${response.status}`), + }), + ); + }, + ), + ), }; export const controlTransportLayer = Layer.succeed(ControlTransport, controlTransport); diff --git a/packages/stack/src/platform-node.integration.test.ts b/packages/stack/src/platform-node.integration.test.ts index e26c6d060f..3c8e5c6149 100644 --- a/packages/stack/src/platform-node.integration.test.ts +++ b/packages/stack/src/platform-node.integration.test.ts @@ -4,8 +4,10 @@ import type { Socket } from "node:net"; import { describe, expect, test } from "vitest"; import { ControlProtocolError, + ControlStopConflictError, ControlTransport, ControlTransportError, + makeControlClient, type ControlEndpoint, } from "./managed/control.ts"; import { controlTransportLayer } from "./platform-node.ts"; @@ -63,10 +65,12 @@ const runRead = (endpoint: ControlEndpoint) => const runStop = (endpoint: ControlEndpoint) => Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => transport.requestStop(endpoint)).pipe( - Effect.provide(controlTransportLayer), - Effect.exit, - ), + Effect.flatMap(ControlTransport, (transport) => + transport.requestStop(endpoint, { + ownershipId: "0".repeat(64), + ownerSessionId: "session", + }), + ).pipe(Effect.provide(controlTransportLayer), Effect.exit), ); const expectTypedFailure = ( @@ -78,6 +82,69 @@ const expectTypedFailure = ( }; describe("Node control transport", () => { + test("classifies a fenced stop conflict distinctly from transport failure", async () => { + const sockets = new Set(); + const server = createServer((_request, response) => { + response.writeHead(409, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "conflict" })); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + const exit = await runStop(endpoint); + expectTypedFailure(exit, ControlStopConflictError); + } finally { + await close(server, sockets); + } + }); + + test("stable client completes the captured stop after a replacement conflict", async () => { + const ownershipId = "0".repeat(64); + const ownerSessionId = "captured-session"; + const stopBodies: Array = []; + const sockets = new Set(); + const server = createServer((request, response) => { + if (request.url === "/owner") { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, + ownershipId, + ownerSessionId: "replacement-session", + state: "running", + ready: true, + daemonCliVersion: "test", + }), + ); + return; + } + request.setEncoding("utf8"); + let body = ""; + request.on("data", (chunk: string) => { + body += chunk; + }); + request.once("end", () => { + stopBodies.push(body); + response.writeHead(409, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "conflict" })); + }); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + const exit = await Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => + makeControlClient(transport).stopSession(endpoint, ownershipId, ownerSessionId), + ).pipe(Effect.provide(controlTransportLayer), Effect.exit), + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(stopBodies).toEqual([JSON.stringify({ ownershipId, ownerSessionId })]); + } finally { + await close(server, sockets); + } + }); + test("maps post-header resets from owner and stop probes to typed failures", async () => { let requestCount = 0; let resolveRequest!: () => void; diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index a9c769ee67..2e1509f06f 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -3,21 +3,25 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { createServer } from "node:http"; import * as Http from "node:http"; import { fileURLToPath } from "node:url"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Scope, Schema } from "effect"; +import { HttpServer } from "effect/unstable/http"; import type { PlatformFactory } from "./createStack.ts"; +import { readControlOwner } from "./ControlHttpReader.ts"; +import { STACK_RPC_PATH } from "./StackRpc.ts"; import { CONTROL_STATUS_PATH, CONTROL_STOP_PATH, + ControlStopRequestSchema, + type ControlStopRequest, ControlBindError, - ControlProtocolError, + ControlStopConflictError, ControlTransport, ControlTransportError, type ControlOwnerStatus, type ControlEndpoint, + type ControlApplication, } from "./managed/control.ts"; -const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; - const errorCode = (cause: unknown): string | undefined => { if (typeof cause !== "object" || cause === null) return undefined; if ("code" in cause && typeof cause.code === "string") return cause.code; @@ -30,53 +34,129 @@ const isDefinitivelyUnreachable = (cause: unknown): boolean => { return code === "ECONNREFUSED"; }; -const closeControlServer = (server: Http.Server): Effect.Effect => +const closeControlServer = ( + server: Http.Server, + interruptRpcRequests: () => void = () => {}, +): Effect.Effect => Effect.callback((resume) => { if (!server.listening) { resume(Effect.void); return Effect.void; } server.close((error) => resume(error === undefined ? Effect.void : Effect.die(error))); + interruptRpcRequests(); return Effect.void; }); -const readError = ( - endpoint: ControlEndpoint, - cause: unknown, -): ControlTransportError | ControlProtocolError => { - if ( - cause instanceof SyntaxError || - (cause instanceof Error && - cause.message === `Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`) || - (cause instanceof Error && cause.message.startsWith("Control status request returned")) - ) { - return new ControlProtocolError({ endpoint, cause }); - } - if (isDefinitivelyUnreachable(cause)) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); - } - return new ControlTransportError({ endpoint, reason: "transport", cause }); -}; - const controlTransport: ControlTransport["Service"] = { - bind: (endpoint: ControlEndpoint, ownerStatus: () => ControlOwnerStatus, onStop: () => void) => { - const rawServer = createServer((request, response) => { - if (request.url === CONTROL_STOP_PATH && request.method === "POST") { - if (rawServer.listenerCount("request") > 1) return; - onStop(); - response.writeHead(202, { "content-type": "application/json" }); - response.end(JSON.stringify({ ok: true })); - return; - } - if (request.url !== CONTROL_STATUS_PATH || request.method !== "GET") { - if (rawServer.listenerCount("request") > 1) return; - response.writeHead(503, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "Stack supervisor is starting" })); - return; - } - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify(ownerStatus())); - }); + bind: ( + endpoint: ControlEndpoint, + ownerStatus: () => ControlOwnerStatus, + onStop: (request: ControlStopRequest) => "accepted" | "conflict" | "invalid", + application?: ControlApplication, + ) => { + const rawServer = createServer( + application === undefined + ? (request, response) => { + if (request.url === CONTROL_STOP_PATH && request.method === "POST") { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.byteLength; + if (size <= 16 * 1024) chunks.push(chunk); + }); + request.once("end", () => { + let decoded: ControlStopRequest | undefined; + try { + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + decoded = Schema.decodeUnknownSync(ControlStopRequestSchema)(parsed); + } catch { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "Invalid stop request" })); + return; + } + const decision = onStop(decoded); + const status = decision === "accepted" ? 202 : decision === "conflict" ? 409 : 400; + response.writeHead(status, { "content-type": "application/json" }); + response.end( + JSON.stringify(decision === "accepted" ? { ok: true } : { error: decision }), + ); + }); + return; + } + if (request.url !== CONTROL_STATUS_PATH || request.method !== "GET") { + response.writeHead(503, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "Stack supervisor is starting" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(ownerStatus())); + } + : undefined, + ); + if (application !== undefined) { + return Effect.gen(function* () { + const scope = yield* Effect.scope; + const activeRpcRequests = new Set<() => void>(); + const interruptRpcRequests = () => { + for (const interrupt of activeRpcRequests) interrupt(); + }; + const close = closeControlServer(rawServer, interruptRpcRequests); + const handler = yield* NodeHttpServer.makeHandler(application.app, { + scope, + }); + rawServer.removeAllListeners("request"); + rawServer.on("request", (request, response) => { + if (request.url === STACK_RPC_PATH || request.url === `${STACK_RPC_PATH}/`) { + const interrupt = () => response.destroy(); + activeRpcRequests.add(interrupt); + const release = () => activeRpcRequests.delete(interrupt); + response.once("finish", release); + response.once("close", release); + } + }); + rawServer.on("request", handler); + yield* Effect.callback((resume) => { + const onError = (cause: Error) => { + rawServer.off("error", onError); + resume(Effect.fail(cause)); + }; + rawServer.once("error", onError); + rawServer.listen({ host: endpoint.hostname, port: endpoint.port }, () => { + rawServer.off("error", onError); + resume(Effect.void); + }); + return Effect.sync(() => { + rawServer.off("error", onError); + if (rawServer.listening) rawServer.close(); + else rawServer.once("listening", () => rawServer.close()); + }); + }).pipe( + Effect.mapError( + (cause) => + new ControlBindError({ + endpoint, + reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", + cause, + }), + ), + ); + const boundAddress = rawServer.address(); + const server = HttpServer.make({ + address: { + _tag: "TcpAddress", + hostname: endpoint.hostname, + port: + typeof boundAddress === "object" && boundAddress !== null + ? boundAddress.port + : endpoint.port, + }, + serve: () => Effect.void, + }); + yield* Scope.addFinalizer(scope, close); + return { server, close }; + }); + } return NodeHttpServer.make(() => rawServer, { host: endpoint.hostname, port: endpoint.port, @@ -93,128 +173,8 @@ const controlTransport: ControlTransport["Service"] = { ), ); }, - read: (endpoint: ControlEndpoint) => - Effect.callback((resume) => { - let response: Http.IncomingMessage | undefined; - let onData: ((chunk: string) => void) | undefined; - let onEnd: (() => void) | undefined; - let onResponseError: ((cause: Error) => void) | undefined; - let onResponseAborted: (() => void) | undefined; - let onResponseClose: (() => void) | undefined; - let settled = false; - let cleanup = () => {}; - let dispose = () => {}; - const finish = (effect: Effect.Effect, shouldDispose = false) => { - if (settled) return; - settled = true; - cleanup(); - if (shouldDispose) dispose(); - resume(effect); - }; - const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); - const request = Http.request( - { - host: "127.0.0.1", - port: endpoint.port, - path: CONTROL_STATUS_PATH, - method: "GET", - // One-shot connection: a pooled keep-alive connection would let a - // closed listener keep answering status probes while the probes - // themselves keep the connection alive. - agent: false, - }, - (incoming) => { - response = incoming; - let body = ""; - let bodyBytes = 0; - let ended = false; - let responseAborted = false; - onData = (chunk) => { - bodyBytes += Buffer.byteLength(chunk, "utf8"); - if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { - finish( - Effect.fail( - new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), - ), - true, - ); - return; - } - body += chunk; - }; - onEnd = () => { - ended = true; - if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { - finish( - Effect.fail( - new Error(`Control status request returned ${incoming.statusCode ?? 500}`), - ), - true, - ); - return; - } - try { - finish(Effect.succeed(JSON.parse(body))); - } catch (cause) { - finish(Effect.fail(cause), true); - } - }; - onResponseError = (cause) => finish(Effect.fail(cause), true); - onResponseAborted = () => { - responseAborted = true; - }; - onResponseClose = () => { - if (responseAborted || !ended) { - finish(Effect.fail(new Error("Control status response closed before end")), true); - } - }; - incoming.setEncoding("utf8"); - incoming.on("data", onData); - incoming.once("end", onEnd); - incoming.once("error", onResponseError); - incoming.once("aborted", onResponseAborted); - incoming.once("close", onResponseClose); - }, - ); - dispose = () => { - response?.destroy(); - request.destroy(); - }; - cleanup = () => { - request.removeListener("error", onRequestError); - if (response !== undefined) { - if (onData !== undefined) response.removeListener("data", onData); - if (onEnd !== undefined) response.removeListener("end", onEnd); - if (onResponseError !== undefined) response.removeListener("error", onResponseError); - if (onResponseAborted !== undefined) { - response.removeListener("aborted", onResponseAborted); - } - if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); - } - }; - request.once("error", onRequestError); - request.end(); - return Effect.callback((resumeCancellation) => { - const onClose = () => { - cleanup(); - resumeCancellation(Effect.void); - }; - settled = true; - request.once("close", onClose); - dispose(); - return Effect.sync(() => { - request.removeListener("close", onClose); - cleanup(); - }); - }); - }).pipe( - Effect.timeoutOrElse({ - duration: 500, - orElse: () => Effect.fail(new Error("Control status request timed out")), - }), - Effect.mapError((cause) => readError(endpoint, cause)), - ), - requestStop: (endpoint: ControlEndpoint) => + read: readControlOwner, + requestStop: (endpoint: ControlEndpoint, stopRequest: ControlStopRequest) => Effect.callback((resume) => { let response: Http.IncomingMessage | undefined; let onEnd: (() => void) | undefined; @@ -246,15 +206,13 @@ const controlTransport: ControlTransport["Service"] = { let responseAborted = false; onEnd = () => { ended = true; - if ((incoming.statusCode ?? 500) >= 200 && (incoming.statusCode ?? 500) < 300) { + const status = incoming.statusCode ?? 500; + if (status >= 200 && status < 300) { finish(Effect.void); + } else if (status === 409) { + finish(Effect.fail(new ControlStopConflictError({ endpoint })), true); } else { - finish( - Effect.fail( - new Error(`Control stop request returned ${incoming.statusCode ?? 500}`), - ), - true, - ); + finish(Effect.fail(new Error(`Control stop request returned ${status}`)), true); } }; onResponseError = (cause) => finish(Effect.fail(cause), true); @@ -289,7 +247,10 @@ const controlTransport: ControlTransport["Service"] = { } }; request.once("error", onRequestError); - request.end(); + const body = JSON.stringify(stopRequest); + request.setHeader("content-type", "application/json"); + request.setHeader("content-length", Buffer.byteLength(body)); + request.end(body); return Effect.callback((resumeCancellation) => { const onClose = () => { cleanup(); @@ -308,13 +269,14 @@ const controlTransport: ControlTransport["Service"] = { duration: 500, orElse: () => Effect.fail(new Error("Control stop request timed out")), }), - Effect.mapError( - (cause) => - new ControlTransportError({ - endpoint, - reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", - cause, - }), + Effect.mapError((cause) => + cause instanceof ControlStopConflictError + ? cause + : new ControlTransportError({ + endpoint, + reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", + cause, + }), ), ), }; diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index 976e62d555..b16dc89106 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -1,5 +1,5 @@ -import { Cause, Context, Effect, Exit, Layer } from "effect"; -import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Context, Effect, Exit, Layer, Schema } from "effect"; +import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { fork, type ChildProcess } from "node:child_process"; import { createServer as createHttpServer } from "node:http"; import { createConnection, createServer } from "node:net"; @@ -20,16 +20,21 @@ import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; import { Stack } from "./Stack.ts"; -import { RemoteStack } from "./RemoteStack.ts"; +import { RemoteStack, updateRemoteLaunch } from "./RemoteStack.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; import { managedDaemonLayer } from "./supervisor.ts"; import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; +import { stopManagedStack } from "./managed/lifecycle.ts"; +import { gitConfigStoreLayer } from "./managed/git.ts"; +import { managedStackManagerLayer } from "./managed/manager.ts"; import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; +import { SupervisorEventSchema, type SupervisorErrorMessage } from "./SupervisorProtocol.ts"; import { git } from "../tests/helpers/git-workspace.ts"; import { watchDirectoryWithRetry } from "../tests/helpers/file-watch.ts"; +import { controlTransportLayer } from "./platform-node.ts"; const childEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-child.ts", import.meta.url), @@ -48,6 +53,7 @@ type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-sta interface ChildHandle { readonly child: ChildProcess; readonly started: Promise; + readonly error: Promise; readonly attachedBeforeReady: Promise; readonly managedStarted: Promise; } @@ -133,6 +139,7 @@ const messageFor = ( overrides: Partial = {}, ): SupervisorStartMessage => ({ type: "start", + cliVersion: "test", stackId: roots.stackId, workspacePath: roots.root, stackName: "default", @@ -168,6 +175,15 @@ const spawnChild = ( readonly environment?: Readonly>; } = {}, ): ChildHandle => { + const stageRoot = join(input.workspacePath, ".supabase", "test-stages"); + mkdirSync(stageRoot, { recursive: true }); + const stageId = randomUUID(); + const attachedBeforeReadyFile = + options.environment?.["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"] ?? + join(stageRoot, `${stageId}-attached-before-ready`); + const managedStartedFile = + options.environment?.["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"] ?? + join(stageRoot, `${stageId}-managed-started`); const child = fork(childEntryPoint, [], { execPath: bunExecutable, execArgv: [], @@ -180,6 +196,8 @@ const spawnChild = ( ? {} : { SUPABASE_STACK_TEST_RUNTIME_MODE: options.testMode }), ...(options.platform === undefined ? {} : { SUPABASE_STACK_TEST_PLATFORM: options.platform }), + SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedBeforeReadyFile, + SUPABASE_STACK_TEST_MANAGED_STARTED_FILE: managedStartedFile, ...options.environment, }, }); @@ -194,17 +212,18 @@ const spawnChild = ( child.off("exit", onExit); }; const onMessage = (value: unknown) => { - if (typeof value !== "object" || value === null) return; - if ("type" in value && value.type === "started" && "endpoint" in value) { + let event: Schema.Schema.Type; + try { + event = Schema.decodeUnknownSync(SupervisorEventSchema)(value); + } catch { + return; + } + if (event.type === "started") { cleanup(); - resolve(value as SupervisorStartedMessage); - } else if ("type" in value && value.type === "error") { + resolve(event); + } else if (event.type === "error") { cleanup(); - reject( - new Error( - `${"message" in value ? String(value.message) : "supervisor failed"}\n${stderr}`, - ), - ); + reject(new Error(`${event.message}\n${stderr}`)); } }; const onError = (cause: Error) => { @@ -219,45 +238,45 @@ const spawnChild = ( child.once("error", onError); child.once("exit", onExit); }); - const waitForStage = (stage: "attached-before-ready" | "managed-started") => - new Promise((resolve, reject) => { - const onMessage = (value: unknown) => { - if ( - typeof value === "object" && - value !== null && - "type" in value && - value.type === "test-stage" && - "stage" in value && - value.stage === stage - ) { - cleanup(); - resolve(); - } - }; - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null) => { + const error = new Promise((resolve, reject) => { + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + const onMessage = (value: unknown) => { + let event: Schema.Schema.Type; + try { + event = Schema.decodeUnknownSync(SupervisorEventSchema)(value); + } catch { + return; + } + if (event.type === "error") { cleanup(); - reject(new Error(`supervisor exited before ${stage} stage (${String(code)})\n${stderr}`)); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); - const attachedBeforeReady = waitForStage("attached-before-ready"); - const managedStarted = waitForStage("managed-started"); + resolve(event); + } + }; + const onError = (cause: Error) => { + cleanup(); + reject(cause); + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`supervisor exited before error event (${String(code)})`)); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }); + const waitForStage = (path: string) => waitForFile(path); + const attachedBeforeReady = waitForStage(attachedBeforeReadyFile); + const managedStarted = waitForStage(managedStartedFile); void started.catch(() => undefined); + void error.catch(() => undefined); void attachedBeforeReady.catch(() => undefined); void managedStarted.catch(() => undefined); child.send(input); - return { child, started, attachedBeforeReady, managedStarted }; + return { child, started, error, attachedBeforeReady, managedStarted }; }; const kill = (child: ChildProcess): Promise => @@ -285,43 +304,96 @@ const fetchOwner = async (endpoint: ControlEndpoint): Promise; }; -const remoteStop = (endpoint: ControlEndpoint): Promise => - Effect.runPromise( +const ownerDescriptor = (owner: Record) => ({ + ownershipId: String(owner.ownershipId), + ownerSessionId: String(owner.ownerSessionId), + controlProtocolVersion: 1 as const, + daemonCliVersion: String(owner.daemonCliVersion), +}); + +const remoteStop = async (endpoint: ControlEndpoint): Promise => { + const owner = ownerDescriptor(await fetchOwner(endpoint)); + await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const context = yield* Layer.build( - RemoteStack.layer(endpoint).pipe(Layer.provide(httpTransportClientLayer)), + RemoteStack.layer(endpoint, { + owner, + cliVersion: owner.daemonCliVersion, + }).pipe(Layer.provide(httpTransportClientLayer)), ); yield* Context.get(context, Stack).stop(); }), ), ); +}; + +const requestOwnerStop = async (endpoint: ControlEndpoint): Promise => { + const owner = ownerDescriptor(await fetchOwner(endpoint)); + return fetch(`${endpoint.url}/stop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ownershipId: owner.ownershipId, + ownerSessionId: owner.ownerSessionId, + }), + }); +}; + +const stopViaManagedFacade = async (roots: { + readonly root: string; + readonly stateRoot: string; +}): Promise => { + await Effect.runPromise( + stopManagedStack({ workspacePath: roots.root }).pipe( + Effect.scoped, + Effect.provide(managedStackManagerLayer({ stateRoot: roots.stateRoot })), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + Effect.provide(httpTransportClientLayer), + ), + ); +}; -const remoteInfo = (endpoint: ControlEndpoint): Promise<{ readonly url: string }> => - Effect.runPromise( +const remoteInfo = async (endpoint: ControlEndpoint): Promise<{ readonly url: string }> => { + const owner = ownerDescriptor(await fetchOwner(endpoint)); + return await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const context = yield* Layer.build( - RemoteStack.layer(endpoint).pipe(Layer.provide(httpTransportClientLayer)), + RemoteStack.layer(endpoint, { + owner, + cliVersion: owner.daemonCliVersion, + }).pipe(Layer.provide(httpTransportClientLayer)), ); return yield* Context.get(context, Stack).getInfo(); }), ), ); +}; const updateLaunch = async ( endpoint: ControlEndpoint, + stackId: string, + owner: SupervisorStartedMessage["owner"], + cliVersion: string, launch: { readonly versions: Record; }, ): Promise => { - const response = await fetch(`${endpoint.url}/managed/launch`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(launch), - }); - expect(response.status).toBe(200); - await response.json(); + await Effect.runPromise( + updateRemoteLaunch( + endpoint, + { + owner, + cliVersion, + }, + stackId, + launch, + ).pipe(Effect.provide(httpTransportClientLayer)), + ); }; const canConnect = (port: number): Promise => @@ -380,10 +452,13 @@ const listenStartingOwner = ( response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ - protocolVersion: 1, + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, ownershipId, + ownerSessionId: "fake-session", state: "starting", ready: false, + daemonCliVersion: "test", }), ); return; @@ -414,10 +489,13 @@ const listenOwnerSequence = ( response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ - protocolVersion: 1, + controlProtocol: "supabase-stack-control", + controlProtocolVersion: 1, ownershipId, + ownerSessionId: "fake-session", state, ready: false, + daemonCliVersion: "test", }), () => { if (closeAfterSequence && reads >= states.length) server.close(); @@ -625,8 +703,6 @@ describe("detached supervisor child journeys", () => { const started = await child.started; const owner = await fetchOwner(started.endpoint); expect(owner).toMatchObject({ state: "running", ready: true }); - const status = await fetch(`${started.endpoint.url}/status`); - expect(status.status).toBe(200); const document = JSON.parse( readFileSync( join(roots.stateRoot, "stacks", `${String(owner.ownershipId)}`, "stack.json"), @@ -645,6 +721,506 @@ describe("detached supervisor child journeys", () => { } }); + test("shuts down the owner when a readiness failure disposes its local runtime", async () => { + const roots = await workspace(); + const input = messageFor(roots); + const child = spawnChild(input, { + environment: { SUPABASE_STACK_TEST_RUNTIME_MODE: "readiness-failure" }, + }); + try { + const started = await child.started; + const owner = ownerDescriptor(await fetchOwner(started.endpoint)); + const readiness = await Effect.runPromiseExit( + Effect.scoped( + Effect.gen(function* () { + const context = yield* Layer.build( + RemoteStack.layer(started.endpoint, { + owner, + cliVersion: input.cliVersion, + }).pipe(Layer.provide(httpTransportClientLayer)), + ); + return yield* Context.get(context, Stack).waitAllReady(); + }), + ), + ); + expect(Exit.isFailure(readiness)).toBe(true); + if (Exit.isFailure(readiness)) { + expect(Cause.squash(readiness.cause)).toMatchObject({ _tag: "StackReadinessError" }); + } + await Promise.race([ + waitForExit(child.child), + new Promise((_, reject) => + setTimeout(() => reject(new Error("supervisor did not shut down after disposal")), 5_000), + ), + ]); + await expect(fetch(`${started.endpoint.url}/owner`)).rejects.toThrow(); + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + } + }); + + test("upgrade restart ignores invocation exclusions while preserving persisted launch", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let restart: ChildHandle | undefined; + let analyticsPortBlocker: ReturnType | undefined; + try { + const oldStarted = await oldOwner.started; + analyticsPortBlocker = createServer(); + const blockedAnalyticsPort = await new Promise((resolve, reject) => { + analyticsPortBlocker?.once("error", reject); + analyticsPortBlocker?.listen(0, "127.0.0.1", () => { + const address = analyticsPortBlocker?.address(); + if (address === null || typeof address === "string" || address === undefined) { + reject(new Error("analytics blocker did not expose an address")); + return; + } + resolve(address.port); + }); + }); + const documentPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), + ); + const before = JSON.parse(readFileSync(documentPath, "utf8")) as { + id: string; + createdAt: string; + launch: { + mode: string; + containerRuntime?: string; + versions: Record; + excludedServices?: ReadonlyArray; + }; + ports: ReadonlyArray<{ key: string; port: number; intent: string }>; + }; + const persistedBefore = { + ...before, + launch: { ...before.launch, excludedServices: ["analytics"] }, + ports: [ + ...before.ports, + { key: "analytics.port", port: blockedAnalyticsPort, intent: "exact" }, + ], + }; + writeFileSync(documentPath, `${JSON.stringify(persistedBefore, null, 2)}\n`); + const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, roots.stackId)); + const sentinel = join(paths.root, "data", "upgrade-sentinel.txt"); + mkdirSync(dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, "preserve-me"); + restart = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + config: { + ...messageFor(roots).config, + analytics: { port: blockedAnalyticsPort }, + vector: {}, + }, + launch: { + mode: "native", + versions: { postgres: "pinned-postgres" }, + excludedServices: ["studio"], + }, + }), + ); + const newStarted = await restart.started; + expect(newStarted.owner.daemonCliVersion).toBe("new"); + expect(analyticsPortBlocker?.listening).toBe(true); + await waitForExit(oldOwner.child); + const staleStop = await fetch(`${newStarted.endpoint.url}/stop`, { + method: "POST", + headers: { "content-type": "application/json", connection: "close" }, + body: JSON.stringify({ + ownershipId: oldStarted.owner.ownershipId, + ownerSessionId: oldStarted.owner.ownerSessionId, + }), + }); + expect(staleStop.status).toBe(409); + expect(await fetchOwner(newStarted.endpoint)).toMatchObject({ + state: "running", + ready: true, + }); + const after = JSON.parse(readFileSync(documentPath, "utf8")) as typeof before; + expect(after.id).toBe(before.id); + expect(after.createdAt).toBe(before.createdAt); + expect(after.launch).toEqual(persistedBefore.launch); + expect(after.ports).toHaveLength(persistedBefore.ports.length); + expect(after.ports).toEqual(expect.arrayContaining(persistedBefore.ports)); + expect(readFileSync(sentinel, "utf8")).toBe("preserve-me"); + await remoteStop(newStarted.endpoint); + await waitForExit(restart.child); + expect(oldStarted.owner.ownerSessionId).not.toBe(newStarted.owner.ownerSessionId); + } finally { + if (analyticsPortBlocker?.listening === true) { + await new Promise((resolve) => analyticsPortBlocker?.close(() => resolve())); + } + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (restart?.child.exitCode === null) await kill(restart.child); + cleanupRoots(roots); + } + }); + + test("explicit stop during the upgrade restart gap prevents a later takeover", async () => { + const roots = await workspace(); + const managedStartedRelease = join(roots.root, "managed-started-release"); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let restart: ChildHandle | undefined; + try { + await oldOwner.started; + restart = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + }), + { + environment: { SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE: managedStartedRelease }, + }, + ); + await restart.managedStarted; + await stopViaManagedFacade(roots); + await waitForExit(oldOwner.child); + expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); + + writeFileSync(managedStartedRelease, "release"); + await expect(restart.started).rejects.toThrow( + /stopped before takeover|Stack was stopped during startup/, + ); + await waitForExit(restart.child); + expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (restart?.child.exitCode === null) await kill(restart.child); + cleanupRoots(roots); + } + }); + + test("an ordinary start rejects an incompatible owner without restarting it", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let contender: ChildHandle | undefined; + try { + const oldStarted = await oldOwner.started; + contender = spawnChild( + messageFor(roots, { + cliVersion: "new", + }), + ); + await expect(contender.started).rejects.toThrow("Daemon CLI version mismatch"); + await expect(contender.error).resolves.toMatchObject({ + errorCode: "DAEMON_UPGRADE_REQUIRED", + state: "running", + ready: true, + }); + expect(oldOwner.child.exitCode).toBeNull(); + await remoteStop(oldStarted.endpoint); + await waitForExit(oldOwner.child); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + cleanupRoots(roots); + } + }); + + test("preserves retryable managed data when upgrade restart startup fails", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let restart: ChildHandle | undefined; + try { + await oldOwner.started; + const documentPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), + ); + const before = JSON.parse(readFileSync(documentPath, "utf8")) as { + readonly id: string; + readonly createdAt: string; + readonly launch: unknown; + }; + const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, roots.stackId)); + const sentinel = join(paths.root, "data", "upgrade-restart-start-failure.txt"); + mkdirSync(dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, "retryable"); + restart = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + }), + { testMode: "fail-after-bind" }, + ); + await expect(restart.started).rejects.toThrow( + /UpgradeRestartError|runtime failed after binding/, + ); + await waitForExit(oldOwner.child); + const after = JSON.parse(readFileSync(documentPath, "utf8")) as { + readonly id: string; + readonly createdAt: string; + readonly lifecycle: string; + readonly launch: unknown; + }; + expect(after.lifecycle).toBe("failed"); + expect(after.id).toBe(before.id); + expect(after.createdAt).toBe(before.createdAt); + expect(after.launch).toEqual(before.launch); + expect(readFileSync(sentinel, "utf8")).toBe("retryable"); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (restart?.child.exitCode === null) await kill(restart.child); + cleanupRoots(roots); + } + }); + + test("concurrent ordinary starts fail without restarting the owner", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let first: ChildHandle | undefined; + let second: ChildHandle | undefined; + try { + await oldOwner.started; + first = spawnChild( + messageFor(roots, { + cliVersion: "new", + }), + ); + second = spawnChild( + messageFor(roots, { + cliVersion: "new", + }), + ); + const results = await Promise.allSettled([first.started, second.started]); + const started = results.filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ); + const rejectionDetails = results.flatMap((result, index) => { + if (result.status === "fulfilled") return []; + const reason = + result.reason instanceof Error + ? (result.reason.stack ?? result.reason.message) + : String(result.reason); + return [`contender ${index + 1} rejected:\n${reason}`]; + }); + expect(rejectionDetails, rejectionDetails.join("\n\n")).toHaveLength(2); + expect( + rejectionDetails.every((detail) => detail.includes("Daemon CLI version mismatch")), + rejectionDetails.join("\n\n"), + ).toBe(true); + expect(results).toHaveLength(2); + expect(started).toHaveLength(0); + expect(await fetchOwner((await oldOwner.started).endpoint)).toMatchObject({ + daemonCliVersion: "old", + state: "running", + ready: true, + }); + await remoteStop((await oldOwner.started).endpoint); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (first?.child.exitCode === null) await kill(first.child); + if (second?.child.exitCode === null) await kill(second.child); + cleanupRoots(roots); + } + }); + + test("ordinary attach joins an explicit upgrade restart and waits for readiness", async () => { + const roots = await workspace(); + const managedStartedRelease = join(roots.root, "upgrade-managed-started-release"); + const attachedReady = join(roots.root, "upgrade-attached-ready"); + const attachedRelease = join(roots.root, "upgrade-attached-release"); + const oldOwner = spawnChild(messageFor(roots, { cliVersion: "old" })); + let restart: ChildHandle | undefined; + let attached: ChildHandle | undefined; + try { + await oldOwner.started; + restart = spawnChild(messageFor(roots, { type: "upgrade-restart", cliVersion: "new" }), { + environment: { SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE: managedStartedRelease }, + }); + await restart.managedStarted; + attached = spawnChild(messageFor(roots, { cliVersion: "new" }), { + environment: { + SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, + SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, + }, + }); + await waitForFile(attachedReady); + let attachedStarted = false; + void attached.started.then( + () => { + attachedStarted = true; + }, + () => undefined, + ); + expect(attachedStarted).toBe(false); + writeFileSync(attachedRelease, "release"); + writeFileSync(managedStartedRelease, "release"); + const started = await Promise.all([restart.started, attached.started]); + expect(started[0]?.owner.ownerSessionId).toBe(started[1]?.owner.ownerSessionId); + expect(started[0]?.owner.daemonCliVersion).toBe("new"); + await remoteStop(started[0]!.endpoint); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (restart?.child.exitCode === null) await kill(restart.child); + if (attached?.child.exitCode === null) await kill(attached.child); + cleanupRoots(roots); + } + }); + + test("upgrade preflight failure leaves the incompatible owner running", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let contender: ChildHandle | undefined; + try { + const oldStarted = await oldOwner.started; + contender = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + config: { ...messageFor(roots).config, port: 65_536 }, + }), + ); + await expect(contender.started).rejects.toThrow(); + expect(oldOwner.child.exitCode).toBeNull(); + expect((await fetchOwner(oldStarted.endpoint)).state).toBe("running"); + await remoteStop(oldStarted.endpoint); + await waitForExit(oldOwner.child); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + cleanupRoots(roots); + } + }); + + test("upgrade restart preserves the target sticky port when the request names a stopped sibling reservation", async () => { + const roots = await workspace(); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + ); + let contender: ChildHandle | undefined; + try { + await oldOwner.started; + const targetPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), + ); + const target = JSON.parse(readFileSync(targetPath, "utf8")) as { + readonly identity: Readonly>; + readonly ports: ReadonlyArray<{ key: string; port: number; intent: string }>; + }; + const api = target.ports.find((assignment) => assignment.key === "api.port"); + if (api === undefined) throw new Error("expected target API assignment"); + const siblingPort = api.port === 65_000 ? 65_001 : 65_000; + const siblingId = "b".repeat(64); + const siblingPath = Effect.runSync( + managedStackDocumentPathEffect(roots.stateRoot, siblingId), + ); + mkdirSync(dirname(siblingPath), { recursive: true }); + writeFileSync( + siblingPath, + `${JSON.stringify( + { + ...target, + id: siblingId, + identity: { ...target.identity, workspaceId: "sibling-workspace" }, + ports: [{ key: "api.port", port: siblingPort, intent: "exact" }], + lifecycle: "stopped", + }, + null, + 2, + )}\n`, + ); + contender = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + config: { ...messageFor(roots).config, port: siblingPort }, + portIntents: { + ...messageFor(roots).portIntents, + document: { api: { port: siblingPort } }, + }, + }), + ); + const restarted = await contender.started; + expect(restarted.owner.daemonCliVersion).toBe("new"); + expect(await fetchOwner(restarted.endpoint)).toMatchObject({ + daemonCliVersion: "new", + state: "running", + ready: true, + }); + const after = JSON.parse(readFileSync(targetPath, "utf8")) as { + readonly ports: ReadonlyArray<{ key: string; port: number; intent: string }>; + }; + expect(after?.ports).toContainEqual({ key: "api.port", port: api.port, intent: api.intent }); + expect(after?.ports).not.toContainEqual( + expect.objectContaining({ key: "api.port", port: siblingPort }), + ); + await remoteStop(restarted.endpoint); + await waitForExit(contender.child); + await waitForExit(oldOwner.child); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + cleanupRoots(roots); + } + }); + + test("stop timeout leaves the old owner and starts no upgrade restart", async () => { + const roots = await workspace(); + const stopBegan = join(roots.root, "stop-began"); + const oldOwner = spawnChild( + messageFor(roots, { + cliVersion: "old", + }), + { + testMode: "hold-stop", + environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, + }, + ); + let restart: ChildHandle | undefined; + try { + const oldStarted = await oldOwner.started; + restart = spawnChild( + messageFor(roots, { + type: "upgrade-restart", + cliVersion: "new", + }), + { environment: { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400" } }, + ); + await waitForFile(stopBegan); + await expect(restart.started).rejects.toThrow(/StopTimeout|timed out/i); + expect(oldOwner.child.exitCode).toBeNull(); + expect(await fetchOwner(oldStarted.endpoint)).toMatchObject({ + state: "stopping", + ready: false, + }); + expect(await canBind(oldStarted.endpoint.port)).toBe(false); + } finally { + if (oldOwner.child.exitCode === null) await kill(oldOwner.child); + if (restart?.child.exitCode === null) await kill(restart.child); + cleanupRoots(roots); + } + }); + test("starts an omitted-mode stack from one detected runtime selection", async () => { const roots = await workspace(); const binDir = mkdtempSync(join(tmpdir(), "sup-stack-runtime-")); @@ -920,7 +1496,7 @@ describe("detached supervisor child journeys", () => { try { const started = await child.started; expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "running", ready: true }); - void fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); + void requestOwnerStop(started.endpoint).catch(() => undefined); await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); } finally { @@ -940,7 +1516,7 @@ describe("detached supervisor child journeys", () => { try { const started = await child.started; let responseSettled = false; - const stopResult = fetch(`${started.endpoint.url}/stop`, { method: "POST" }) + const stopResult = requestOwnerStop(started.endpoint) .then((response) => { responseSettled = true; return response.status; @@ -951,7 +1527,9 @@ describe("detached supervisor child journeys", () => { }); await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); - expect(responseSettled).toBe(false); + // The static control application flushes the fenced 202 before the + // lifecycle transaction closes the listener. + expect(responseSettled).toBe(true); await kill(child.child); await stopResult; } finally { @@ -975,7 +1553,7 @@ describe("detached supervisor child journeys", () => { let contender: ChildHandle | undefined; try { const started = await owner.started; - const stop = fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); + const stop = requestOwnerStop(started.endpoint).catch(() => undefined); await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); @@ -1010,7 +1588,7 @@ describe("detached supervisor child journeys", () => { await waitForFile(ensureReady); expect(existsSync(ensureReady)).toBe(true); const endpoint = await Effect.runPromise(controlEndpoint(roots.stackId)); - const response = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + const response = await requestOwnerStop(endpoint); expect(response.status).toBe(202); writeFileSync(ensureRelease, "release"); await expect(child.started).rejects.toThrow("Stack was stopped during startup"); @@ -1075,7 +1653,7 @@ describe("detached supervisor child journeys", () => { try { const starting = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(starting.id)); - const stop = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + const stop = await requestOwnerStop(endpoint); expect(stop.status).toBe(202); await waitForExit(owner.child); expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); @@ -1147,7 +1725,7 @@ describe("detached supervisor child journeys", () => { const document = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(document.id)); expect(await fetchOwner(endpoint)).toMatchObject({ state: "starting", ready: false }); - const response = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + const response = await requestOwnerStop(endpoint); expect(response.status).toBe(202); await Promise.race([ waitForExit(child.child), @@ -1178,10 +1756,11 @@ describe("detached supervisor child journeys", () => { const document = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(document.id)); expect(await fetchOwner(endpoint)).toMatchObject({ state: "starting", ready: false }); - const stopResponse = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + const stopResponse = await requestOwnerStop(endpoint); expect(stopResponse.status).toBe(202); await waitForExit(owner.child); expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); + await stopViaManagedFacade(roots); fakeOwner = await listenOwnerSequence( endpoint, @@ -1424,7 +2003,9 @@ describe("detached supervisor child journeys", () => { const attached = await later.started; expect(attached.attached).toBe(true); expect(await remoteInfo(attached.endpoint)).toMatchObject({ url: expect.any(String) }); - await updateLaunch(attached.endpoint, { versions: { postgres: "17.6.1" } }); + await updateLaunch(attached.endpoint, roots.stackId, attached.owner, input.cliVersion, { + versions: { postgres: "17.6.1" }, + }); expect(readStackDocument(roots)?.launch).toEqual({ mode: "native", versions: { postgres: "17.6.1" }, diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index 1bd28c9fcd..488e0e7651 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -8,11 +8,13 @@ import { Fiber, Layer, Predicate, + Queue, + Result, Schedule, Scope, Schema, + Stream, } from "effect"; -import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { selectStackRuntime, @@ -20,17 +22,32 @@ import { type StackRuntimeSelection, } from "./ContainerRuntime.ts"; import type { PlatformFactory } from "./createStack.ts"; -import { DaemonServer } from "./DaemonServer.ts"; import { Stack } from "./Stack.ts"; +import { LocalStackLifecycle } from "./LocalStack.ts"; +import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +import type { StackLaunchUpdater } from "./StackRpcHandlers.ts"; +import type { StackLaunchUpdateRpc } from "./StackRpc.ts"; +import { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; +import { + SupervisorErrorEventSchema, + SupervisorStartCommandSchema, + SupervisorStartedEventSchema, +} from "./SupervisorProtocol.ts"; import { foregroundLayer } from "./layers.ts"; import { acquireControl, ControlTransportError, + ControlTransport, type ControlAcquisition, type ControlAttached, - type ControlEndpoint, type ControlOwnership, - type ControlTransport, + type ControlOwnerStatus, + type ControlApplication, + type ControlAddressConflictError, + type ControlBindError, + type ControlProtocolError, + type ControlProtocolMismatchError, + type InvalidControlOwnershipIdError, } from "./managed/control.ts"; import { ManagedStackManager, @@ -38,7 +55,7 @@ import { type ManagedStackStartResult, } from "./managed/manager.ts"; import { - managedStackLaunchInputSchema, + managedStackLaunchUpdateSchema, type ManagedStackLaunch, type ManagedStackLaunchInput, } from "./managed/document.ts"; @@ -48,7 +65,7 @@ import { validateManagedStackName, type ManagedPortIntentDocument } from "./mana import { managedStackPathsEffect } from "./managed/paths.ts"; import { PORT_CATALOG, PORT_FIELDS } from "./PortCatalog.ts"; import { portFieldsForConfigInput } from "./ServicePorts.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; import { dockerContainerName } from "./StackIdentity.ts"; import type { PortLease } from "./PortAllocator.ts"; import { @@ -61,33 +78,34 @@ import { HttpTransportClient } from "./HttpTransportClient.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { terminateChildProcess } from "./terminateChild.ts"; import { dockerForceRemove } from "./cleanup.ts"; - -/** The only message sent across the detached child IPC boundary. */ -export interface SupervisorStartMessage { - readonly type: "start"; - readonly stackId: string; - readonly workspacePath: string; - readonly stackName: string; - readonly stateRoot: string; - readonly config: Readonly>; - readonly portIntents: ManagedPortIntentDocument; - readonly launch?: ManagedStackLaunchInput; -} - -export interface SupervisorStartedMessage { - readonly type: "started"; - readonly endpoint: ControlEndpoint; - readonly attached?: boolean; -} - -interface SupervisorErrorMessage { - readonly type: "error"; - readonly message: string; -} - +import { CONTROL_PROTOCOL_VERSION } from "./DaemonProtocol.ts"; +import { + DaemonUpgradeRequired, + StackBuildError, + StackRpcProtocolError, + StackRpcTransportError, + StopTimeout, + UpgradePreflightError, + UpgradeRestartError, + SupervisorStartError, +} from "./errors.ts"; +import { + restartIncompatibleOwner, + runtimeSelectionForLaunch, + applyNativeDefaults, + UPGRADE_RESTART_PHASE_TIMEOUT, +} from "./SupervisorUpgradeRestart.ts"; +import type { + SupervisorErrorMessage, + SupervisorStartMessage, + SupervisorStartedMessage, +} from "./SupervisorProtocol.ts"; +export type { SupervisorStartMessage, SupervisorStartedMessage }; +export { SupervisorStartError } from "./errors.ts"; type SupervisorMessage = SupervisorStartedMessage | SupervisorErrorMessage; /** Input shape for the public managed launcher. */ export interface ManagedDaemonStartInput { + readonly cliVersion: string; readonly workspacePath: string; readonly stackName: string; readonly stateRoot: string; @@ -96,31 +114,7 @@ export interface ManagedDaemonStartInput { readonly launch?: ManagedStackLaunchInput; } -const supervisorPortIntentSchema = Schema.Struct({ - activeFields: Schema.Array(Schema.Literals(PORT_FIELDS)), - disabledFields: Schema.optionalKey(Schema.Array(Schema.Literals(PORT_FIELDS))), - document: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), -}); - -const supervisorStartMessageSchema = Schema.Struct({ - type: Schema.Literal("start"), - stackId: Schema.String, - workspacePath: Schema.String, - stackName: Schema.String, - stateRoot: Schema.String, - config: Schema.Record(Schema.String, Schema.Unknown), - portIntents: supervisorPortIntentSchema, - launch: Schema.optionalKey(managedStackLaunchInputSchema), -}); - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null; - -const isControlEndpoint = (value: unknown): value is ControlEndpoint => - isRecord(value) && - typeof value.hostname === "string" && - typeof value.port === "number" && - typeof value.url === "string"; +const supervisorStartMessageSchema = SupervisorStartCommandSchema; const isControlOwnership = (value: ControlAcquisition): value is ControlOwnership => Predicate.isTagged(value, "Owned"); @@ -128,6 +122,15 @@ const isControlOwnership = (value: ControlAcquisition): value is ControlOwnershi const isControlAttached = (value: ControlAcquisition): value is ControlAttached => Predicate.isTagged(value, "Attached"); +const startedOwnerDescriptor = (status: ControlOwnerStatus): SupervisorStartedMessage["owner"] => ({ + ownershipId: status.ownershipId, + ownerSessionId: status.ownerSessionId, + controlProtocolVersion: status.controlProtocolVersion, + daemonCliVersion: status.daemonCliVersion, + state: status.state, + ready: status.ready, +}); + const decodeSupervisorStartMessage = ( value: unknown, ): Effect.Effect => @@ -136,6 +139,9 @@ const decodeSupervisorStartMessage = ( ); const causeMessage = (cause: unknown): string => { + if (cause instanceof ControlTransportError) { + return `ControlTransportError(${cause.reason}): ${cause.cause instanceof Error ? cause.cause.message : String(cause.cause)}`; + } if (cause instanceof Error && cause.message.length > 0) return cause.message; if ( typeof cause === "object" && @@ -148,50 +154,9 @@ const causeMessage = (cause: unknown): string => { return typeof cause === "string" ? cause : String(cause); }; -const runtimeSelectionForLaunch = (launch: ManagedStackLaunch): StackRuntimeSelection => - launch.mode === "native" - ? { mode: "native", containerRuntime: null } - : { mode: "docker", containerRuntime: launch.containerRuntime }; - const toDaemonConfig = (value: Readonly>): DaemonConfigInput | undefined => typeof value.cwd === "string" ? { ...value, cwd: value.cwd } : undefined; -/** - * The CLI's omitted-mode defaults are empty service objects, optionally - * decorated with only a pinned version. A managed caller's non-default field - * is an explicit request and must survive fallback so native validation can - * reject it instead of silently changing the requested stack. - */ -const isCatalogDefaultServiceConfig = (value: unknown): boolean => { - if (value === undefined) return true; - if (!isRecord(value)) return false; - return Object.keys(value).every((key) => key === "version"); -}; - -const nativeFallbackConfig = (config: DaemonConfigInput): DaemonConfigInput => { - const servicePolicies: NonNullable = { - ...config.servicePolicies, - }; - - for (const service of SERVICE_NAMES) { - const metadata = SERVICE_CATALOG[service]; - if ( - metadata.runtimeSupport === "docker-only" && - servicePolicies[service] === undefined && - isCatalogDefaultServiceConfig(config[metadata.configKey]) - ) { - servicePolicies[service] = "off"; - } - } - - return { ...config, servicePolicies }; -}; - -export class SupervisorStartError extends Data.TaggedError("SupervisorStartError")<{ - readonly message: string; - readonly reason?: "owner-stopped"; -}> {} - class SupervisorOwnerUnavailableError extends Data.TaggedError("SupervisorOwnerUnavailableError")<{ readonly retry: boolean; readonly detail: string; @@ -204,12 +169,21 @@ class SupervisorOwnerReacquirePending extends Data.TaggedError( const OWNER_STOPPED_AFTER_TAKEOVER = "Attached supervisor owner stopped before takeover"; const STACK_STOPPED_DURING_STARTUP = "Stack was stopped during startup"; -const SUPERVISOR_STARTUP_TIMEOUT = "30 seconds" as const; -const SUPERVISOR_HANDSHAKE_TIMEOUT = "35 seconds" as const; +const SUPERVISOR_STARTUP_TIMEOUT = Duration.seconds(30); +const SUPERVISOR_HANDSHAKE_GRACE = Duration.seconds(5); +const SUPERVISOR_HANDSHAKE_TIMEOUT = Duration.sum( + SUPERVISOR_STARTUP_TIMEOUT, + SUPERVISOR_HANDSHAKE_GRACE, +); +// Preflight, old-session stop, and endpoint reacquisition each have one phase +// budget before the normal child startup budget begins. +const UPGRADE_RESTART_HANDSHAKE_TIMEOUT = Duration.sum( + Duration.times(UPGRADE_RESTART_PHASE_TIMEOUT, 3), + SUPERVISOR_HANDSHAKE_TIMEOUT, +); const awaitOwnerReady = ( acquisition: ControlAttached, - onWaiting: Effect.Effect = Effect.void, ): Effect.Effect< import("./managed/control.ts").ControlOwnerStatus, | SupervisorStartError @@ -229,10 +203,10 @@ const awaitOwnerReady = ( ); }), Effect.retry({ - schedule: Schedule.spaced("25 millis").pipe( - Schedule.tap(({ attempt }) => (attempt === 1 ? onWaiting : Effect.void)), - ), - while: (error) => Predicate.isTagged(error, "SupervisorOwnerUnavailableError") && error.retry, + schedule: Schedule.spaced("25 millis"), + while: (error) => + (Predicate.isTagged(error, "SupervisorOwnerUnavailableError") && error.retry) || + (Predicate.isTagged(error, "ControlTransportError") && error.reason === "transport"), }), Effect.catchTag("SupervisorOwnerUnavailableError", (error) => Effect.fail(new SupervisorStartError({ message: error.detail })), @@ -245,9 +219,7 @@ export interface SupervisorPlatform { readonly runtimeLayer?: (input: { readonly config: ResolvedDaemonConfig; readonly lease: PortLease; - }) => Effect.Effect, unknown, Scope.Scope>; - /** Optional notification hook for an attached owner that is not ready yet. */ - readonly onAttachedBeforeReady?: () => Effect.Effect; + }) => Effect.Effect, unknown, Scope.Scope>; readonly resolutionTimeout?: Duration.Input; readonly managerLayer: ( stateRoot: string, @@ -280,25 +252,151 @@ const receiveStartMessage = (): Effect.Effect => - Effect.callback((resume) => { - if (process.send === undefined || !process.connected) { - resume(Effect.void); + Effect.gen(function* () { + const schema = + message.type === "error" ? SupervisorErrorEventSchema : SupervisorStartedEventSchema; + const encoded = yield* Schema.encodeEffect(schema)(message).pipe( + Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), + ); + yield* Effect.callback((resume) => { + if (process.send === undefined || !process.connected) { + resume(Effect.void); + return Effect.void; + } + try { + process.send(encoded, (error) => + resume( + error === null + ? Effect.void + : Effect.fail(new SupervisorStartError({ message: error.message })), + ), + ); + } catch (cause) { + resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); + } return Effect.void; - } - try { - process.send(message, (error) => - resume( - error === null - ? Effect.void - : Effect.fail(new SupervisorStartError({ message: error.message })), - ), - ); - } catch (cause) { - resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); - } - return Effect.void; + }); }); +const decodeSupervisorEvent = ( + value: unknown, +): Effect.Effect< + SupervisorStartedMessage, + | SupervisorStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout +> => decodeSupervisorStartedOrError(value); + +const decodeSupervisorStartedOrError = ( + value: unknown, +): Effect.Effect< + SupervisorStartedMessage, + | SupervisorStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout +> => + Schema.decodeUnknownEffect(SupervisorStartedEventSchema)(value).pipe( + Effect.map((event): SupervisorStartedMessage => ({ + type: "started", + endpoint: event.endpoint, + owner: event.owner, + ...(event.attached === undefined ? {} : { attached: event.attached }), + })), + Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), + Effect.catch(() => + Schema.decodeUnknownEffect(SupervisorErrorEventSchema)(value).pipe( + Effect.flatMap( + ( + event, + ): Effect.Effect< + never, + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout + | SupervisorStartError + > => { + if ( + event.errorCode === "DAEMON_UPGRADE_REQUIRED" && + event.stackId !== undefined && + event.oldCliVersion !== undefined && + event.newCliVersion !== undefined && + event.state !== undefined && + event.ready !== undefined + ) { + return Effect.fail( + new DaemonUpgradeRequired({ + stackId: event.stackId, + oldCliVersion: event.oldCliVersion, + newCliVersion: event.newCliVersion, + state: event.state, + ready: event.ready, + }), + ); + } + if ( + event.errorCode === "UPGRADE_PREFLIGHT" && + event.stackId !== undefined && + event.oldCliVersion !== undefined && + event.newCliVersion !== undefined && + event.detail !== undefined + ) { + return Effect.fail( + new UpgradePreflightError({ + stackId: event.stackId, + oldCliVersion: event.oldCliVersion, + newCliVersion: event.newCliVersion, + detail: event.detail, + }), + ); + } + if ( + event.errorCode === "UPGRADE_RESTART" && + event.stackId !== undefined && + event.newCliVersion !== undefined && + event.detail !== undefined + ) { + return Effect.fail( + new UpgradeRestartError({ + stackId: event.stackId, + newCliVersion: event.newCliVersion, + detail: event.detail, + }), + ); + } + if ( + event.errorCode === "STOP_TIMEOUT" && + event.endpoint !== undefined && + event.ownerSessionId !== undefined + ) { + return Effect.fail( + new StopTimeout({ + endpoint: event.endpoint, + ownerSessionId: event.ownerSessionId, + ...(event.detail === undefined ? {} : { lastState: event.detail }), + }), + ); + } + return Effect.fail(new SupervisorStartError({ message: event.message })); + }, + ), + Effect.mapError((cause) => + cause instanceof DaemonUpgradeRequired || + cause instanceof UpgradePreflightError || + cause instanceof UpgradeRestartError || + cause instanceof StopTimeout || + cause instanceof SupervisorStartError + ? cause + : new SupervisorStartError({ message: causeMessage(cause) }), + ), + ), + ), + ); + const waitForSignal = (): Effect.Effect<"SIGINT" | "SIGTERM"> => Effect.callback((resume) => { const cleanup = () => { @@ -318,6 +416,54 @@ const waitForSignal = (): Effect.Effect<"SIGINT" | "SIGTERM"> => return Effect.sync(cleanup); }); +const supervisorErrorMessage = (cause: Cause.Cause): SupervisorErrorMessage => { + const error = Cause.squash(cause); + if (error instanceof DaemonUpgradeRequired) { + return { + type: "error", + message: `Daemon CLI version mismatch for ${error.stackId}: expected ${error.newCliVersion}, observed ${error.oldCliVersion}`, + errorCode: "DAEMON_UPGRADE_REQUIRED", + stackId: error.stackId, + oldCliVersion: error.oldCliVersion, + newCliVersion: error.newCliVersion, + state: error.state, + ready: error.ready, + }; + } + if (error instanceof UpgradeRestartError) { + return { + type: "error", + message: `UpgradeRestartError: ${error.detail}`, + errorCode: "UPGRADE_RESTART", + stackId: error.stackId, + newCliVersion: error.newCliVersion, + detail: error.detail, + }; + } + if (error instanceof UpgradePreflightError) { + return { + type: "error", + message: `UpgradePreflightError: ${error.detail}`, + errorCode: "UPGRADE_PREFLIGHT", + stackId: error.stackId, + oldCliVersion: error.oldCliVersion, + newCliVersion: error.newCliVersion, + detail: error.detail, + }; + } + if (error instanceof StopTimeout) { + return { + type: "error", + message: `StopTimeout: ${error.endpoint}`, + errorCode: "STOP_TIMEOUT", + detail: error.lastState, + endpoint: error.endpoint, + ownerSessionId: error.ownerSessionId, + }; + } + return { type: "error", message: causeMessage(error) }; +}; + const startDaemon = (input: { readonly config: ResolvedDaemonConfig; readonly lease: PortLease; @@ -328,7 +474,10 @@ const startDaemon = (input: { launch: import("./managed/document.ts").ManagedStackLaunchUpdate, ) => Effect.Effect; }): Effect.Effect< - { readonly daemon: DaemonServer["Service"] }, + { + readonly stack: Stack["Service"]; + readonly localLifecycle: LocalStackLifecycle["Service"]; + }, unknown, import("effect").FileSystem.FileSystem | import("effect").Path.Path | Scope.Scope > => @@ -339,24 +488,8 @@ const startDaemon = (input: { : yield* input.platform.runtimeLayer({ config: input.config, lease: input.lease }); const appServices = yield* Layer.buildWithScope(appLayer, input.scope); const localStack = Context.get(appServices, Stack); - const daemonLayer = DaemonServer.layerWithShutdown( - Effect.gen(function* () { - yield* input.ownership.setState("stopping", false); - yield* localStack.stop(); - }), - input.ownership.ownerStatus, - { - includeOwnerRoute: false, - stopOnShutdown: false, - ...(input.launchUpdate === undefined ? {} : { launchUpdate: input.launchUpdate }), - }, - ).pipe( - Layer.provide(Layer.succeed(Stack, localStack)), - Layer.provide(Layer.succeed(HttpServer.HttpServer, input.ownership.server)), - ); - const daemonServices = yield* Layer.buildWithScope(daemonLayer, input.scope); - const daemon = Context.get(daemonServices, DaemonServer); - return { daemon }; + const localLifecycle = Context.get(appServices, LocalStackLifecycle); + return { stack: localStack, localLifecycle }; }); const runManaged = ( @@ -375,7 +508,11 @@ const runManaged = ( let owner: ControlOwnership | undefined; let managerService: ManagedStackManager["Service"] | undefined; let claimedStack = false; + let lifecycle: SupervisorLifecycle["Service"] | undefined; + let upgradeRestarting = false; + let oldSessionEnded = false; return Effect.gen(function* () { + const controlTransport = yield* ControlTransport; yield* validateManagedStackName(input.stackName); const configInput = toDaemonConfig(input.config); if (configInput === undefined) { @@ -383,19 +520,77 @@ const runManaged = ( new SupervisorStartError({ message: "Supervisor config is missing cwd" }), ); } - const initialAcquisition = yield* acquireControl({ stackId: input.stackId }); - if (isControlOwnership(initialAcquisition)) owner = initialAcquisition; + const supervisorLifecycle = yield* SupervisorLifecycle.make({ + ownershipId: input.stackId, + ownerSessionId: crypto.randomUUID(), + daemonCliVersion: input.cliVersion, + }); + lifecycle = supervisorLifecycle; + const launchUpdater: StackLaunchUpdater = { + update: (stackId: string, launch: StackLaunchUpdateRpc) => { + const currentOwner = owner; + const currentManager = managerService; + if (currentOwner === undefined || currentManager === undefined) { + return Effect.fail( + new StackBuildError({ detail: "Managed launch updates require an owned supervisor" }), + ); + } + return Schema.decodeUnknownEffect(managedStackLaunchUpdateSchema)(launch).pipe( + Effect.mapError((cause) => new StackBuildError({ detail: causeMessage(cause) })), + Effect.flatMap((decoded) => + currentManager.updateLaunch(currentOwner, { stackId, launch: decoded }), + ), + Effect.mapError((cause) => new StackBuildError({ detail: causeMessage(cause) })), + Effect.asVoid, + ); + }, + }; + const controlApplication: ControlApplication = { + app: yield* makeSupervisorControlApplication(supervisorLifecycle, launchUpdater), + }; + let initialAcquisition = yield* acquireControl({ + stackId: input.stackId, + initialStatus: yield* supervisorLifecycle.currentStatus, + application: controlApplication, + }); + if (isControlOwnership(initialAcquisition)) { + owner = initialAcquisition; + } const manager = yield* ManagedStackManager.pipe( Effect.provide(platform.managerLayer(input.stateRoot)), + Effect.catchCause((cause) => + supervisorLifecycle + .setClose(owner?.close ?? Effect.void) + .pipe(Effect.andThen(Effect.failCause(cause))), + ), ); managerService = manager; + const registerOwnerClose = (ownedOwner: ControlOwnership) => + supervisorLifecycle.setClose( + Effect.ensuring( + manager.inspectStack(input.stackId).pipe( + Effect.flatMap((current) => + current?.lifecycle === "starting" || current?.lifecycle === "running" + ? manager + .recordLifecycle(ownedOwner, { + stackId: input.stackId, + lifecycle: "stopped", + }) + .pipe(Effect.asVoid) + : Effect.void, + ), + ), + ownedOwner.close, + ), + ); + if (owner !== undefined) yield* registerOwnerClose(owner); const discovered = manager .ensureWorkspace(input.workspacePath) .pipe(Effect.map((discovery) => ({ _tag: "discovered" as const, discovery }))); const discoveryResult = yield* isControlOwnership(initialAcquisition) ? Effect.raceFirst( discovered, - initialAcquisition.stopRequested.pipe(Effect.as({ _tag: "stopped" as const })), + supervisorLifecycle.awaitShutdown.pipe(Effect.as({ _tag: "stopped" as const })), ) : discovered; if (Predicate.isTagged(discoveryResult, "stopped")) { @@ -409,6 +604,7 @@ const runManaged = ( ); } const requestedMode = configInput.mode ?? input.launch?.mode; + let effectiveConfigInput = configInput; const existing = yield* manager.inspectStack(stackId); const persistedRuntime: StackRuntimeSelection | undefined = existing === undefined ? undefined : runtimeSelectionForLaunch(existing.launch); @@ -425,68 +621,110 @@ const runManaged = ( ); } let attachedOwnerWasStopping = false; - const reacquireAfterDeath = (): Effect.Effect => - manager.acquireControl(stackId).pipe( - Effect.flatMap((candidate): Effect.Effect => { - if (isControlOwnership(candidate)) return Effect.succeed(candidate); - return candidate.ownerStatus.pipe( - Effect.flatMap((status): Effect.Effect => - status.state === "starting" - ? Effect.fail(new SupervisorOwnerReacquirePending()) - : Effect.fail( - new SupervisorStartError({ - message: `Attached supervisor owner is ${status.state} after disconnect`, - }), - ), - ), - Effect.catch((error) => - error instanceof ControlTransportError - ? Effect.fail(new SupervisorOwnerReacquirePending()) - : Effect.fail(error), - ), - ); - }), - Effect.retry({ - schedule: Schedule.spaced("25 millis"), - while: (error) => error instanceof SupervisorOwnerReacquirePending, - }), - ); - const attachedResolution = isControlAttached(initialAcquisition) - ? initialAcquisition.ownerStatus.pipe( - Effect.tap((status) => - Effect.sync(() => { - attachedOwnerWasStopping = status.state === "stopping"; + const initiallyAttached = isControlAttached(initialAcquisition); + const awaitAttachedOwnerReady = (acquisition: ControlAttached) => + awaitOwnerReady(acquisition).pipe( + Effect.timeout(platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new SupervisorStartError({ + message: "Timed out resolving attached supervisor owner", }), ), - Effect.flatMap((status) => - status.state === "running" && status.ready - ? Effect.succeed(status) - : awaitOwnerReady( - initialAcquisition, - platform.onAttachedBeforeReady?.() ?? Effect.void, - ), - ), - Effect.as(initialAcquisition), - Effect.catch((error) => - error instanceof ControlTransportError ? reacquireAfterDeath() : Effect.fail(error), + ), + ); + const reacquireAfterDeath = (): Effect.Effect< + ControlAcquisition, + | SupervisorOwnerReacquirePending + | ControlAddressConflictError + | ControlBindError + | ControlTransportError + | ControlProtocolError + | ControlProtocolMismatchError + | InvalidControlOwnershipIdError, + Scope.Scope + > => + Effect.gen(function* () { + const status = yield* supervisorLifecycle.currentStatus; + return yield* acquireControl({ + stackId, + initialStatus: status, + application: controlApplication, + }).pipe( + Effect.provideService(ControlTransport, controlTransport), + Effect.flatMap((candidate) => { + if (isControlOwnership(candidate)) return Effect.succeed(candidate); + if (candidate.observedStatus.daemonCliVersion !== input.cliVersion) { + return Effect.fail(new SupervisorOwnerReacquirePending()); + } + return awaitAttachedOwnerReady(candidate).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "SupervisorStartError") || + (Predicate.isTagged(error, "ControlTransportError") && + error.reason === "unreachable") + ? new SupervisorOwnerReacquirePending() + : error, + ), + Effect.as(candidate), + ); + }), + Effect.retry({ + schedule: Schedule.spaced("25 millis"), + while: (error) => error instanceof SupervisorOwnerReacquirePending, + }), + ); + }); + if (isControlAttached(initialAcquisition)) { + const attachedStatus = initialAcquisition.observedStatus; + attachedOwnerWasStopping = attachedStatus.state === "stopping"; + if (attachedStatus.daemonCliVersion !== input.cliVersion) { + if (input.type !== "upgrade-restart") { + return yield* Effect.fail( + new DaemonUpgradeRequired({ + stackId, + oldCliVersion: attachedStatus.daemonCliVersion, + newCliVersion: input.cliVersion, + state: attachedStatus.state, + ready: attachedStatus.ready, + }), + ); + } + upgradeRestarting = true; + const restart = yield* restartIncompatibleOwner({ + stackId, + oldOwner: initialAcquisition, + input, + configInput, + manager, + controlTransport, + resolutionTimeout: platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT, + reacquire: () => + reacquireAfterDeath().pipe( + Effect.catchTag("SupervisorOwnerReacquirePending", () => Effect.never), + ), + }); + oldSessionEnded = restart.oldSessionEnded; + attachedOwnerWasStopping = restart.attachedOwnerWasStopping; + effectiveConfigInput = restart.effectiveConfigInput; + initialAcquisition = restart.acquisition; + } else { + yield* awaitAttachedOwnerReady(initialAcquisition).pipe( + Effect.catchTag("ControlTransportError", (error) => + error.reason === "unreachable" + ? reacquireAfterDeath().pipe( + Effect.tap((next) => + Effect.sync(() => { + initialAcquisition = next; + }), + ), + Effect.asVoid, + ) + : Effect.fail(error), ), - ) - : Effect.succeed(initialAcquisition); - const acquisition = yield* attachedResolution.pipe( - Effect.timeout(platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT), - Effect.catch((error) => - typeof error === "object" && - error !== null && - "_tag" in error && - Predicate.isTagged(error, "TimeoutError") - ? Effect.fail( - new SupervisorStartError({ - message: "Timed out resolving attached supervisor owner", - }), - ) - : Effect.fail(error), - ), - ); + ); + } + } + const acquisition = initialAcquisition; if (isControlAttached(initialAcquisition)) { const revalidated = yield* manager.ensureWorkspace(input.workspacePath); const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); @@ -518,15 +756,25 @@ const runManaged = ( }), ); } - yield* sendMessage({ type: "started", endpoint: acquisition.endpoint, attached: true }); + const attachedStatus = yield* acquisition.ownerStatus; + yield* sendMessage({ + type: "started", + endpoint: acquisition.endpoint, + owner: startedOwnerDescriptor(attachedStatus), + attached: true, + }); process.disconnect?.(); return; } const ownership = acquisition; owner = ownership; + // An upgrade restart starts attached and only acquires this new + // owner after the old session has ended. Register the close capability + // at that handoff before startup can publish or accept /stop. + yield* registerOwnerClose(ownership); const ownedExisting = yield* manager.inspectStack(stackId); - if (isControlAttached(initialAcquisition) && !attachedOwnerWasStopping) { - if (ownedExisting?.lifecycle === "stopped") { + if (initiallyAttached && !attachedOwnerWasStopping) { + if (ownedExisting?.lifecycle === "stopped" && ownedExisting.stopIntent === "explicit") { yield* ownership.close; return yield* Effect.fail( new SupervisorStartError({ @@ -555,8 +803,8 @@ const runManaged = ( : yield* validateStackRuntime(ownedPersistedRuntime); const runtimeConfigInput = runtime.mode === "native" && requestedMode === undefined - ? nativeFallbackConfig(configInput) - : configInput; + ? applyNativeDefaults(effectiveConfigInput) + : effectiveConfigInput; const activeFields = portFieldsForConfigInput({ ...runtimeConfigInput, mode: runtime.mode }); const activeFieldSet = new Set(activeFields); const portIntents: ManagedPortIntentDocument = { @@ -569,7 +817,10 @@ const runManaged = ( // Validate policies and explicit ports before manager.startStack writes // `starting` or acquires the managed lease. yield* portRequestsForConfig(runtimeConfigInput, { runtime }); - const launchInput = input.launch ?? { versions: {} }; + const launchInput = + upgradeRestarting && ownedExisting !== undefined + ? ownedExisting.launch + : (input.launch ?? { versions: {} }); const launch: ManagedStackLaunch = runtime.mode === "native" ? { ...launchInput, mode: "native" } @@ -596,6 +847,7 @@ const runManaged = ( ownership, lifecycle: "starting", launch, + preservePersistedPorts: upgradeRestarting, }); claimedStack = true; const managedPaths = yield* managedStackPathsEffect(input.stateRoot, started.stack.id); @@ -629,18 +881,31 @@ const runManaged = ( .updateLaunch(ownership, { stackId: started.stack.id, launch }) .pipe(Effect.asVoid), }); + if (lifecycle !== undefined) yield* lifecycle.publishStack(built.stack); + if (lifecycle !== undefined) { + yield* Effect.forkIn( + built.localLifecycle.awaitDisposed.pipe( + Effect.andThen(lifecycle.fail("Local stack disposed unexpectedly")), + Effect.andThen(lifecycle.requestShutdown("dispose")), + Effect.catchCause(() => Effect.void), + ), + scope, + ); + } yield* manager.recordLifecycle(ownership, { stackId: started.stack.id, lifecycle: "running", runtime: { pid: process.pid, controlEndpoint: ownership.endpoint.url, - protocolVersion: 1, + protocolVersion: CONTROL_PROTOCOL_VERSION, }, }); + const publishedStatus = yield* supervisorLifecycle.currentStatus; yield* sendMessage({ type: "started", endpoint: ownership.endpoint, + owner: startedOwnerDescriptor(publishedStatus), attached: false, }); process.disconnect?.(); @@ -648,43 +913,85 @@ const runManaged = ( }); const startupResult = yield* Effect.raceFirst( startup.pipe(Effect.map((result) => ({ _tag: "started" as const, ...result }))), - ownership.stopRequested.pipe(Effect.as({ _tag: "stopped" as const })), + (lifecycle?.awaitShutdown ?? Effect.never).pipe(Effect.as({ _tag: "stopped" as const })), ); if (Predicate.isTagged(startupResult, "stopped")) { - const current = yield* manager.inspectStack(stackId); - if (current !== undefined) { - yield* manager.recordLifecycle(ownership, { stackId, lifecycle: "stopped" }); - } yield* sendMessage({ type: "error", message: STACK_STOPPED_DURING_STARTUP }); return; } - const { started, built } = startupResult; const shutdown = yield* Effect.raceFirst( - Effect.raceFirst(waitForSignal(), built.daemon.awaitShutdown).pipe( - Effect.as("shutdown" as const), - ), - ownership.stopRequested.pipe(Effect.as("requested" as const)), + waitForSignal().pipe(Effect.as("signal" as const)), + (lifecycle?.awaitShutdown ?? Effect.never).pipe(Effect.as("shutdown" as const)), ); - if (shutdown === "requested") yield* built.daemon.beginShutdown; - yield* manager.recordLifecycle(ownership, { stackId: started.stack.id, lifecycle: "stopped" }); + if (lifecycle !== undefined && shutdown === "signal") { + yield* lifecycle.requestShutdown("signal"); + } }).pipe( Effect.catchCause((cause) => { - const failure = Cause.squash(cause); + const typed = Cause.findError(cause); + const failure = Result.isSuccess(typed) ? typed.success : undefined; if (failure instanceof SupervisorStartError && failure.reason === "owner-stopped") { return Effect.failCause(cause); } + const canMapRestart = + upgradeRestarting && + oldSessionEnded && + failure !== undefined && + !Cause.hasDies(cause) && + !Cause.hasInterrupts(cause); + const failureDetail = failure === undefined ? causeMessage(cause) : causeMessage(failure); + const finalizeFailure = + lifecycle === undefined + ? Effect.void + : lifecycle + .setClose(owner?.close ?? Effect.void) + .pipe( + Effect.andThen(lifecycle.fail(failureDetail)), + Effect.andThen(lifecycle.requestShutdown("startup-failure")), + ); if (!claimedStack || owner === undefined || managerService === undefined) { - return Effect.failCause(cause); + return finalizeFailure.pipe( + Effect.andThen( + canMapRestart + ? Effect.fail( + new UpgradeRestartError({ + stackId: input.stackId, + newCliVersion: input.cliVersion, + detail: failureDetail, + }), + ) + : Effect.failCause(cause), + ), + ); } return managerService .recordLifecycle(owner, { stackId: owner.ownershipId, lifecycle: "failed", }) + .pipe(Effect.andThen(finalizeFailure)) .pipe( Effect.matchCauseEffect({ - onFailure: () => Effect.failCause(cause), - onSuccess: () => Effect.failCause(cause), + onFailure: () => + canMapRestart + ? Effect.fail( + new UpgradeRestartError({ + stackId: input.stackId, + newCliVersion: input.cliVersion, + detail: causeMessage(failure), + }), + ) + : Effect.failCause(cause), + onSuccess: () => + canMapRestart + ? Effect.fail( + new UpgradeRestartError({ + stackId: input.stackId, + newCliVersion: input.cliVersion, + detail: causeMessage(failure), + }), + ) + : Effect.failCause(cause), }), ); }), @@ -708,9 +1015,7 @@ export const runSupervisor = ( const input = yield* receiveStartMessage(); yield* Effect.matchCauseEffect(runManaged(input, platform, scope), { onFailure: (cause) => - sendMessage({ type: "error", message: causeMessage(Cause.squash(cause)) }).pipe( - Effect.andThen(Effect.failCause(cause)), - ), + sendMessage(supervisorErrorMessage(cause)).pipe(Effect.andThen(Effect.failCause(cause))), onSuccess: Effect.succeed, }); }), @@ -718,12 +1023,19 @@ export const runSupervisor = ( const forkSupervisor = (entryPoint: string): Effect.Effect => Effect.try({ - try: () => - fork(entryPoint, [], { + try: () => { + // A compiled Bun executable cannot execute the source daemon path from + // Bun's virtual filesystem. Keep that path as the fork module so Bun + // installs its IPC channel, but re-execute the current compiled binary + // and let the entrypoint's daemon marker select runBunDaemon(). + const compiledBunEntryPoint = /[\\/]\$bunfs[\\/]/.test(entryPoint); + return fork(entryPoint, [], { stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true, + ...(compiledBunEntryPoint ? { execPath: process.execPath } : {}), env: { ...process.env, SUPABASE_STACK_RUN_DAEMON: "1" }, - }), + }); + }, catch: (cause) => new SupervisorStartError({ message: `Failed to fork supervisor: ${causeMessage(cause)}` }), }); @@ -732,69 +1044,115 @@ const sendStart = ( child: ChildProcess, message: SupervisorStartMessage, ): Effect.Effect => - Effect.callback((resume) => { - try { - child.send(message, (error) => - resume( - error === null - ? Effect.void - : Effect.fail(new SupervisorStartError({ message: error.message })), - ), - ); - } catch (cause) { - resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); - } - return Effect.void; + Effect.gen(function* () { + const config = yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))( + message.config, + ).pipe(Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) }))); + const document = + message.portIntents.document === undefined + ? undefined + : yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))( + message.portIntents.document, + ).pipe( + Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), + ); + const encoded = yield* Schema.encodeEffect(SupervisorStartCommandSchema)({ + ...message, + config, + portIntents: { + activeFields: message.portIntents.activeFields, + ...(message.portIntents.disabledFields === undefined + ? {} + : { disabledFields: message.portIntents.disabledFields }), + ...(document === undefined ? {} : { document }), + }, + }).pipe(Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) }))); + yield* Effect.callback((resume) => { + try { + child.send(encoded, (error) => + resume( + error === null + ? Effect.void + : Effect.fail(new SupervisorStartError({ message: error.message })), + ), + ); + } catch (cause) { + resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); + } + return Effect.void; + }); }); const waitForStarted = ( child: ChildProcess, -): Effect.Effect => - Effect.callback((resume) => { - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onMessage = (value: unknown) => { - cleanup(); - if (isRecord(value) && value.type === "started" && isControlEndpoint(value.endpoint)) { - resume( - Effect.succeed({ - type: "started", - endpoint: value.endpoint, - ...(value.attached === true ? { attached: true } : {}), +): Effect.Effect< + SupervisorStartedMessage, + | SupervisorStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout +> => + Effect.scoped( + Effect.gen(function* () { + const events = Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + let finished = false; + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + const fail = (error: SupervisorStartError) => { + if (finished) return; + finished = true; + Queue.failCauseUnsafe(queue, Cause.fail(error)); + cleanup(); + }; + const onMessage = (value: unknown) => Queue.offerUnsafe(queue, value); + const onError = (cause: Error) => + fail(new SupervisorStartError({ message: cause.message })); + const onExit = (code: number | null) => + fail(new SupervisorStartError({ message: `Supervisor exited with code ${code}` })); + child.on("message", onMessage); + child.on("error", onError); + child.on("exit", onExit); + return cleanup; }), + (cleanup) => Effect.sync(cleanup), + ), + ); + const pull = yield* Stream.toPull(events); + while (true) { + const chunk = yield* pull.pipe( + Effect.mapError((error) => + error instanceof SupervisorStartError + ? error + : new SupervisorStartError({ message: "Supervisor event stream ended" }), + ), ); - } else if (isRecord(value) && value.type === "error" && typeof value.message === "string") { - resume(Effect.fail(new SupervisorStartError({ message: value.message }))); - } else { - resume(Effect.fail(new SupervisorStartError({ message: "Invalid supervisor response" }))); + const event = yield* decodeSupervisorEvent(chunk[0]); + return event; } - }; - const onError = (cause: Error) => { - cleanup(); - resume(Effect.fail(new SupervisorStartError({ message: cause.message }))); - }; - const onExit = (code: number | null) => { - cleanup(); - resume( - Effect.fail(new SupervisorStartError({ message: `Supervisor exited with code ${code}` })), - ); - }; - child.on("message", onMessage); - child.on("error", onError); - child.on("exit", onExit); - return Effect.sync(cleanup); - }); + }), + ); /** Parent-side launcher for the managed supervisor. */ export const supervisorLayer = ( input: SupervisorStartMessage, entryPoint: string, ): Effect.Effect< - Layer.Layer, - SupervisorStartError | import("./managed/model.ts").InvalidManagedStackNameError, + Layer.Layer< + import("./Stack.ts").Stack, + DaemonUpgradeRequired | StackRpcProtocolError | StackRpcTransportError + >, + | SupervisorStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout + | import("./managed/model.ts").InvalidManagedStackNameError, HttpTransportClient > => Effect.gen(function* () { @@ -804,7 +1162,11 @@ export const supervisorLayer = ( let detached = false; return yield* Effect.gen(function* () { const responseFiber = yield* waitForStarted(child).pipe( - Effect.timeout(SUPERVISOR_HANDSHAKE_TIMEOUT), + Effect.timeout( + input.type === "upgrade-restart" + ? UPGRADE_RESTART_HANDSHAKE_TIMEOUT + : SUPERVISOR_HANDSHAKE_TIMEOUT, + ), Effect.catchTag("TimeoutError", () => Effect.fail( new SupervisorStartError({ message: "Timed out waiting for supervisor startup" }), @@ -814,11 +1176,24 @@ export const supervisorLayer = ( ); yield* sendStart(child, input); const response = yield* Fiber.join(responseFiber); + if (response.owner.daemonCliVersion !== input.cliVersion) { + return yield* Effect.fail( + new DaemonUpgradeRequired({ + stackId: input.stackId, + oldCliVersion: response.owner.daemonCliVersion, + newCliVersion: input.cliVersion, + state: response.owner.state, + ready: response.owner.ready, + }), + ); + } child.unref(); detached = true; - return RemoteStack.layer(response.endpoint).pipe( - Layer.provide(Layer.succeed(HttpTransportClient, client)), - ); + return RemoteStack.layer(response.endpoint, { + owner: response.owner, + cliVersion: input.cliVersion, + stackId: input.stackId, + }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, client))); }).pipe( Effect.onExit(() => detached ? Effect.void : terminateChildProcess(child).pipe(Effect.ignore), @@ -830,8 +1205,13 @@ export const managedDaemonLayer = ( input: ManagedDaemonStartInput, entryPoint: string, ): Effect.Effect< - Layer.Layer, - SupervisorStartError | import("./managed/model.ts").InvalidManagedStackNameError, + Layer.Layer, + | SupervisorStartError + | DaemonUpgradeRequired + | UpgradePreflightError + | UpgradeRestartError + | StopTimeout + | import("./managed/model.ts").InvalidManagedStackNameError, HttpTransportClient | import("effect").FileSystem.FileSystem > => Effect.gen(function* () { @@ -843,6 +1223,7 @@ export const managedDaemonLayer = ( return yield* supervisorLayer( { type: "start", + cliVersion: input.cliVersion, stackId: deriveStackId(discovery.identity, input.stackName), workspacePath: input.workspacePath, stackName: input.stackName, diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 2743fdf6c3..aaad8c1ee3 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,3 +1,56 @@ /** Test-only runtime seams for building deterministic consumer layers. */ -export { DaemonServer } from "./DaemonServer.ts"; +import { Effect, Stream } from "effect"; +import type { StackInfo } from "./Stack.ts"; +import type { Stack } from "./Stack.ts"; +import { StackServiceState } from "./StackServiceState.ts"; + +const testStackInfo: StackInfo = { + url: "http://127.0.0.1", + dbUrl: "postgresql://127.0.0.1/postgres", + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "role", + serviceEndpoints: {}, +}; + +const testStackState = new StackServiceState({ + name: "auth", + status: "Running", + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, +}); + +export const makeTestStack = ( + options: { + readonly stop?: () => Effect.Effect; + readonly dispose?: () => Effect.Effect; + } = {}, +): Stack["Service"] => ({ + getInfo: () => Effect.succeed(testStackInfo), + start: () => Effect.void, + stop: options.stop ?? (() => Effect.void), + dispose: options.dispose ?? (() => Effect.void), + startService: () => Effect.void, + stopService: () => Effect.void, + restartService: () => Effect.void, + reloadFunctions: () => Effect.void, + reloadEdgeRuntime: () => Effect.void, + getState: () => Effect.succeed(testStackState), + getAllStates: () => Effect.succeed([testStackState]), + stateChanges: () => Effect.succeed(Stream.empty), + allStateChanges: () => Stream.empty, + waitReady: () => Effect.void, + waitAllReady: () => Effect.void, + subscribeLogs: () => Stream.empty, + subscribeAllLogs: () => Stream.empty, + logHistory: () => Effect.succeed([]), + logHistoryAll: () => Effect.succeed([]), +}); + export { HttpTransportClient } from "./HttpTransportClient.ts"; +export { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; +export { SupervisorLifecycle } from "./SupervisorLifecycle.ts"; diff --git a/packages/stack/tests/helpers/compiled-supervisor-parent.ts b/packages/stack/tests/helpers/compiled-supervisor-parent.ts new file mode 100644 index 0000000000..b41b1fb555 --- /dev/null +++ b/packages/stack/tests/helpers/compiled-supervisor-parent.ts @@ -0,0 +1,75 @@ +import { Context, Effect, Layer, Schema } from "effect"; +import { runTestSupervisor } from "./supervisor-child.ts"; +import { Stack } from "../../src/Stack.ts"; +import { httpTransportClientLayer } from "../../src/HttpTransportClient.ts"; +import { SupervisorStartCommandSchema } from "../../src/SupervisorProtocol.ts"; +import { daemonEntryPoint } from "../../src/platform-bun.ts"; +import { supervisorLayer } from "../../src/supervisor.ts"; + +/** + * The compiled parent and its re-entered child exchange only schema-validated + * values. The child itself uses the supervisor's production protocol; this + * event only tells the test process that the parent obtained its RemoteStack + * layer and detached the compiled supervisor child. + */ +export const CompiledSupervisorParentEventSchema = Schema.Union([ + Schema.Struct({ type: Schema.Literal("ready"), stackId: Schema.String }), + Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }), +]); + +type ParentEvent = typeof CompiledSupervisorParentEventSchema.Type; + +const send = (event: ParentEvent): Promise => + new Promise((resolve, reject) => { + if (process.send === undefined || !process.connected) { + reject(new Error("compiled supervisor parent IPC is unavailable")); + return; + } + try { + process.send(Schema.encodeSync(CompiledSupervisorParentEventSchema)(event), (cause) => { + if (cause === null) resolve(); + else reject(cause); + }); + } catch (cause) { + reject(cause); + } + }); + +const fail = (cause: unknown): void => { + const message = cause instanceof Error ? cause.message : String(cause); + void send({ type: "error", message }).finally(() => process.disconnect?.()); +}; + +const runParent = (raw: unknown): void => { + const program = Effect.scoped( + Effect.gen(function* () { + const input = yield* Schema.decodeUnknownEffect(SupervisorStartCommandSchema)(raw); + const remoteLayer = yield* supervisorLayer(input, daemonEntryPoint).pipe( + Effect.provide(httpTransportClientLayer), + ); + const context = yield* Layer.build(remoteLayer); + // Force the generated RemoteStack layer to be materialized before + // reporting readiness; this is the real parent/child detach boundary. + Context.get(context, Stack); + yield* Effect.promise(() => send({ type: "ready", stackId: input.stackId })); + }), + ); + void Effect.runPromise(program) + .then(() => process.disconnect?.()) + .catch(fail); +}; + +const onMessage = (raw: unknown): void => runParent(raw); + +if (import.meta.main) { + if (process.env["SUPABASE_STACK_RUN_DAEMON"] === "1") { + // The compiled child re-enters this same artifact with the stable marker + // installed by forkSupervisor. It receives the supervisor start command on + // the inherited IPC channel and executes the test runtime platform. + runTestSupervisor(); + } else { + process.once("message", onMessage); + } +} + +export type CompiledSupervisorStartMessage = typeof SupervisorStartCommandSchema.Type; diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index f1c5973ac2..735a6b20f8 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -139,9 +139,10 @@ export const closeExternal = (server: Server): Promise => /** * Control endpoints project two identity-hash bytes into `CONTROL_PORT_RANGE`, * so parallel test files can land on a port already owned by another live - * stack's control server. Acquires control for a fresh directory under `base`, - * retrying with a new directory (a new path-seeded identity, so a new port) on - * a conflict until a wall-clock deadline, rethrowing the last conflict. + * stack's control server or by a non-control listener. Acquires control for a + * fresh directory under `base`, retrying `ControlAddressConflictError` and + * `ControlTransportError` with a new path-seeded identity until the deadline, + * then rethrowing the last failure. */ export const acquireWorkspaceControl = (base: string, prefix = "workspace") => Effect.gen(function* () { @@ -153,7 +154,9 @@ export const acquireWorkspaceControl = (base: string, prefix = "workspace") => const acquired = yield* acquireControl({ stackId }).pipe( Effect.map((ownership) => ({ ownership })), Effect.catch((error) => - Predicate.isTagged(error, "ControlAddressConflictError") && Date.now() < deadline + (Predicate.isTagged(error, "ControlAddressConflictError") || + Predicate.isTagged(error, "ControlTransportError")) && + Date.now() < deadline ? Effect.succeed(undefined) : Effect.fail(error), ), diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 684b646aff..62c7d1c17d 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -1,6 +1,6 @@ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { BunFileSystem, BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Stream, Duration } from "effect"; +import { Deferred, Effect, Layer, Stream, Duration } from "effect"; import { createServer, type Server } from "node:net"; import { existsSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; @@ -9,8 +9,11 @@ import { SupervisorStartError, type SupervisorPlatform, } from "../../src/supervisor.ts"; +import { LocalStackLifecycle } from "../../src/LocalStack.ts"; import { Stack } from "../../src/Stack.ts"; import { validateResolvedConfig } from "../../src/StackBuilder.ts"; +import { StackReadinessError } from "../../src/errors.ts"; +import { ControlTransport } from "../../src/managed/control.ts"; import { gitConfigStoreLayer } from "../../src/managed/git.ts"; import { ManagedStackManager, managedStackManagerLayer } from "../../src/managed/manager.ts"; import { @@ -26,7 +29,13 @@ import type { PortLease } from "../../src/PortAllocator.ts"; import type { ResolvedDaemonConfig } from "../../src/StackConfig.ts"; import { watchDirectoryWithRetry } from "./file-watch.ts"; -type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; +type TestMode = + | "bind-all" + | "fail-after-bind" + | "hold-reservations" + | "hold-start" + | "hold-stop" + | "readiness-failure"; const FILE_WAIT_TIMEOUT = "30 seconds"; const waitForFile = (path: string): Effect.Effect => @@ -68,6 +77,7 @@ const testMode = (): TestMode => { if (value === "hold-reservations") return value; if (value === "hold-start") return value; if (value === "hold-stop") return value; + if (value === "readiness-failure") return value; return "bind-all"; }; @@ -98,7 +108,11 @@ const closeTestPorts = (servers: ReadonlyArray): Effect.Effect => { discard: true }, ); -const testStackLayer = (config: ResolvedDaemonConfig, mode: TestMode): Layer.Layer => { +const testStackLayer = ( + config: ResolvedDaemonConfig, + mode: TestMode, + disposed: Deferred.Deferred, +): Layer.Layer => { const info = { url: `http://127.0.0.1:${config.apiPort}`, dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, @@ -120,9 +134,7 @@ const testStackLayer = (config: ResolvedDaemonConfig, mode: TestMode): Layer.Lay mode === "hold-stop" ? Effect.gen(function* () { const stageFile = process.env["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; - if (stageFile === undefined) { - yield* sendTestStage("stop-began").pipe(Effect.orDie); - } else { + if (stageFile !== undefined) { yield* Effect.sync(() => writeFileSync(stageFile, "began")); } yield* waitForStopRelease(); @@ -139,7 +151,20 @@ const testStackLayer = (config: ResolvedDaemonConfig, mode: TestMode): Layer.Lay stateChanges: () => Effect.succeed(Stream.empty), allStateChanges: () => Stream.empty, waitReady: () => Effect.void, - waitAllReady: () => Effect.void, + waitAllReady: () => + mode === "readiness-failure" + ? Deferred.succeed(disposed, undefined).pipe( + Effect.andThen( + Effect.fail( + new StackReadinessError({ + target: "stack", + timeoutMs: 75, + detail: "Timed out waiting for stack readiness after 75ms", + }), + ), + ), + ) + : Effect.void, subscribeLogs: () => Stream.empty, subscribeAllLogs: () => Stream.empty, logHistory: () => Effect.succeed([]), @@ -153,11 +178,19 @@ const testRuntime = ({ }: { readonly config: ResolvedDaemonConfig; readonly lease: PortLease; -}): Effect.Effect, unknown, import("effect").Scope.Scope> => { +}): Effect.Effect< + Layer.Layer, + unknown, + import("effect").Scope.Scope +> => { const mode = testMode(); return Effect.gen(function* () { + const disposed = Deferred.makeUnsafe(); yield* validateResolvedConfig(config); - if (mode === "hold-start") yield* Effect.never; + if (mode === "hold-start") { + const releaseFile = process.env["SUPABASE_STACK_TEST_START_RELEASE_FILE"]; + yield* releaseFile === undefined ? Effect.never : waitForFile(releaseFile); + } const servers: Array = []; if (mode !== "hold-reservations") { for (const field of PORT_FIELDS) { @@ -173,74 +206,35 @@ const testRuntime = ({ new SupervisorStartError({ message: "Supervisor test runtime failed after binding" }), ); } - return testStackLayer(config, mode); + return Layer.mergeAll( + testStackLayer(config, mode, disposed), + Layer.succeed(LocalStackLifecycle, { + awaitDisposed: Deferred.await(disposed), + isDisposed: Effect.succeed(mode === "readiness-failure"), + }), + ); }); }; -const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { +const observeAttachedBeforeReady = (value: unknown): Effect.Effect => { + if ( + typeof value !== "object" || + value === null || + !("ready" in value) || + value.ready !== false || + !("state" in value) || + (value.state !== "starting" && value.state !== "stopping") + ) { + return Effect.void; + } const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; - if (readyFile === undefined || releaseFile === undefined) return Effect.void; - return Effect.callback((resume) => { - let settled = false; - let stopWatching: (() => void) | undefined; - const cleanup = () => { - stopWatching?.(); - stopWatching = undefined; - }; - const settle = (result: Effect.Effect) => { - if (settled) return; - settled = true; - cleanup(); - resume(result); - }; - const resolveIfReleased = () => { - if (existsSync(releaseFile)) settle(Effect.void); - }; - try { - stopWatching = watchDirectoryWithRetry(dirname(releaseFile), resolveIfReleased, (cause) => - settle(Effect.die(cause)), - ); - writeFileSync(readyFile, "ready"); - resolveIfReleased(); - } catch (cause) { - settle(Effect.die(cause)); - } - return Effect.sync(cleanup); - }); + if (readyFile === undefined || existsSync(readyFile)) return Effect.void; + return Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( + Effect.andThen(releaseFile === undefined ? Effect.void : waitForFile(releaseFile)), + ); }; -const sendTestStage = ( - stage: "attached-before-ready" | "managed-started" | "stop-began", -): Effect.Effect => - Effect.callback((resume) => { - if (process.send === undefined || !process.connected) { - resume(Effect.void); - return Effect.void; - } - try { - process.send({ type: "test-stage", stage }, (error) => - resume( - error === null - ? Effect.void - : Effect.fail(new SupervisorStartError({ message: error.message })), - ), - ); - } catch (cause) { - resume( - Effect.fail( - new SupervisorStartError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - ), - ); - } - return Effect.void; - }); - -const sendAttachedBeforeReadyStage = (): Effect.Effect => - sendTestStage("attached-before-ready").pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); - const resolutionTimeout = (): Duration.Input => { const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); return Number.isFinite(milliseconds) && milliseconds > 0 @@ -272,9 +266,21 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => Effect.map((manager) => ({ ...manager, startStack: (input: Parameters[0]) => - manager - .startStack(input) - .pipe(Effect.tap(() => sendTestStage("managed-started").pipe(Effect.orDie))), + manager.startStack(input).pipe( + Effect.tap(() => { + const markerFile = process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"]; + const releaseFile = + process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE"]; + return Effect.sync(() => { + if (markerFile !== undefined) writeFileSync(markerFile, "started"); + }).pipe( + Effect.andThen( + releaseFile === undefined ? Effect.void : waitForFile(releaseFile), + ), + Effect.orDie, + ); + }), + ), ...(readyFile === undefined || releaseFile === undefined ? {} : { @@ -292,18 +298,28 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => export const runTestSupervisor = (): void => { const platformKind = testPlatform(); - const controlTransportLayer = + const baseControlTransportLayer = platformKind === "bun" ? bunControlTransportLayer : nodeControlTransportLayer; + const testControlTransportLayer = Layer.effect( + ControlTransport, + Effect.gen(function* () { + const transport = yield* ControlTransport; + return { + ...transport, + read: (endpoint: Parameters[0]) => + transport.read(endpoint).pipe(Effect.tap(observeAttachedBeforeReady)), + }; + }), + ).pipe(Layer.provide(baseControlTransportLayer)); const supervisorPlatform: SupervisorPlatform = { platformFactory: platformKind === "bun" ? bunPlatformFactory : nodePlatformFactory, managerLayer: (stateRoot) => managerLayer(stateRoot, platformKind), runtimeLayer: testRuntime, - onAttachedBeforeReady: sendAttachedBeforeReadyStage, resolutionTimeout: resolutionTimeout(), }; const program = runSupervisor(supervisorPlatform).pipe( Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), + Effect.provide(testControlTransportLayer), ); void Effect.runPromise( platformKind === "bun"