diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 680424c0ad..42ab7751d5 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http"; import * as Network from "./Network.ts"; import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; import * as State from "./State.ts"; +import { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -99,12 +100,13 @@ it.live("retains dedicated assignments across network reopen", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-retain-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const enabled = yield* Ref.make(true); - const first = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const first = yield* makeTestNetwork({ stackId, runtime: "native", state }); const firstNamespace = yield* first.register({ id: "one", endpoints: { api: endpoint(target, Ref.get(enabled)) }, @@ -112,7 +114,7 @@ it.live("retains dedicated assignments across network reopen", () => yield* firstNamespace.bind; const firstAddress = yield* firstNamespace.address("api", "host"); expect(yield* request(firstAddress.host, firstAddress.port, "/one")).toBe("backend:/one"); - const saved = yield* state.read("stack"); + const saved = yield* state.read(stackId); expect(saved?.ports).toHaveLength(1); yield* Ref.set(enabled, false); yield* firstNamespace.close; @@ -122,7 +124,7 @@ it.live("retains dedicated assignments across network reopen", () => "backend:/restart", ); yield* firstNamespace.close; - const second = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const second = yield* makeTestNetwork({ stackId, runtime: "native", state }); const secondNamespace = yield* second.register({ id: "one", endpoints: { api: endpoint(target, Ref.get(enabled)) }, @@ -134,8 +136,8 @@ it.live("retains dedicated assignments across network reopen", () => "backend:/reopen", ); yield* secondNamespace.release; - expect((yield* state.read("stack"))?.ports).toHaveLength(0); - expect(yield* fs.exists(path.join(root, "stack", "state.json"))).toBe(true); + expect((yield* state.read(stackId))?.ports).toHaveLength(0); + expect(yield* fs.exists(path.join(root, stackId, "state.json"))).toBe(true); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -144,13 +146,14 @@ it.live("keeps shared routes independent and retains the shared claim", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-shared-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const firstEnabled = yield* Ref.make(true); const secondEnabled = yield* Ref.make(true); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const first = yield* network.register({ id: "rest", endpoints: { @@ -186,7 +189,7 @@ it.live("keeps shared routes independent and retains the shared claim", () => expect(yield* request(address.host, address.port, "/auth/v1")).toBe("backend:/auth/v1"); yield* Ref.set(secondEnabled, false); yield* second.close; - expect((yield* state.read("stack"))?.ports).toHaveLength(1); + expect((yield* state.read(stackId))?.ports).toHaveLength(1); yield* first.bind; expect((yield* first.address("api", "host")).port).toBe(address.port); expect(yield* request(address.host, address.port, "/rest/reopen")).toBe( @@ -194,7 +197,7 @@ it.live("keeps shared routes independent and retains the shared claim", () => ); yield* first.close; yield* second.release; - expect((yield* state.read("stack"))?.ports).toHaveLength(1); + expect((yield* state.read(stackId))?.ports).toHaveLength(1); expect(yield* fs.exists(root)).toBe(true); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -203,14 +206,15 @@ it.live("keeps shared routes independent and retains the shared claim", () => it.live("reports a dedicated port conflict without rewriting saved ownership", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-conflict-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("first", "auto")); - yield* state.save(stack("second", 20001)); + const firstId = uniqueStackId("first"); + const secondId = uniqueStackId("second"); + yield* state.save(stack(firstId, "auto")); + yield* state.save(stack(secondId, 20001)); const target = yield* backend; const firstEnabled = yield* Ref.make(true); - const firstNetwork = yield* makeTestNetwork({ stackId: "first", runtime: "native", state }); + const firstNetwork = yield* makeTestNetwork({ stackId: firstId, runtime: "native", state }); const first = yield* firstNetwork.register({ id: "db", endpoints: { sql: { ...endpoint(target, Ref.get(firstEnabled)), protocol: "tcp" } }, @@ -218,7 +222,7 @@ it.live("reports a dedicated port conflict without rewriting saved ownership", ( yield* first.bind; const firstPort = (yield* first.address("sql", "host")).port; const secondEnabled = yield* Ref.make(true); - const secondNetwork = yield* makeTestNetwork({ stackId: "second", runtime: "native", state }); + const secondNetwork = yield* makeTestNetwork({ stackId: secondId, runtime: "native", state }); const second = yield* secondNetwork.register({ id: "db", endpoints: { @@ -231,7 +235,7 @@ it.live("reports a dedicated port conflict without rewriting saved ownership", ( }); const failure = yield* second.bind.pipe(Effect.flip); expect(failure.operation).toBe("bind"); - expect((yield* state.read("second"))?.ports).toEqual([ + expect((yield* state.read(secondId))?.ports).toEqual([ { key: "api", host: "127.0.0.1", port: 20001 }, ]); yield* Ref.set(firstEnabled, false); @@ -245,13 +249,13 @@ it.live("reports a dedicated port conflict without rewriting saved ownership", ( it.live("can bind a shared listener after saving its first assignment failed", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-save-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const failing = yield* Ref.make(true); const network = yield* makeTestNetwork({ - stackId: "stack", + stackId, runtime: "native", state: { ...state, @@ -281,7 +285,7 @@ it.live("can bind a shared listener after saving its first assignment failed", ( expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); yield* namespace.release; yield* network.release; - expect((yield* state.read("stack"))?.ports).toHaveLength(0); + expect((yield* state.read(stackId))?.ports).toHaveLength(0); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -289,13 +293,13 @@ it.live("can bind a shared listener after saving its first assignment failed", ( it.live("releases dedicated HTTP activity after the response while keep-alive stays open", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-http-activity-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const released = yield* Deferred.make(); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const service = yield* network.register({ id: "studio", endpoints: { @@ -344,12 +348,12 @@ it.live( () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-fixed-port-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const studio = yield* network.register({ id: "studio", endpoints: { http: joinEndpoint(target, Effect.succeed(true)) }, @@ -357,7 +361,7 @@ it.live( yield* studio.bind; // The join route is queued, not installed: it never claims or asserts the shared "api" // port, so no shared listener or claim exists yet, whatever port a later claimant picks. - const beforeClaimant = yield* state.read("stack"); + const beforeClaimant = yield* state.read(stackId); expect(beforeClaimant?.ports.some((claim) => claim.key === "api")).toBe(false); const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); yield* rest.bind; @@ -373,12 +377,12 @@ it.live( it.live("drops a queued join route when its namespace closes before any claimant binds", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-cancel-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const studio = yield* network.register({ id: "studio", endpoints: { http: joinEndpoint(target, Effect.succeed(false)) }, @@ -401,12 +405,12 @@ it.live("drops a queued join route when its namespace closes before any claimant it.live("stays idempotent across repeated binds of the joining namespace", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-idempotent-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); yield* rest.bind; const studio = yield* network.register({ @@ -425,14 +429,12 @@ it.live("stays idempotent across repeated binds of the joining namespace", () => it.live("keeps a namespace's own shared route when its join endpoint also binds", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "network-join-shares-namespace-", - }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const combo = yield* network.register({ id: "combo", endpoints: { @@ -454,13 +456,13 @@ it.live("keeps a namespace's own shared route when its join endpoint also binds" it.live("re-adds a joined route after its namespace closes and rebinds", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-rebind-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const enabled = yield* Ref.make(true); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); yield* rest.bind; const studio = yield* network.register({ @@ -487,13 +489,13 @@ it.live("re-adds a joined route after its namespace closes and rebinds", () => it.live("never restores a joined route once its namespace is released", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-release-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const enabled = yield* Ref.make(true); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); yield* rest.bind; const studio = yield* network.register({ @@ -521,14 +523,14 @@ it.live( () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-last-close-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const restEnabled = yield* Ref.make(true); const studioEnabled = yield* Ref.make(true); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const rest = yield* network.register(claimant("rest", target, Ref.get(restEnabled))); yield* rest.bind; const studio = yield* network.register({ @@ -576,13 +578,13 @@ it.live( it.live("wakes a sleeping backend when a request reaches its joined route", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-wake-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; const woken = yield* Deferred.make(); - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "native", state }); const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); yield* rest.bind; const studio = yield* network.register({ @@ -608,12 +610,12 @@ it.live("wakes a sleeping backend when a request reaches its joined route", () = it.live("addresses docker runtime endpoints through the stack host alias", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-docker-alias-" }); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* state.save(stack("stack", "auto")); + const stackId = uniqueStackId("stack"); + yield* state.save(stack(stackId, "auto")); const target = yield* backend; - const network = yield* makeTestNetwork({ stackId: "stack", runtime: "docker", state }); + const network = yield* makeTestNetwork({ stackId, runtime: "docker", state }); const namespace = yield* network.register({ id: "one", endpoints: { api: endpoint(target, Effect.succeed(false)) }, diff --git a/packages/stack/src/Owner.analytics.integration.test.ts b/packages/stack/src/Owner.analytics.integration.test.ts index 578c863a83..9cecd6d810 100644 --- a/packages/stack/src/Owner.analytics.integration.test.ts +++ b/packages/stack/src/Owner.analytics.integration.test.ts @@ -23,7 +23,8 @@ import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; import type { ServiceCreationInput } from "./services/Catalog.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; -import { ownerFor } from "../tests/owner-rpc.ts"; +import { ownerFor, registerLeased } from "../tests/owner-rpc.ts"; +import { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; @@ -89,11 +90,10 @@ const startStack = ( analyticsIdleMillis?: number, ) => Effect.gen(function* () { - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; const client = yield* HttpClient.HttpClient; - const stackId = `${name}-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; - const root = yield* makeDockerDatabaseRoot(`stack-${name}-`, stackId); + const stackId = uniqueStackId(name); + const stateRoot = sharedStateRoot(); + const root = yield* makeDockerDatabaseRoot(`stack-${name}-`, stackId, { stateRoot }); const saved: SavedStack = { id: stackId, identity: { projectRoot: "/tmp/project", branchContext: name, stackName: stackId }, @@ -103,11 +103,8 @@ const startStack = ( composition: { members: [], dependencies: [] }, ports: [], }; - const state = Context.get( - yield* Layer.build(State.layer({ root: path.dirname(path.dirname(root)) })), - State.Service, - ); - yield* state.save(saved); + const state = Context.get(yield* Layer.build(State.layer({ root: stateRoot })), State.Service); + yield* registerLeased(state, saved); const owner = yield* ownerFor({ saved, state, root, cacheRoot }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 029119d629..1aa4f52443 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -3,6 +3,7 @@ import { PgClient } from "@effect/sql-pg"; import { expect, it } from "@effect/vitest"; import { Context, Effect, FileSystem, Layer, Redacted, Ref, Schema } from "effect"; import { HttpClient } from "effect/unstable/http"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { RpcTest } from "effect/unstable/rpc"; import * as Owner from "./Owner.ts"; @@ -11,7 +12,8 @@ import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; import { DEFAULT_LOCAL_JWT_SECRET } from "./Defaults.ts"; import { ServiceCreation, type ServiceCreationInput } from "./services/Catalog.ts"; -import { ownerFor } from "../tests/owner-rpc.ts"; +import { ownerFor, registerLeased } from "../tests/owner-rpc.ts"; +import { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; const stateFor = (root: string) => Effect.gen(function* () { @@ -55,7 +57,7 @@ it.effect("credential lookup leaves a fresh stack untouched for custom database const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); const stack = initial("owner-credentials"); const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -89,9 +91,12 @@ it.live("forwards and rotates saved identity across composed services in one own Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-identity-rotation-" }); - const stack = initial("a".repeat(64)); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + // Exactly 64 characters, the boundary `services/Database.ts` enforces, with a unique prefix + // so this test's identity never collides with another file's on the shared state root. + const uniqueSuffix = randomUUID().replaceAll("-", "").slice(0, 12); + const stack = initial(`${uniqueSuffix}${"a".repeat(64 - uniqueSuffix.length)}`); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -331,7 +336,7 @@ it.effect("publishes service removal and composition pruning together", () => const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-remove-" }); const stack = initial("owner-remove"); const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + yield* registerLeased(state, stack); const removalWrite = yield* Ref.make(false); const failingState: State.Interface = { ...state, @@ -383,9 +388,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-" }); - const stack = initial("owner-integration"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("owner-integration")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -485,8 +490,8 @@ it.effect("isolates owner graphs built in one scope", () => const state = yield* stateFor(`${root}/state`); const first = initial("owner-layer-first"); const second = initial("owner-layer-second"); - yield* state.save(first); - yield* state.save(second); + yield* registerLeased(state, first); + yield* registerLeased(state, second); const memoMap = yield* Layer.makeMemoMap; const scope = yield* Effect.scope; const buildOwner = (saved: SavedStack) => @@ -534,9 +539,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-factory-" }); - const stack = initial("owner-factory"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("owner-factory")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -602,7 +607,7 @@ it.effect("validates Supabase composition recipes before creating instances", () const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-factory-errors-" }); const stack = initial("owner-factory-errors"); const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -692,7 +697,7 @@ it.effect("lets a retry choose other credentials after the first database creati }); const stack = initial("owner-credential-rollback"); const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + yield* registerLeased(state, stack); const database = (password: string) => ({ service: "database" as const, config: { version: "17", databasePassword: Redacted.make(password), jwtExpiry: 3600 }, @@ -732,7 +737,7 @@ it.effect("rejects a duplicate instance without releasing the existing instance' instances: [instance], ports: [claim], }; - yield* state.save(stack); + yield* registerLeased(state, stack); const failure = yield* ownerFor({ saved: { ...stack, instances: [instance, instance] }, @@ -754,7 +759,7 @@ it.effect("refuses to generate credentials for a stack whose saved instances con const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); const stack = initial("owner-credentials"); const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); yield* owner.rpc.createService({ @@ -787,9 +792,9 @@ it.live("rejects a missing required input before starting or stopping the servic Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-missing-input-" }); - const stack = initial("owner-missing-input"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("owner-missing-input")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const unbound = yield* owner.rpc.createService({ diff --git a/packages/stack/src/Owner.logs.integration.test.ts b/packages/stack/src/Owner.logs.integration.test.ts index 0d85931690..bacd4346f2 100644 --- a/packages/stack/src/Owner.logs.integration.test.ts +++ b/packages/stack/src/Owner.logs.integration.test.ts @@ -2,7 +2,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Context, - Crypto, Effect, Exit, Fiber, @@ -15,7 +14,8 @@ import { Stream, } from "effect"; import { tmpdir } from "node:os"; -import { ownerFor } from "../tests/owner-rpc.ts"; +import { ownerFor, registerLeased } from "../tests/owner-rpc.ts"; +import { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; import type { LogRecord } from "./host/LogRecord.ts"; import { StackError, streamStackLogs } from "./effect.ts"; import * as LogStore from "./host/LogStore.ts"; @@ -35,34 +35,44 @@ const stackFor = (id: string, runtime: SavedStack["runtime"]): SavedStack => ({ ports: [], }); -/** Opens an owner that destroys its stack on scope exit unless `kept` preserves it for a later read. */ +/** Leases a fresh stack on the shared state root for the enclosing scope. */ +const registerStack = (prefix: string, runtime: SavedStack["runtime"]) => + Effect.gen(function* () { + const stack = stackFor(uniqueStackId(prefix), runtime); + const stateRoot = sharedStateRoot(); + const state = Context.get(yield* Layer.build(State.layer({ root: stateRoot })), State.Service); + yield* registerLeased(state, stack); + return { stack, state, stateRoot }; + }); + +/** + * Opens an owner for an already-registered stack, destroying it on scope exit unless `persist` + * keeps it for a later reopen. Call `registerStack` in the enclosing scope so a reopen after an + * inner scope closes reuses its lease instead of taking it again. + */ const openOwner = ( - prefix: string, - runtime: SavedStack["runtime"], - kept?: { readonly root: string }, + registered: { + readonly stack: SavedStack; + readonly state: State.Interface; + readonly stateRoot: string; + }, + dataRoot: string, + persist = false, ) => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const crypto = yield* Crypto.Crypto; - const root = kept?.root ?? (yield* fs.makeTempDirectoryScoped({ prefix })); - const stack = stackFor( - `owner-logs-${(yield* crypto.randomUUIDv4).replaceAll("-", "")}`, - runtime, - ); - const state = Context.get( - yield* Layer.build(State.layer({ root: `${root}/state` })), - State.Service, - ); - yield* state.save(stack); - const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); - if (kept === undefined) - yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + const owner = yield* ownerFor({ + saved: registered.stack, + state: registered.state, + root: `${dataRoot}/data`, + cacheRoot, + }); + if (!persist) yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); return { ...owner, - state, - stack, - stateRoot: `${root}/state`, - logsRoot: state.logsRoot(stack.id), + state: registered.state, + stack: registered.stack, + stateRoot: registered.stateRoot, + logsRoot: registered.state.logsRoot(registered.stack.id), }; }); @@ -86,7 +96,9 @@ describe("owner persisted logs", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const owner = yield* openOwner("owner-logs-native-", "native"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-native-" }); + const registered = yield* registerStack("owner-logs-native", "native"); + const owner = yield* openOwner(registered, root); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, @@ -122,10 +134,9 @@ describe("owner persisted logs", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-offline-" }); + const registered = yield* registerStack("owner-logs-offline", "native"); const ownerScope = yield* Scope.make(); - const owner = yield* openOwner("owner-logs-offline-", "native", { root }).pipe( - Scope.provide(ownerScope), - ); + const owner = yield* openOwner(registered, root, true).pipe(Scope.provide(ownerScope)); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, @@ -156,10 +167,9 @@ describe("owner persisted logs", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-relaunch-" }); + const registered = yield* registerStack("owner-logs-relaunch", "native"); const firstRun = yield* Scope.make(); - const owner = yield* openOwner("owner-logs-relaunch-", "native", { root }).pipe( - Scope.provide(firstRun), - ); + const owner = yield* openOwner(registered, root, true).pipe(Scope.provide(firstRun)); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, @@ -169,13 +179,14 @@ describe("owner persisted logs", () => { yield* owner.rpc.readyService({ id: mail.id }); yield* owner.rpc.stopService({ id: mail.id }); yield* Scope.close(firstRun, Exit.void); - const state = Context.get( - yield* Layer.build(State.layer({ root: owner.stateRoot })), - State.Service, - ); - const saved = yield* state.read(owner.stack.id); + const saved = yield* registered.state.read(registered.stack.id); if (saved === undefined) return yield* Effect.die("stack state missing"); - const restarted = yield* ownerFor({ saved, state, root: `${root}/data`, cacheRoot }); + const restarted = yield* ownerFor({ + saved, + state: registered.state, + root: `${root}/data`, + cacheRoot, + }); yield* Effect.addFinalizer(() => restarted.namespace.destroy.pipe(Effect.ignore)); const launched = yield* awaitLaunches( @@ -201,21 +212,16 @@ describe("owner persisted logs", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-unsaved-launch-" }); + const registered = yield* registerStack("owner-logs-unsaved-launch", "native"); const firstRun = yield* Scope.make(); - const owner = yield* openOwner("owner-logs-unsaved-launch-", "native", { root }).pipe( - Scope.provide(firstRun), - ); + const owner = yield* openOwner(registered, root, true).pipe(Scope.provide(firstRun)); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" } }, }); yield* Scope.close(firstRun, Exit.void); - const state = Context.get( - yield* Layer.build(State.layer({ root: owner.stateRoot })), - State.Service, - ); - const saved = yield* state.read(owner.stack.id); + const saved = yield* registered.state.read(registered.stack.id); if (saved === undefined) return yield* Effect.die("stack state missing"); // Logs a state saved before launch ids were persisted left behind at launch 3. const directory = `${owner.logsRoot}/mail/${mail.id}`; @@ -224,7 +230,12 @@ describe("owner persisted logs", () => { `${directory}/0000000001.log`, "2026-01-01T00:00:00.000Z launch 3 | \n2026-01-01T00:00:00.001Z stdout 3 | earlier\n", ); - const restarted = yield* ownerFor({ saved, state, root: `${root}/data`, cacheRoot }); + const restarted = yield* ownerFor({ + saved, + state: registered.state, + root: `${root}/data`, + cacheRoot, + }); yield* Effect.addFinalizer(() => restarted.namespace.destroy.pipe(Effect.ignore)); const launched = yield* awaitLaunches( @@ -249,7 +260,10 @@ describe("owner persisted logs", () => { it.live("resumes a follow at a record position without replaying earlier records", () => Effect.scoped( Effect.gen(function* () { - const owner = yield* openOwner("owner-logs-resume-", "native"); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-resume-" }); + const registered = yield* registerStack("owner-logs-resume", "native"); + const owner = yield* openOwner(registered, root); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, @@ -286,7 +300,9 @@ describe("owner persisted logs", () => { Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const owner = yield* openOwner("owner-logs-reset-", "native"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-reset-" }); + const registered = yield* registerStack("owner-logs-reset", "native"); + const owner = yield* openOwner(registered, root); const database = yield* owner.rpc.createService({ service: "database", config: { @@ -322,7 +338,10 @@ describe("owner persisted logs", () => { () => Effect.scoped( Effect.gen(function* () { - const owner = yield* openOwner("owner-logs-docker-", "docker"); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "owner-logs-docker-" }); + const registered = yield* registerStack("owner-logs-docker", "docker"); + const owner = yield* openOwner(registered, root); const mail = yield* owner.rpc.createService({ service: "mail", config: {}, diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index c7f4df0938..8a50977af1 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -1,8 +1,13 @@ import { NodeServices, NodeSocketServer } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Cause, Context, Effect, Exit, FileSystem, Layer, Option, Ref, Scope } from "effect"; +import { Cause, Context, Effect, Exit, FileSystem, Hash, Layer, Option, Ref, Scope } from "effect"; import { makePorts, portBase, portSpan, PortError, reserveNativePort } from "./Ports.ts"; import * as State from "./State.ts"; +import { + sharedPortClaims, + sharedStateRoot, + uniqueStackId, +} from "../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -17,10 +22,11 @@ const bind = (host: string, port: number) => it.live("retains distinct claims for stopped stacks and rebinds the original public port", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - for (const id of ["first", "second"]) + const firstId = uniqueStackId("first"); + const secondId = uniqueStackId("second"); + for (const id of [firstId, secondId]) yield* state.save({ id, runtime: "native", @@ -32,17 +38,22 @@ it.live("retains distinct claims for stopped stacks and rebinds the original pub }); const ports = yield* makePorts(state); const firstScope = yield* Scope.make(); - const request = { stackId: "first", key: "db/sql", host: "127.0.0.1", port: "auto" as const }; + const request = { + stackId: firstId, + key: "db/sql", + host: "127.0.0.1", + port: "auto" as const, + }; const first = yield* ports .acquire(request, bind) .pipe(Effect.provideService(Scope.Scope, firstScope)); yield* Scope.close(firstScope, Exit.void); - const second = yield* ports.acquire({ ...request, stackId: "second" }, bind); + const second = yield* ports.acquire({ ...request, stackId: secondId }, bind); expect(second.port).not.toBe(first.port); const reopened = yield* makePorts(yield* makeTestState(root)); const again = yield* reopened.acquire(request, bind); expect(again.port).toBe(first.port); - expect((yield* state.read("first"))?.ports).toEqual([ + expect((yield* state.read(firstId))?.ports).toEqual([ { key: "db/sql", host: "127.0.0.1", port: first.port }, ]); }), @@ -52,11 +63,11 @@ it.live("retains distinct claims for stopped stacks and rebinds the original pub it.live("reports an occupied saved port without moving its assignment", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); + const stackId = uniqueStackId("stack"); yield* state.save({ - id: "stack", + id: stackId, runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], @@ -66,7 +77,7 @@ it.live("reports an occupied saved port without moving its assignment", () => }); const ports = yield* makePorts(state); const scope = yield* Scope.make(); - const request = { stackId: "stack", key: "api", host: "127.0.0.1", port: "auto" as const }; + const request = { stackId, key: "api", host: "127.0.0.1", port: "auto" as const }; const first = yield* ports .acquire(request, bind) .pipe(Effect.provideService(Scope.Scope, scope)); @@ -76,7 +87,7 @@ it.live("reports an occupied saved port without moving its assignment", () => expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`api at 127.0.0.1:${first.port}`); expect(failure.message).not.toContain("claims this port"); - expect((yield* state.read("stack"))?.ports[0]?.port).toBe(first.port); + expect((yield* state.read(stackId))?.ports[0]?.port).toBe(first.port); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -198,10 +209,11 @@ it.live("allocates past siblings whose state is unreadable or from a newer forma `${root}/newer/state.json`, '{"id":"newer","runtime":"future","ports":[]}', ); - const ports = yield* makePorts(state); + const ports = yield* makePorts(state, "linux"); + const accept = (_host: string, port: number) => Effect.succeed(port); const acquired = yield* ports.acquire( { stackId: "healthy", key: "sql", host: "127.0.0.1", port: "auto" }, - bind, + accept, ); expect((yield* state.read("healthy"))?.ports).toEqual([ { key: "sql", host: "127.0.0.1", port: acquired.port }, @@ -218,7 +230,7 @@ it.live("keeps auto allocation off ports claimed by a sibling in a newer format" const root = yield* fs.makeTempDirectoryScoped(); const state = yield* makeTestState(root); yield* saveStack(state, root, "stack"); - const ports = yield* makePorts(state); + const ports = yield* makePorts(state, "linux"); const request = { stackId: "stack", key: "api", host: "127.0.0.1", port: "auto" as const }; const accept = (_host: string, port: number) => Effect.succeed(port); const preferred = yield* ports.acquire(request, accept); @@ -237,27 +249,28 @@ it.live("keeps auto allocation off ports claimed by a sibling in a newer format" it.live("lets a stack bind an explicit port that a stopped stack still claims", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "stopped"); - yield* saveStack(state, root, "current"); + const stoppedId = uniqueStackId("stopped"); + const currentId = uniqueStackId("current"); + yield* saveStack(state, root, stoppedId); + yield* saveStack(state, root, currentId); const ports = yield* makePorts(state); const stoppedScope = yield* Scope.make(); const stopped = yield* ports - .acquire({ stackId: "stopped", key: "db/sql", host: "127.0.0.1", port: "auto" }, bind) + .acquire({ stackId: stoppedId, key: "db/sql", host: "127.0.0.1", port: "auto" }, bind) .pipe(Effect.provideService(Scope.Scope, stoppedScope)); yield* Scope.close(stoppedScope, Exit.void); const current = yield* ports.acquire( - { stackId: "current", key: "db/sql", host: "127.0.0.1", port: stopped.port }, + { stackId: currentId, key: "db/sql", host: "127.0.0.1", port: stopped.port }, bind, ); expect(current.port).toBe(stopped.port); - expect((yield* state.read("stopped"))?.ports).toEqual([ + expect((yield* state.read(stoppedId))?.ports).toEqual([ { key: "db/sql", host: "127.0.0.1", port: stopped.port }, ]); - expect((yield* state.read("current"))?.ports).toEqual([ + expect((yield* state.read(currentId))?.ports).toEqual([ { key: "db/sql", host: "127.0.0.1", port: stopped.port }, ]); }), @@ -267,24 +280,25 @@ it.live("lets a stack bind an explicit port that a stopped stack still claims", it.live("names the stack claiming an explicit port that a live listener holds", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "holder"); - yield* saveStack(state, root, "current"); + const holderId = uniqueStackId("holder"); + const currentId = uniqueStackId("current"); + yield* saveStack(state, root, holderId); + yield* saveStack(state, root, currentId); const ports = yield* makePorts(state); const held = yield* ports.acquire( - { stackId: "holder", key: "db/sql", host: "127.0.0.1", port: "auto" }, + { stackId: holderId, key: "db/sql", host: "127.0.0.1", port: "auto" }, bind, ); const failure = yield* ports - .acquire({ stackId: "current", key: "db/sql", host: "127.0.0.1", port: held.port }, bind) + .acquire({ stackId: currentId, key: "db/sql", host: "127.0.0.1", port: held.port }, bind) .pipe(Effect.flip); expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`db/sql at 127.0.0.1:${held.port}`); - expect(failure.message).toContain(`stack "holder" on branch-holder in ${root}`); - expect((yield* state.read("current"))?.ports).toEqual([]); + expect(failure.message).toContain(`stack "${holderId}" on branch-${holderId} in ${root}`); + expect((yield* state.read(currentId))?.ports).toEqual([]); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -296,27 +310,28 @@ for (const [held, requested] of [ it.live(`rejects ${requested} on a port a ${held} listener holds where binds can overlap`, () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "holder"); - yield* saveStack(state, root, "current"); + const holderId = uniqueStackId("holder"); + const currentId = uniqueStackId("current"); + yield* saveStack(state, root, holderId); + yield* saveStack(state, root, currentId); const ports = yield* makePorts(state, "darwin"); const listener = yield* ports.acquire( - { stackId: "holder", key: "api", host: held, port: "auto" }, + { stackId: holderId, key: "api", host: held, port: "auto" }, bind, ); const overlappingBinds = yield* Ref.make(0); const failure = yield* ports - .acquire({ stackId: "current", key: "api", host: requested, port: listener.port }, () => + .acquire({ stackId: currentId, key: "api", host: requested, port: listener.port }, () => Ref.update(overlappingBinds, (count) => count + 1), ) .pipe(Effect.flip); expect(failure.message).toContain("already in use"); - expect(failure.message).toContain(`stack "holder"`); + expect(failure.message).toContain(`stack "${holderId}"`); expect(yield* Ref.get(overlappingBinds)).toBe(0); - expect((yield* state.read("current"))?.ports).toEqual([]); + expect((yield* state.read(currentId))?.ports).toEqual([]); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -324,20 +339,21 @@ for (const [held, requested] of [ it.live("leaves a fixed port to the bind where overlapping binds are rejected", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "holder"); - yield* saveStack(state, root, "current"); + const holderId = uniqueStackId("holder"); + const currentId = uniqueStackId("current"); + yield* saveStack(state, root, holderId); + yield* saveStack(state, root, currentId); const ports = yield* makePorts(state, "linux"); const listener = yield* ports.acquire( - { stackId: "holder", key: "api", host: "127.0.0.1", port: "auto" }, + { stackId: holderId, key: "api", host: "127.0.0.1", port: "auto" }, bind, ); const binds = yield* Ref.make(0); const acquired = yield* ports.acquire( - { stackId: "current", key: "api", host: "0.0.0.0", port: listener.port }, + { stackId: currentId, key: "api", host: "0.0.0.0", port: listener.port }, () => Ref.update(binds, (count) => count + 1), ); expect(acquired.port).toBe(listener.port); @@ -349,28 +365,29 @@ it.live("leaves a fixed port to the bind where overlapping binds are rejected", it.live("lets a stack bind a port that a running stack claims but does not listen on", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "running"); - yield* saveStack(state, root, "current"); + const runningId = uniqueStackId("running"); + const currentId = uniqueStackId("current"); + yield* saveStack(state, root, runningId); + yield* saveStack(state, root, currentId); const ports = yield* makePorts(state); yield* ports.acquire( - { stackId: "running", key: "admin", host: "127.0.0.1", port: "auto" }, + { stackId: runningId, key: "admin", host: "127.0.0.1", port: "auto" }, bind, ); const restScope = yield* Scope.make(); const rest = yield* ports - .acquire({ stackId: "running", key: "api", host: "127.0.0.1", port: "auto" }, bind) + .acquire({ stackId: runningId, key: "api", host: "127.0.0.1", port: "auto" }, bind) .pipe(Effect.provideService(Scope.Scope, restScope)); yield* Scope.close(restScope, Exit.void); const current = yield* ports.acquire( - { stackId: "current", key: "api", host: "127.0.0.1", port: rest.port }, + { stackId: currentId, key: "api", host: "127.0.0.1", port: rest.port }, bind, ); expect(current.port).toBe(rest.port); - expect((yield* state.read("current"))?.ports).toEqual([ + expect((yield* state.read(currentId))?.ports).toEqual([ { key: "api", host: "127.0.0.1", port: rest.port }, ]); }), @@ -380,29 +397,30 @@ it.live("lets a stack bind a port that a running stack claims but does not liste it.live("lets exactly one of two stacks sharing a saved port bind it when both start at once", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "first"); + const firstId = uniqueStackId("first"); + const secondId = uniqueStackId("second"); + yield* saveStack(state, root, firstId); const ports = yield* makePorts(state); - const request = { stackId: "first", key: "api", host: "127.0.0.1", port: "auto" as const }; + const request = { stackId: firstId, key: "api", host: "127.0.0.1", port: "auto" as const }; const seedScope = yield* Scope.make(); const seed = yield* ports .acquire(request, bind) .pipe(Effect.provideService(Scope.Scope, seedScope)); yield* Scope.close(seedScope, Exit.void); - yield* saveStack(state, root, "second", [{ key: "api", host: "127.0.0.1", port: seed.port }]); + yield* saveStack(state, root, secondId, [{ key: "api", host: "127.0.0.1", port: seed.port }]); const [first, second] = yield* Effect.all( [ Effect.exit(ports.acquire(request, bind)), - Effect.exit(ports.acquire({ ...request, stackId: "second" }, bind)), + Effect.exit(ports.acquire({ ...request, stackId: secondId }, bind)), ], { concurrency: "unbounded" }, ); const outcomes = [ - { claimant: "second", exit: first }, - { claimant: "first", exit: second }, + { claimant: secondId, exit: first }, + { claimant: firstId, exit: second }, ]; expect(outcomes.filter(({ exit }) => Exit.isSuccess(exit))).toHaveLength(1); const loser = outcomes.find(({ exit }) => Exit.isFailure(exit)); @@ -414,7 +432,7 @@ it.live("lets exactly one of two stacks sharing a saved port bind it when both s expect(failure.value).toBeInstanceOf(PortError); expect(failure.value.message).toContain(`127.0.0.1:${seed.port}`); expect(failure.value.message).toContain(`stack "${loser?.claimant}"`); - for (const id of ["first", "second"]) + for (const id of [firstId, secondId]) expect((yield* state.read(id))?.ports).toEqual([ { key: "api", host: "127.0.0.1", port: seed.port }, ]); @@ -425,13 +443,18 @@ it.live("lets exactly one of two stacks sharing a saved port bind it when both s // Fixed so a test can force two reservations to the same candidate; production uses randomPortSpanStart. const fixedStart = Effect.succeed(0); -// Binds a real listener directly in the native-reservation span, retrying past occupied -// candidates, instead of reserving then releasing a port that something else could grab meanwhile. -const bindBlockingPort = (host: string) => +const noClaimedPorts: ReadonlySet = new Set(); + +// Binds a real listener directly in the native-reservation span, retrying past occupied and +// claimed candidates, instead of reserving then releasing a port that something else could grab +// meanwhile. +const bindBlockingPort = (host: string, claimed: ReadonlySet = noClaimedPorts) => Effect.gen(function* () { for (let offset = 0; offset < portSpan; offset++) { - const attempt = yield* Effect.exit(bind(host, portBase + offset)); - if (Exit.isSuccess(attempt)) return { port: portBase + offset, listener: attempt.value }; + const port = portBase + offset; + if (claimed.has(port)) continue; + const attempt = yield* Effect.exit(bind(host, port)); + if (Exit.isSuccess(attempt)) return { port, listener: attempt.value }; } return yield* Effect.die("No port in the native reservation span was free for the fixture"); }); @@ -442,9 +465,10 @@ it.live("skips a native backend port claimed by another saved stack", () => const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped(); const state = yield* makeTestState(root); + const shared = yield* sharedPortClaims; const probeScope = yield* Scope.make(); - const probe = yield* reserveNativePort([], "pooler", fixedStart).pipe( + const probe = yield* reserveNativePort(shared, "pooler", fixedStart).pipe( Effect.provideService(Scope.Scope, probeScope), ); yield* Scope.close(probeScope, Exit.void); @@ -453,7 +477,13 @@ it.live("skips a native backend port claimed by another saved stack", () => { key: "db/sql", host: "127.0.0.1", port: probe.port }, ]); - const reserved = yield* reserveNativePort(yield* state.claims, "pooler", fixedStart); + // Starts at the claimed port with the same shared snapshot, so only the claim can skip it. + const claimedStart = Effect.succeed(probe.port - portBase); + const reserved = yield* reserveNativePort( + [...(yield* state.claims), ...shared], + "pooler", + claimedStart, + ); expect(reserved.port).not.toBe(probe.port); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -462,11 +492,13 @@ it.live("skips a native backend port claimed by another saved stack", () => it.live("skips a native backend port a wildcard listener holds", () => Effect.scoped( Effect.gen(function* () { + const claims = yield* sharedPortClaims; + const claimed = new Set(claims.flatMap((stack) => stack.ports.map((claim) => claim.port))); // A wildcard bind is reachable through loopback, so a loopback-only probe would miss it. - const blocked = yield* bindBlockingPort("0.0.0.0"); + const blocked = yield* bindBlockingPort("0.0.0.0", claimed); const forcedStart = Effect.succeed(blocked.port - portBase); - const reserved = yield* reserveNativePort([], "pooler", forcedStart); + const reserved = yield* reserveNativePort(claims, "pooler", forcedStart); expect(reserved.port).not.toBe(blocked.port); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -475,37 +507,120 @@ it.live("skips a native backend port a wildcard listener holds", () => it.live("excludes a port a previous attempt lost from the next reservation", () => Effect.scoped( Effect.gen(function* () { + // Read once and reuse, so claims cannot change between the two reservations and newly + // exclude the port on their own; only the `excluded` set may. + const claims = yield* sharedPortClaims; const firstScope = yield* Scope.make(); - const first = yield* reserveNativePort([], "pooler", fixedStart).pipe( + const first = yield* reserveNativePort(claims, "pooler", fixedStart).pipe( Effect.provideService(Scope.Scope, firstScope), ); yield* Scope.close(firstScope, Exit.void); - const second = yield* reserveNativePort([], "pooler", fixedStart, new Set([first.port])); + // Starts the scan at first's own port, so excluding it is the only reason the second + // reservation can move past it. + const secondStart = Effect.succeed(first.port - portBase); + const second = yield* reserveNativePort(claims, "pooler", secondStart, new Set([first.port])); expect(second.port).not.toBe(first.port); }), ).pipe(Effect.provide(NodeServices.layer)), ); +// Mirrors the unexported `scanStart` in ./Ports.ts; several stories below need a competitor id +// whose own first candidate collides with another stack's port. +const scanStart = (projectRoot: string, id: string, key: string) => + Math.abs(Hash.string(`${projectRoot}:${id}:${key}`)) % portSpan; + +/** Brute-forces a stack id whose first automatic candidate is `target`, dying if none is found. */ +const findScanStartCollision = (projectRoot: string, key: string, target: number) => + Effect.gen(function* () { + for (let suffix = 0; suffix < 200_000; suffix++) { + const candidate = `competitor-${suffix}`; + if (scanStart(projectRoot, candidate, key) === target) return candidate; + } + return yield* Effect.die(`No id shares candidate ${target} as its own first scan candidate`); + }); + it.live("skips a public auto candidate a loopback listener already holds", () => Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped(); + const root = sharedStateRoot(); const state = yield* makeTestState(root); - yield* saveStack(state, root, "stack"); + const stackId = uniqueStackId("stack"); + yield* saveStack(state, root, stackId); const ports = yield* makePorts(state); // A container-runtime stack's bind to the wildcard host would otherwise succeed here too. - const request = { stackId: "stack", key: "api", host: "0.0.0.0", port: "auto" as const }; + const request = { stackId, key: "api", host: "0.0.0.0", port: "auto" as const }; const accept = (_host: string, port: number) => Effect.succeed(port); - // A loopback-only listener keeps occupying the port while only its saved claim is released, - // so the next auto allocation has to skip it for real via loopbackOccupied, not a real bind. + // Releasing the claim, rather than closing its scope, leaves the listener live while + // freeing the port for anyone's claims-based scan. const probe = yield* ports.acquire(request, (_host, port) => bind("127.0.0.1", port)); - yield* ports.release("stack", "api"); + yield* ports.release(stackId, "api"); - const acquired = yield* ports.acquire(request, accept); + // A competitor whose own first candidate is probe's port proves the skip can only come + // from loopbackOccupied finding the still-live listener, not from a claims-based skip. + const competitorId = yield* findScanStartCollision(root, "api", probe.port - portBase); + yield* saveStack(state, root, competitorId); + + const acquired = yield* ports.acquire({ ...request, stackId: competitorId }, accept); expect(acquired.port).not.toBe(probe.port); }), ).pipe(Effect.provide(NodeServices.layer)), ); + +it.live( + "keeps automatic allocation off a stopped sibling's saved port that is its own first candidate", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "ports-sibling-project-", + }); + const key = "sql"; + const firstId = uniqueStackId("first"); + const accept = (_host: string, port: number) => Effect.succeed(port); + const request = { stackId: firstId, key, host: "127.0.0.1", port: "auto" as const }; + + // `first` lives on the shared root, so every other suite allocator already sees its + // claim once saved; its actual port (not a prediction) names the candidate to collide. + const sharedRoot = sharedStateRoot(); + const sharedState = yield* makeTestState(sharedRoot); + yield* saveStack(sharedState, projectRoot, firstId); + const sharedPorts = yield* makePorts(sharedState, "linux"); + const firstScope = yield* Scope.make(); + const firstAcquired = yield* sharedPorts + .acquire(request, accept) + .pipe(Effect.provideService(Scope.Scope, firstScope)); + yield* Scope.close(firstScope, Exit.void); + const competitorId = yield* findScanStartCollision( + projectRoot, + key, + firstAcquired.port - portBase, + ); + + // Proves the prerequisite: alone, in a private root, the competitor's own first + // candidate is the same port. + const verifyRoot = yield* fs.makeTempDirectoryScoped({ prefix: "ports-sibling-verify-" }); + const verifyState = yield* makeTestState(verifyRoot); + yield* saveStack(verifyState, projectRoot, competitorId); + const verifyPorts = yield* makePorts(verifyState, "linux"); + const verifyAcquired = yield* verifyPorts.acquire( + { ...request, stackId: competitorId }, + accept, + ); + expect(verifyAcquired.port).toBe(firstAcquired.port); + + yield* saveStack(sharedState, projectRoot, competitorId); + const competitorAcquired = yield* sharedPorts.acquire( + { ...request, stackId: competitorId }, + accept, + ); + expect(competitorAcquired.port).not.toBe(firstAcquired.port); + + const reopenedPorts = yield* makePorts(yield* makeTestState(sharedRoot), "linux"); + const firstReacquired = yield* reopenedPorts.acquire(request, accept); + expect(firstReacquired.port).toBe(firstAcquired.port); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index 4c0971f97c..c446ff2b45 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -192,7 +192,7 @@ it.live.skipIf(process.platform === "win32")( ), jwtExpiry: 3600, }, - endpoints: { sql: { port: "auto" } }, + endpoints: {}, }), ), Effect.flatMap((database) => diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 0bb65d4a65..aed4d805bd 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -36,6 +36,8 @@ import { bindControl, makeRuntime } from "./StackHost.ts"; import { shutdownOwner } from "../tests/owner.ts"; import { postgres } from "./Commands.ts"; import * as CommandRunner from "./host/CommandRunner.ts"; +import { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; +import { registerLeased } from "../tests/owner-rpc.ts"; class HostTestError extends Data.TaggedError("HostTestError")<{ readonly message: string }> {} @@ -137,12 +139,13 @@ const inProcessRuntime = ( owner: Parameters[0], state: State.Interface, root: string, + stackId = "stack", ) => Effect.gen(function* () { const acquired = yield* bindControl(); const toolContext = yield* Layer.build( CommandRunner.layer({ - stackId: "stack", + stackId, root, cacheRoot: "/tmp/supabase-stack-artifacts", runtime: "native", @@ -152,7 +155,7 @@ const inProcessRuntime = ( owner, { endpoint: { - stackId: "stack", + stackId, identity: { projectRoot: root, branchContext: "main", stackName: "host" }, pid: process.pid, port: acquired.port, @@ -217,9 +220,10 @@ it.live("preserves composition outcomes over RPC", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-host-outcomes-" }); - const state = yield* stateFor(`${root}/state`); + const state = yield* stateFor(sharedStateRoot()); + const stackId = uniqueStackId("stack-host-outcomes"); const saved = { - id: "stack", + id: stackId, runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-outcomes" }, instances: [], @@ -227,14 +231,14 @@ it.live("preserves composition outcomes over RPC", () => composition: { members: [], dependencies: [] }, ports: [], }; - yield* state.save(saved); + yield* registerLeased(state, saved); const owner = yield* ownerFor({ saved, state, root: `${root}/data`, cacheRoot: "/tmp/supabase-stack-artifacts", }); - const { runtime } = yield* inProcessRuntime(owner, state, root); + const { runtime } = yield* inProcessRuntime(owner, state, root, stackId); const client = yield* ownerClient(runtime.access); const port = yield* occupiedPort; const lazy = yield* client.createService({ @@ -501,9 +505,11 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-host-" }); - const state = yield* stateFor(`${root}/state`); + const stateRoot = sharedStateRoot(); + const state = yield* stateFor(stateRoot); + const stackId = uniqueStackId("stack-host"); yield* state.save({ - id: "stack", + id: stackId, runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host" }, instances: [], @@ -512,9 +518,9 @@ it.live( ports: [], }); const access = yield* launchHost(state, { - stateRoot: `${root}/state`, + stateRoot, cacheRoot: "/tmp/supabase-stack-artifacts", - stackId: "stack", + stackId, }); const { endpoint } = access; const http = yield* HttpClient.HttpClient; @@ -809,13 +815,14 @@ it.live("withdraws a command waiting for its prerequisite", () => ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -const disconnectFixture = (prefix: string) => +const disconnectFixture = (prefix: string, overrides: { readonly stateRoot?: string } = {}) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix }); - const state = yield* stateFor(`${root}/state`); + const stackId = overrides.stateRoot === undefined ? "stack" : uniqueStackId(prefix); + const state = yield* stateFor(overrides.stateRoot ?? `${root}/state`); const saved: State.SavedStack = { - id: "stack", + id: stackId, runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: prefix }, instances: [], @@ -958,7 +965,9 @@ it.live("persists a composition change after its caller disconnects", () => const abandonedComposition = (prefix: string, destroy: boolean) => Effect.gen(function* () { - const { root, state, saved } = yield* disconnectFixture(prefix); + const { root, state, saved } = yield* disconnectFixture(prefix, { + stateRoot: sharedStateRoot(), + }); const persisted = yield* Deferred.make(); const allow = yield* Deferred.make(); yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); @@ -1001,6 +1010,7 @@ const abandonedComposition = (prefix: string, destroy: boolean) => }, state, root, + saved.id, ); const client = yield* ownerClient(runtime.access); const composition = yield* Effect.forkScoped( diff --git a/packages/stack/src/State.vector-migration.integration.test.ts b/packages/stack/src/State.vector-migration.integration.test.ts index 29dca51126..ae65cfadd0 100644 --- a/packages/stack/src/State.vector-migration.integration.test.ts +++ b/packages/stack/src/State.vector-migration.integration.test.ts @@ -71,8 +71,8 @@ const legacyDocument = (id: string, projectRoot: string) => ({ ], }, ports: [ - { key: "vector:http", host: "127.0.0.1", port: 24_501 }, - { key: "mail:http", host: "127.0.0.1", port: 24_502 }, + { key: "vector:http", host: "127.0.0.1", port: 19_501 }, + { key: "mail:http", host: "127.0.0.1", port: 19_502 }, ], }); diff --git a/packages/stack/src/composition/Supabase.native.integration.test.ts b/packages/stack/src/composition/Supabase.native.integration.test.ts index fffc8cc9b5..8333c5ef53 100644 --- a/packages/stack/src/composition/Supabase.native.integration.test.ts +++ b/packages/stack/src/composition/Supabase.native.integration.test.ts @@ -18,7 +18,8 @@ import { SignJWT } from "jose"; import { tmpdir } from "node:os"; import * as State from "../State.ts"; import type { SavedStack } from "../State.ts"; -import { ownerFor } from "../../tests/owner-rpc.ts"; +import { ownerFor, registerLeased } from "../../tests/owner-rpc.ts"; +import { sharedStateRoot, uniqueStackId } from "../../tests/helpers/integration-state.ts"; import { makeSupabaseComposition, SupabaseCompositionError, @@ -26,9 +27,10 @@ import { } from "./Supabase.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; -// Below every OS ephemeral range, so another test's outbound socket cannot already hold them. -const FIXED_STUDIO_PORT = 24_391; -const FIXED_MAIL_PORT = 24_392; +// Below the automatic port span and every OS ephemeral range, so another test's outbound socket +// or auto-allocated listener cannot already hold them. +const FIXED_STUDIO_PORT = 19_391; +const FIXED_MAIL_PORT = 19_392; const stateFor = (root: string) => Effect.gen(function* () { @@ -92,9 +94,9 @@ it.live("removes a managed SMTP binding when Mail is excluded", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-exclude-mail-" }); - const saved = initial("catalog-exclude-mail"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(saved); + const saved = initial(uniqueStackId("catalog-exclude-mail")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, saved); const owner = yield* ownerFor({ saved, state, root: `${root}/data`, cacheRoot }); yield* Effect.acquireUseRelease( Effect.succeed(owner), @@ -191,9 +193,9 @@ it.live( const fs = yield* FileSystem.FileSystem; const client = yield* HttpClient.HttpClient; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-native-auth-storage-" }); - const stack = initial("catalog-native-auth-storage"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("catalog-native-auth-storage")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const storageRoot = `${root}/storage`; yield* fs.makeDirectory(storageRoot, { recursive: true }); const owner = yield* ownerFor({ @@ -335,9 +337,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-native-reuse-" }); - const stack = initial("catalog-native-reuse"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("catalog-native-reuse")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -431,9 +433,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-native-studio-reuse-" }); - const stack = initial("catalog-native-studio-reuse"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("catalog-native-studio-reuse")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -516,9 +518,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-native-reuse-running-" }); - const stack = initial("catalog-native-reuse-running"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("catalog-native-reuse-running")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, @@ -560,9 +562,9 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-native-reuse-cleanup-" }); - const stack = initial("catalog-native-reuse-cleanup"); - const state = yield* stateFor(`${root}/state`); - yield* state.save(stack); + const stack = initial(uniqueStackId("catalog-native-reuse-cleanup")); + const state = yield* stateFor(sharedStateRoot()); + yield* registerLeased(state, stack); const owner = yield* ownerFor({ saved: stack, state, diff --git a/packages/stack/src/credentials.integration.test.ts b/packages/stack/src/credentials.integration.test.ts index c66ce6dfc6..d04fa0a847 100644 --- a/packages/stack/src/credentials.integration.test.ts +++ b/packages/stack/src/credentials.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Redacted } from "effect"; import { tmpdir } from "node:os"; import { create, open } from "./effect.ts"; +import { sharedStateRoot } from "../tests/helpers/integration-state.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); @@ -52,7 +53,7 @@ it.live("resolves composition credentials before creating services", () => const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-composition-credentials-" }); const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot: sharedStateRoot(), cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, runtime: "native", }); diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 723bb24fc1..5137c51979 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -32,11 +32,13 @@ import * as State from "./State.ts"; import { assertOwnerExited, watchLeaseRelease } from "../tests/owner.ts"; import { foreignRelease } from "../tests/release-owner-fixture.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; +import { sharedStateRoot } from "../tests/helpers/integration-state.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -// Below every OS ephemeral range, so another test's outbound socket cannot already hold it. -const FIXED_API_PORT = 24_393; +// Below the automatic port span and every OS ephemeral range, so another test's outbound socket +// or auto-allocated listener cannot already hold it. +const FIXED_API_PORT = 19_393; const databaseOwnerMarker = Schema.fromJsonString( Schema.Struct({ stackId: Schema.String, instanceId: Schema.String }), ); @@ -222,7 +224,7 @@ const resetDataStory = (runtime: "native" | "docker") => const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-reset-data-${runtime}-` }); const options = { projectRoot: root, - stateRoot: `${root}/state`, + stateRoot: sharedStateRoot(), cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, runtime, } satisfies Parameters[0]; @@ -851,9 +853,10 @@ it.live("plans requested creations against the saved composition and honours eag Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-" }); + const stateRoot = sharedStateRoot(); const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot, cacheRoot: `${root}/cache`, runtime: "native", }); @@ -908,7 +911,7 @@ it.live("plans requested creations against the saved composition and honours eag { id: authId, service: "auth", member: true, change: "unchanged" }, ]); const client = yield* Effect.promise(() => - PromiseApi.open({ id: stack.id, stateRoot: `${root}/state`, cacheRoot: `${root}/cache` }), + PromiseApi.open({ id: stack.id, stateRoot, cacheRoot: `${root}/cache` }), ); const promisePlan = yield* Effect.promise(() => client.composition @@ -960,7 +963,7 @@ it.live("plans a Studio public API URL the project sets but not the one the stac const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-studio-" }); const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot: sharedStateRoot(), cacheRoot: `${root}/cache`, runtime: "native", }); @@ -1013,7 +1016,7 @@ it.live("plans a project's own URL for an input whose supplying member is absent const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-unbound-" }); const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot: sharedStateRoot(), cacheRoot: `${root}/cache`, runtime: "native", }); diff --git a/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts index 240d72a977..e56769067a 100644 --- a/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts +++ b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts @@ -1,20 +1,31 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path, Stream } from "effect"; +import { beforeAll, expect, it } from "@effect/vitest"; +import { Effect, Context, FileSystem, Layer, Stream } from "effect"; import { systemError } from "effect/PlatformError"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { tmpdir } from "node:os"; +import { prepareNativeArtifact, postgresVersion } from "../Artifacts.ts"; import { postgres } from "../Commands.ts"; import * as CommandRunner from "./CommandRunner.ts"; +const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; + +// Downloads psql under the hook timeout, so the test timeout covers only the cleanup retries. +beforeAll(() => + Effect.runPromise( + prepareNativeArtifact({ service: "database", version: postgresVersion("17") }, cacheRoot).pipe( + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + ), +); + it.live.skipIf(process.platform === "win32")( "retries failed native workload cleanup when the stack runner is cleaned up", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "native-runner-cleanup-" }); - const cacheRoot = path.join(root, "cache"); let isRunningCalls = 0; const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => diff --git a/packages/stack/src/lifetime.integration.test.ts b/packages/stack/src/lifetime.integration.test.ts index da1a17a058..8d0a316c54 100644 --- a/packages/stack/src/lifetime.integration.test.ts +++ b/packages/stack/src/lifetime.integration.test.ts @@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url"; import { create, open } from "./effect.ts"; import * as State from "./State.ts"; import { assertOwnerExited, captureOwnerPid, watchLeaseRelease } from "../tests/owner.ts"; +import { sharedStateRoot } from "../tests/helpers/integration-state.ts"; class LifetimeTestError extends Data.TaggedError("LifetimeTestError")<{ readonly message: string; @@ -98,10 +99,10 @@ it.live.skipIf(process.platform === "win32")( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-kill-" }); - const stateRoot = `${root}/state`; + const stateRoot = sharedStateRoot(); const creator = yield* ChildProcess.make( process.execPath, - [sessionFixture, root, cacheRoot], + [sessionFixture, root, cacheRoot, stateRoot], { stdin: "ignore", stdout: "pipe", diff --git a/packages/stack/src/promise-api.integration.test.ts b/packages/stack/src/promise-api.integration.test.ts index 394f99e4ea..9dd233999a 100644 --- a/packages/stack/src/promise-api.integration.test.ts +++ b/packages/stack/src/promise-api.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; import { discover, StackError, type LogRecord, type Observation } from "./index.ts"; import { createTestStack } from "./testing.ts"; +import { sharedStateRoot } from "../tests/helpers/integration-state.ts"; const databaseSecret = (observation: Observation) => observation.config.service === "database" @@ -13,8 +14,7 @@ it.live( "returns observations as data, with exits and Redacted secrets intact", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-state-" }); + const stateRoot = sharedStateRoot(); const test = yield* Effect.acquireRelease( Effect.promise(() => createTestStack({ runtime: "native", stateRoot })), (created) => Effect.promise(() => created[Symbol.asyncDispose]()), @@ -55,7 +55,10 @@ it.live( () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-failure-" }); + const stateRoot = sharedStateRoot(); + const projectRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-promise-failure-project-", + }); const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); const port = occupied.address.port; @@ -65,6 +68,7 @@ it.live( services: [{ service: "mail", endpoints: { http: { port } } }], runtime: "native", stateRoot, + projectRoot, }).then( () => undefined, (error: unknown) => error, @@ -73,7 +77,11 @@ it.live( expect(Schema.is(StackError)(failure)).toBe(true); expect(Schema.is(StackError)(failure) ? failure.operation : undefined).toBe("test-startup"); - expect(yield* Effect.promise(() => discover({ stateRoot }))).toEqual([]); + const resolvedProjectRoot = yield* fs.realPath(projectRoot); + const ownEntries = (yield* Effect.promise(() => discover({ stateRoot }))).filter( + ({ definition }) => definition.identity.projectRoot === resolvedProjectRoot, + ); + expect(ownEntries).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), { timeout: 120_000 }, ); diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 0b667ca653..2d5830d422 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -22,6 +22,7 @@ import { makeService } from "../Service.ts"; import type * as State from "../State.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import * as Functions from "./Functions.ts"; +import { sharedPortClaims } from "../../tests/helpers/integration-state.ts"; // No saved stacks to consult; this test never launches the native backend it configures. const testReadPortClaims = Effect.succeed([]); @@ -207,7 +208,7 @@ describe("service catalog", () => { instanceId: "ancestor", cacheRoot: "/tmp/supabase-stack-artifacts", }, - Effect.succeed([]), + sharedPortClaims, ); const logs = yield* Ref.make(""); yield* Stream.fromSubscription(yield* recipe.logs).pipe( @@ -276,7 +277,7 @@ describe("service catalog", () => { instanceId: "deno-config", cacheRoot: "/tmp/supabase-stack-artifacts", }, - Effect.succeed([]), + sharedPortClaims, ); const logs = yield* Ref.make(""); yield* Stream.fromSubscription(yield* recipe.logs).pipe( @@ -348,7 +349,7 @@ describe("service catalog", () => { instanceId: "plain-deno-config", cacheRoot: "/tmp/supabase-stack-artifacts", }, - Effect.succeed([]), + sharedPortClaims, ); const logs = yield* Ref.make(""); const warned = yield* Deferred.make(); @@ -459,7 +460,7 @@ for (const runtime of ["native", "docker"] as const) { runtime, cacheRoot: "/tmp/supabase-stack-artifacts", }, - Effect.succeed([]), + sharedPortClaims, ); const logs = yield* Ref.make(""); yield* Stream.fromSubscription(yield* recipe.logs).pipe( diff --git a/packages/stack/src/services/Mail.integration.test.ts b/packages/stack/src/services/Mail.integration.test.ts index 1b6b262f88..698589bf3d 100644 --- a/packages/stack/src/services/Mail.integration.test.ts +++ b/packages/stack/src/services/Mail.integration.test.ts @@ -4,6 +4,7 @@ import { Effect, Exit, FileSystem, Layer, Path } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { makeService } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; +import { sharedPortClaims } from "../../tests/helpers/integration-state.ts"; const options = (root: string) => ({ stackId: "catalog-mail", @@ -94,7 +95,7 @@ describe("service catalog", () => { makeServiceRecipe( { service: "mail", config: {} }, { ...options(root), instanceId }, - Effect.succeed([]), + sharedPortClaims, ); const first = yield* recipeFor("mail-a"); const second = yield* recipeFor("mail-b"); @@ -129,7 +130,7 @@ describe("service catalog", () => { const recipe = yield* makeServiceRecipe( { service: "mail", config: {} }, options(root), - Effect.succeed([]), + sharedPortClaims, ); const instance = yield* makeService(recipe.definition, { id: "mail", @@ -198,7 +199,7 @@ describe("service catalog", () => { const recipe = yield* makeServiceRecipe( { service: "mail", config: {} }, options(root), - Effect.succeed([]), + sharedPortClaims, ); const instance = yield* makeService(recipe.definition, { id: "mail", @@ -223,7 +224,7 @@ describe("service catalog", () => { const recipe = yield* makeServiceRecipe( { service: "mail", config: {} }, { ...options(root), instanceId: "../escaped" }, - Effect.succeed([]), + sharedPortClaims, ); const instance = yield* makeService(recipe.definition, { id: "mail", diff --git a/packages/stack/src/services/Pooler.integration.test.ts b/packages/stack/src/services/Pooler.integration.test.ts index e853e7f01f..7aa7a690dd 100644 --- a/packages/stack/src/services/Pooler.integration.test.ts +++ b/packages/stack/src/services/Pooler.integration.test.ts @@ -7,6 +7,7 @@ import { ProxyError } from "../Proxy.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerTcpRelay } from "../../tests/docker-relay.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { sharedPortClaims } from "../../tests/helpers/integration-state.ts"; const options = (root: string) => ({ stackId: "catalog-pooler", @@ -71,7 +72,7 @@ describe("service catalog", () => { }, }, runtime === "native" ? options(root) : dockerOptions(root), - Effect.succeed([]), + sharedPortClaims, ); const pooler = yield* makeService(poolerRecipe.definition, { id: `pooler-${runtime}-${poolMode}`, diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index d91b480694..f922e5ad85 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -48,6 +48,7 @@ import { import type { CatalogOptions, RecipeCreation } from "./Recipe.ts"; import * as Realtime from "./Realtime.ts"; import * as Pooler from "./Pooler.ts"; +import { sharedPortClaims } from "../../tests/helpers/integration-state.ts"; type TestCreation = RecipeCreation<"rest", Record> & { readonly service: "rest"; @@ -100,8 +101,7 @@ const isPortOccupied = (port: number): Effect.Effect => }); }); -// No saved stacks to consult. -const testReadPortClaims = Effect.succeed([]); +const testReadPortClaims = sharedPortClaims; const testReserveNativePort = ( key: string, claims: ReadonlyArray, diff --git a/packages/stack/src/services/Rest.integration.test.ts b/packages/stack/src/services/Rest.integration.test.ts index 0f902e8bbe..28eef378db 100644 --- a/packages/stack/src/services/Rest.integration.test.ts +++ b/packages/stack/src/services/Rest.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path, Redacted, Ref } from "effect"; +import { Context, Effect, FileSystem, Layer, Redacted, Ref } from "effect"; import { PgClient } from "@effect/sql-pg"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SignJWT } from "jose"; @@ -11,6 +11,11 @@ import { makeService } from "../Service.ts"; import { ProxyError } from "../Proxy.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { + sharedPortClaims, + sharedStateRoot, + uniqueStackId, +} from "../../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -47,11 +52,11 @@ describe("service catalog", () => { () => Effect.scoped( Effect.gen(function* () { - const path = yield* Path.Path; const client = yield* HttpClient.HttpClient; - const root = yield* makeDockerDatabaseRoot("catalog-rest-"); - const stackId = "catalog-network"; - const state = yield* makeTestState(path.dirname(path.dirname(path.dirname(root)))); + const stackId = uniqueStackId("catalog-network"); + const stateRoot = sharedStateRoot(); + const root = yield* makeDockerDatabaseRoot("catalog-rest-", stackId, { stateRoot }); + const state = yield* makeTestState(stateRoot); yield* state.save({ id: stackId, identity: { projectRoot: root, branchContext: "test", stackName: "catalog" }, @@ -218,7 +223,7 @@ describe("service catalog", () => { }, }, { ...options(root), stackId, cacheRoot: `${tmpdir()}/supabase-stack-artifacts` }, - Effect.succeed([]), + sharedPortClaims, ); const database = yield* makeService(databaseRecipe.definition, { id: "database", @@ -259,7 +264,7 @@ describe("service catalog", () => { }, }, { ...options(root), stackId, cacheRoot: `${tmpdir()}/supabase-stack-artifacts` }, - Effect.succeed([]), + sharedPortClaims, ); const rest = yield* makeService(restRecipe.definition, { id: "rest", diff --git a/packages/stack/src/services/StorageGateway.integration.test.ts b/packages/stack/src/services/StorageGateway.integration.test.ts index 857e6b9460..bdbc194135 100644 --- a/packages/stack/src/services/StorageGateway.integration.test.ts +++ b/packages/stack/src/services/StorageGateway.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted } from "effect"; +import { Context, Crypto, Effect, FileSystem, Layer, Redacted } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { tmpdir } from "node:os"; import { @@ -11,7 +11,8 @@ import { import * as State from "../State.ts"; import type { SavedStack } from "../State.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; -import { ownerFor } from "../../tests/owner-rpc.ts"; +import { ownerFor, registerLeased } from "../../tests/owner-rpc.ts"; +import { sharedStateRoot } from "../../tests/helpers/integration-state.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "storage-gateway-secret-with-at-least-32-chars"; @@ -23,19 +24,15 @@ const s3Credentials = { const layout = Effect.fnUntraced(function* (runtime: SavedStack["runtime"], stackId: string) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + const stateRoot = sharedStateRoot(); if (runtime === "native") { const root = yield* fs.makeTempDirectoryScoped({ prefix: "storage-gateway-native-" }); - return { stateRoot: `${root}/state`, dataRoot: `${root}/data`, storageRoot: `${root}/storage` }; + return { stateRoot, dataRoot: `${root}/data`, storageRoot: `${root}/storage` }; } - const dataRoot = yield* makeDockerDatabaseRoot("storage-gateway-docker-", stackId).pipe( - Effect.flatMap(fs.realPath), - ); - return { - stateRoot: path.dirname(path.dirname(dataRoot)), - dataRoot, - storageRoot: `${dataRoot}/storage`, - }; + const dataRoot = yield* makeDockerDatabaseRoot("storage-gateway-docker-", stackId, { + stateRoot, + }).pipe(Effect.flatMap(fs.realPath)); + return { stateRoot, dataRoot, storageRoot: `${dataRoot}/storage` }; }); /** Starts Database and Storage in an owned stack and returns Storage's gateway URL. */ @@ -55,7 +52,7 @@ const serveStorage = Effect.fnUntraced(function* (runtime: SavedStack["runtime"] ports: [], }; const state = Context.get(yield* Layer.build(State.layer({ root: stateRoot })), State.Service); - yield* state.save(saved); + yield* registerLeased(state, saved); const owner = yield* ownerFor({ saved, state, root: dataRoot, cacheRoot }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const created = yield* owner.rpc.supabaseComposition({ diff --git a/packages/stack/src/testing.integration.test.ts b/packages/stack/src/testing.integration.test.ts index 8f92f1b45e..aa8d7ccb6f 100644 --- a/packages/stack/src/testing.integration.test.ts +++ b/packages/stack/src/testing.integration.test.ts @@ -16,6 +16,7 @@ import { import { discover, type Stack } from "./effect.ts"; import { makeTestStack } from "./testing.ts"; import { postgres } from "./Commands.ts"; +import { sharedStateRoot } from "../tests/helpers/integration-state.ts"; const sql = (stack: Stack, databaseUrl: string, command: string) => Effect.gen(function* () { @@ -45,7 +46,7 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-state-" }); + const stateRoot = sharedStateRoot(); const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-cache-" }); const exercise = (label: string) => Effect.gen(function* () { @@ -75,12 +76,19 @@ it.live( expect(unknown.message).toContain("the database data was not reset"); expect(yield* rows).toBe(`${label}0,${label}1,${label}2`); expect(yield* snapshotDescriptors(path.join(stateRoot, test.stack.id))).toHaveLength(4); + return test.stack.id; }).pipe(Effect.scoped); - yield* Effect.all([exercise("a"), exercise("b")], { concurrency: "unbounded" }); + const [aId, bId] = yield* Effect.all([exercise("a"), exercise("b")], { + concurrency: "unbounded", + }); - expect(yield* discover({ stateRoot })).toEqual([]); - expect(yield* snapshotDescriptors(stateRoot)).toEqual([]); + const discovered = yield* discover({ stateRoot }); + expect( + discovered.some(({ definition }) => definition.id === aId || definition.id === bId), + ).toBe(false); + expect(yield* snapshotDescriptors(path.join(stateRoot, aId))).toEqual([]); + expect(yield* snapshotDescriptors(path.join(stateRoot, bId))).toEqual([]); expect(yield* snapshotDescriptors(cacheRoot)).toEqual([]); }).pipe( Effect.scoped, @@ -95,9 +103,7 @@ it.live( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stateRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "stack-testing-partial-reset-", - }); + const stateRoot = sharedStateRoot(); const test = yield* makeTestStack({ runtime: "native", stateRoot }); yield* test.checkpoint("0"); @@ -131,8 +137,7 @@ it.live( "restarts the composition when a checkpoint is interrupted after the stack stops", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-interrupt-" }); + const stateRoot = sharedStateRoot(); const test = yield* makeTestStack({ runtime: "native", stateRoot }); const database = test.services.database; const subscribed = yield* Deferred.make(); @@ -172,7 +177,10 @@ it.live( () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-failure-" }); + const stateRoot = sharedStateRoot(); + const projectRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-testing-failure-project-", + }); const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); const port = occupied.address.port; @@ -181,6 +189,7 @@ it.live( services: [{ service: "mail", endpoints: { http: { port } } }], runtime: "native", stateRoot, + projectRoot, }).pipe(Effect.scoped, Effect.exit); expect(Exit.isFailure(startup)).toBe(true); @@ -193,7 +202,11 @@ it.live( expect(failure.value.message).toMatch(new RegExp(`\\b${port}\\b.*\\bin use\\b`)); expect(failure.value.message).toContain("Services: none"); expect(failure.value.message).toMatch(/Owner log: .+\/owner\.log$/); - expect(yield* discover({ stateRoot })).toEqual([]); + const resolvedProjectRoot = yield* fs.realPath(projectRoot); + const ownEntries = (yield* discover({ stateRoot })).filter( + ({ definition }) => definition.identity.projectRoot === resolvedProjectRoot, + ); + expect(ownEntries).toEqual([]); }).pipe( Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), @@ -205,17 +218,18 @@ it.live( "closes a test stack that its test already destroyed", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-destroyed-" }); + const stateRoot = sharedStateRoot(); - const afterDestroy = yield* Effect.gen(function* () { + const { afterDestroy, stackId } = yield* Effect.gen(function* () { const test = yield* makeTestStack({ services: ["mail"], runtime: "native", stateRoot }); yield* test.stack.destroy; - return yield* test.stack.composition.start.pipe(Effect.flip); + const afterDestroy = yield* test.stack.composition.start.pipe(Effect.flip); + return { afterDestroy, stackId: test.stack.id }; }).pipe(Effect.scoped); expect(afterDestroy.reason).toBe("owner-unavailable"); - expect(yield* discover({ stateRoot })).toEqual([]); + const discovered = yield* discover({ stateRoot }); + expect(discovered.some(({ definition }) => definition.id === stackId)).toBe(false); }).pipe( Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index a900828687..8b87bc90a6 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -24,16 +24,18 @@ export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray< ); /** - * Allocates the documented state-root layout used by Docker database fixtures. The volume this - * state root's Docker database creates is removed by the `SUPABASE_STACK_TEST_RUN` test-run - * cleanup (see `tests/docker-volume-run.ts`), not by this fixture. + * Allocates the documented state-root layout used by Docker database fixtures, under a fresh + * private root or, with `stateRoot`, under an existing one such as the shared integration root. + * The volume this state root's Docker database creates is removed by the `SUPABASE_STACK_TEST_RUN` + * test-run cleanup (see `tests/docker-volume-run.ts`), not by this fixture. */ export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( - (prefix: string, stackId = "catalog-test") => + (prefix: string, stackId = "catalog-test", options: { readonly stateRoot?: string } = {}) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const temporaryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); - const root = `${temporaryRoot}/state/${stackId}/data`; + const stateRoot = + options.stateRoot ?? `${yield* fs.makeTempDirectoryScoped({ prefix })}/state`; + const root = `${stateRoot}/${stackId}/data`; yield* fs.makeDirectory(root, { recursive: true }); yield* Effect.addFinalizer(() => Effect.gen(function* () { diff --git a/packages/stack/tests/helpers/integration-provided-context.ts b/packages/stack/tests/helpers/integration-provided-context.ts new file mode 100644 index 0000000000..55780ff3a3 --- /dev/null +++ b/packages/stack/tests/helpers/integration-provided-context.ts @@ -0,0 +1,9 @@ +// Vitest evaluates global setup separately from test modules. Keep this module +// side-effect-free so global setup can provide the shared state root. +export {}; + +declare module "vitest" { + export interface ProvidedContext { + stackStateRoot: string; + } +} diff --git a/packages/stack/tests/helpers/integration-state.ts b/packages/stack/tests/helpers/integration-state.ts new file mode 100644 index 0000000000..8cdddfe9a8 --- /dev/null +++ b/packages/stack/tests/helpers/integration-state.ts @@ -0,0 +1,28 @@ +import { NodeServices } from "@effect/platform-node"; +import { Context, Effect, Layer } from "effect"; +import { randomUUID } from "node:crypto"; +import { inject } from "vitest"; +import * as State from "../../src/State.ts"; + +/** The state root every integration test that binds a real auto-allocated port shares. */ +export const sharedStateRoot = (): string => inject("stackStateRoot"); + +/** + * The shared root's current port claims, so a test's own public or native backend port + * reservation skips ports a sibling test's stack already saved there. + */ +export const sharedPortClaims: Effect.Effect< + ReadonlyArray, + State.StateError +> = Effect.scoped( + Layer.build(State.layer({ root: sharedStateRoot() })).pipe( + Effect.flatMap((context) => Context.get(context, State.Service).claims), + ), +).pipe(Effect.provide(NodeServices.layer)); + +/** + * A stack id unique to this call, safe for `State`'s id pattern (`SafeId` in `src/State.ts`) and + * short enough to fit the 64-character stack id native services enforce (`src/services/Database.ts`). + */ +export const uniqueStackId = (prefix: string): string => + `${prefix}-${randomUUID().replaceAll("-", "").slice(0, 12)}`; diff --git a/packages/stack/tests/integration-global-setup.ts b/packages/stack/tests/integration-global-setup.ts new file mode 100644 index 0000000000..0ad8d8fae8 --- /dev/null +++ b/packages/stack/tests/integration-global-setup.ts @@ -0,0 +1,36 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem } from "effect"; +import type { ProvidedContext } from "vitest"; + +import "./helpers/integration-provided-context.ts"; + +type IntegrationSetupContext = { + provide: (key: K, value: ProvidedContext[K]) => void; +}; + +/** + * One state root per suite run, shared by every test that binds real listeners on automatically + * allocated public ports; a concurrent run from another checkout on the same host uses its own + * root and is outside the supported one-state-root-per-host topology. + */ +// oxlint-disable-next-line effecttsgo/async-function -- Vitest's globalSetup contract is Promise-based. +export async function setup({ provide }: IntegrationSetupContext): Promise<() => Promise> { + const root = await Effect.runPromise( + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "supabase-stack-integration-" })), + Effect.provide(NodeServices.layer), + ), + ); + provide("stackStateRoot", `${root}/state`); + // oxlint-disable-next-line effecttsgo/async-function -- Vitest awaits this teardown function directly. + return async () => { + await Effect.runPromise( + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.remove(root, { recursive: true, force: true })), + Effect.provide(NodeServices.layer), + ), + ); + }; +} + +export default setup; diff --git a/packages/stack/tests/owner-rpc.ts b/packages/stack/tests/owner-rpc.ts index fd8d254423..bceaec1c2a 100644 --- a/packages/stack/tests/owner-rpc.ts +++ b/packages/stack/tests/owner-rpc.ts @@ -23,3 +23,15 @@ export const ownerFor = (options: { ); return { rpc, namespace: owner.namespace, getStackCredentials: owner.getStackCredentials }; }); + +/** + * Registers a stack the way the product owner does: takes its lease for the enclosing scope, + * then saves it, so an in-process owner double built with `ownerFor` holds the lease like + * production. Call once per logical stack, before any fault-injecting wrapper around `state`. + */ +export const registerLeased = (state: StateInterface, saved: SavedStack) => + Effect.gen(function* () { + const held = yield* state.lease(saved.id); + if (!held) return yield* Effect.die(`Could not take the lease for stack ${saved.id}`); + yield* state.save(saved); + }); diff --git a/packages/stack/tests/session-client-fixture.ts b/packages/stack/tests/session-client-fixture.ts index aa7b2020c8..dc6a26e91a 100644 --- a/packages/stack/tests/session-client-fixture.ts +++ b/packages/stack/tests/session-client-fixture.ts @@ -2,15 +2,16 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { Effect, Layer, Schema } from "effect"; import { create } from "../src/effect.ts"; -const [root, cacheRoot] = process.argv.slice(2); -if (root === undefined || cacheRoot === undefined) throw new Error("Session fixture roots missing"); +const [root, cacheRoot, stateRoot] = process.argv.slice(2); +if (root === undefined || cacheRoot === undefined || stateRoot === undefined) + throw new Error("Session fixture roots missing"); /** Creates a session stack with a running native service, reports it, and waits to be killed. */ const program = Effect.scoped( Effect.gen(function* () { const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot, cacheRoot, runtime: "native", lifetime: "session", diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index ca0f72b3de..8631ce5e79 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ // Integration workers start real service processes and containers. maxWorkers: 4, sequence: { groupOrder: 1 }, - globalSetup: ["tests/docker-volume-run.ts"], + globalSetup: ["tests/docker-volume-run.ts", "tests/integration-global-setup.ts"], }, }, {