From 4e96151192e6f4a20abcb2a957de9c8535c6dad1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 10:41:56 +0200 Subject: [PATCH 1/5] test(stack): share one state root across the integration suite Integration files created private state roots, so a concurrent file could auto-allocate a public port that a stopped composition in another file had saved, and the restart then reported the port as occupied. The suite now matches the supported topology of one state root per host. - A vitest globalSetup creates one state root per run and provides it to every test that binds real listeners on automatic public ports; those tests use unique stack IDs. - Tests that only simulate allocation, bind no public ports, or assert the exact contents of a root keep private roots. - In-process owner doubles take the stack lease before registering, like the product owner, so another file's orphan sweep cannot reclaim them. - Whole-root discovery and snapshot assertions are scoped to the test's own stacks. - The shared root's Docker snapshot-cache volume is removed once by the suite teardown instead of per test. - Fixed test ports move below the automatic span. - A Ports regression covers a stopped sibling's saved port that is the competitor's first scan candidate. Concurrent integration runs from other checkouts on the same host use separate roots and remain outside the supported topology. --- .../stack/src/Network.integration.test.ts | 136 ++++++------ packages/stack/src/Owner.integration.test.ts | 43 ++-- packages/stack/src/Ports.integration.test.ts | 204 ++++++++++++------ ...ost.container-shutdown.integration.test.ts | 2 +- .../stack/src/StackHost.integration.test.ts | 38 ++-- .../Supabase.native.integration.test.ts | 46 ++-- .../stack/src/credentials.integration.test.ts | 3 +- packages/stack/src/effect.integration.test.ts | 17 +- .../stack/src/lifetime.integration.test.ts | 5 +- .../stack/src/promise-api.integration.test.ts | 16 +- .../src/services/Rest.integration.test.ts | 11 +- .../StorageGateway.integration.test.ts | 23 +- .../stack/src/testing.integration.test.ts | 46 ++-- packages/stack/tests/docker-fixture.ts | 109 ++++++---- .../helpers/integration-provided-context.ts | 9 + .../stack/tests/helpers/integration-state.ts | 12 ++ .../stack/tests/integration-global-setup.ts | 47 ++++ packages/stack/tests/owner-rpc.ts | 12 ++ .../stack/tests/session-client-fixture.ts | 7 +- packages/stack/vitest.config.ts | 1 + 20 files changed, 509 insertions(+), 278 deletions(-) create mode 100644 packages/stack/tests/helpers/integration-provided-context.ts create mode 100644 packages/stack/tests/helpers/integration-state.ts create mode 100644 packages/stack/tests/integration-global-setup.ts 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.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 029119d629..bb06fa675e 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({ @@ -789,7 +794,7 @@ it.live("rejects a missing required input before starting or stopping the servic 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); + 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/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index e5c4f9bb0d..f13648fbc3 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -1,8 +1,9 @@ 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, PortError } from "./Ports.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( @@ -17,10 +18,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 +34,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 +59,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 +73,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 +83,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 +205,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 +226,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 +245,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 +276,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 +306,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 +335,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 +361,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 +393,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,10 +428,76 @@ 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 }, ]); }), ).pipe(Effect.provide(NodeServices.layer)), ); + +// Mirrors `scanStart`, `portBase`, and `portSpan` in ./Ports.ts, which are not exported; the +// regression below needs the exact automatic-allocation scan order to find a colliding sibling. +const portBase = 20000; +const portSpan = 12768; +const scanStart = (projectRoot: string, id: string, key: string) => + Math.abs(Hash.string(`${projectRoot}:${id}:${key}`)) % portSpan; + +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 firstRoot = yield* fs.makeTempDirectoryScoped({ prefix: "ports-sibling-first-" }); + const key = "sql"; + const firstId = "first"; + const firstStart = scanStart(projectRoot, firstId, key); + let competitorId: string | undefined; + for (let suffix = 0; suffix < 200_000 && competitorId === undefined; suffix++) { + const candidate = `competitor-${suffix}`; + if (scanStart(projectRoot, candidate, key) === firstStart) competitorId = candidate; + } + if (competitorId === undefined) + return yield* Effect.die("No competitor id shares the first candidate's scan start"); + + const accept = (_host: string, port: number) => Effect.succeed(port); + const request = { stackId: firstId, key, host: "127.0.0.1", port: "auto" as const }; + + const firstState = yield* makeTestState(firstRoot); + yield* saveStack(firstState, projectRoot, firstId); + const firstPorts = yield* makePorts(firstState, "linux"); + const firstScope = yield* Scope.make(); + const firstAcquired = yield* firstPorts + .acquire(request, accept) + .pipe(Effect.provideService(Scope.Scope, firstScope)); + yield* Scope.close(firstScope, Exit.void); + expect(firstAcquired.port).toBe(portBase + firstStart); + + // Proves the prerequisite: alone, 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(firstState, projectRoot, competitorId); + const competitorAcquired = yield* firstPorts.acquire( + { ...request, stackId: competitorId }, + accept, + ); + expect(competitorAcquired.port).not.toBe(firstAcquired.port); + + const reopenedPorts = yield* makePorts(yield* makeTestState(firstRoot), "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 914f877ed2..5306a21c18 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; @@ -807,13 +813,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: [], @@ -956,7 +963,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)); @@ -999,6 +1008,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/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/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 d17f2f7792..22362e63ab 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 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]()), @@ -48,7 +48,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; @@ -58,6 +61,7 @@ it.live( services: [{ service: "mail", endpoints: { http: { port } } }], runtime: "native", stateRoot, + projectRoot, }).then( () => undefined, (error: unknown) => error, @@ -66,7 +70,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/Rest.integration.test.ts b/packages/stack/src/services/Rest.integration.test.ts index a8d87db95f..b04fbecd06 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,7 @@ import { makeService } from "../Service.ts"; import { ProxyError } from "../Proxy.ts"; import { makeServiceRecipe } from "./Catalog.ts"; import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { sharedStateRoot, uniqueStackId } from "../../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -47,11 +48,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" }, 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 f986eb7931..eedbd4ef7f 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -1,5 +1,5 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { Crypto, Effect, FileSystem, Path, Stream } from "effect"; +import { Crypto, Effect, FileSystem, Stream } from "effect"; import { volumeNameFor } from "../src/storage/DockerDatabaseStorage.ts"; import { cleanupDockerRoot } from "./docker-cleanup.ts"; @@ -24,52 +24,77 @@ export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray< ), ); -/** Allocates the documented state-root layout used by Docker database fixtures. */ +/** Computes a state root's Docker volume identity digest: this host's daemon plus the root. */ +const stateDigestFor = Effect.fn("DockerTest.stateDigestFor")((stateRoot: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const resolvedStateRoot = yield* fs.realPath(stateRoot); + const daemon = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(result.output.trim()) + : Effect.die(`Docker info failed: ${result.output}`), + ), + ); + return yield* crypto + .digest("SHA-256", new TextEncoder().encode(`${resolvedStateRoot}\0${daemon}`)) + .pipe( + Effect.map((bytes) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); + }), +); + +/** + * Removes the Docker volume a state root owns: the shared snapshot cache of every stack under it. + * Call only once that root's owner is sure no sibling stack still needs it. Tolerates an + * already-removed volume; any other failure, including one still in use, dies. + */ +export const removeManagedVolume = Effect.fn("DockerTest.removeManagedVolume")( + (stateRoot: string) => + Effect.gen(function* () { + const stateDigest = yield* stateDigestFor(stateRoot); + const volume = volumeNameFor(stateDigest); + const inspected = yield* runDocker([ + "volume", + "inspect", + "--format", + '{{ index .Labels "com.supabase.stack-managed" }}|{{ index .Labels "com.supabase.stack-state-root" }}', + volume, + ]); + if (inspected.code !== 0) { + if (/no such volume|not found/iu.test(inspected.output)) return; + return yield* Effect.die(`Docker volume inspect failed: ${inspected.output}`); + } + const [managed, labeledState] = inspected.output.trim().split("|"); + if (managed !== "true" || labeledState !== stateDigest) + return yield* Effect.die("Docker fixture volume identity did not match its state root"); + const removed = yield* runDocker(["volume", "rm", volume]); + if (removed.code !== 0 && !/no such volume|not found/iu.test(removed.output)) + return yield* Effect.die(`Docker volume cleanup failed: ${removed.output}`); + }), +); + +/** + * Allocates the documented state-root layout used by Docker database fixtures. Defaults to a + * fresh private root, whose finalizer owns and removes its volume. Pass `stateRoot` to place the + * data root under an existing one (such as the shared integration state root) instead: that root's + * owner removes the volume once, so this finalizer only cleans its own data leaf. + */ 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 path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const temporaryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); - const root = `${temporaryRoot}/state/${stackId}/data`; + const registryRoot = + options.stateRoot === undefined + ? `${yield* fs.makeTempDirectoryScoped({ prefix })}/state` + : options.stateRoot; + const root = `${registryRoot}/${stackId}/data`; yield* fs.makeDirectory(root, { recursive: true }); - const stateRoot = yield* fs.realPath(path.dirname(path.dirname(root))); - const daemon = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed(result.output.trim()) - : Effect.die(`Docker info failed: ${result.output}`), - ), - ); - const stateDigest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`${stateRoot}\0${daemon}`)) - .pipe( - Effect.map((bytes) => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const volume = volumeNameFor(stateDigest); yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const inspected = yield* runDocker([ - "volume", - "inspect", - "--format", - '{{ index .Labels "com.supabase.stack-managed" }}|{{ index .Labels "com.supabase.stack-state-root" }}', - volume, - ]); - if (inspected.code !== 0) { - if (/no such volume|not found/iu.test(inspected.output)) return; - return yield* Effect.die(`Docker volume inspect failed: ${inspected.output}`); - } - const [managed, labeledState] = inspected.output.trim().split("|"); - if (managed !== "true" || labeledState !== stateDigest) - return yield* Effect.die("Docker fixture volume identity did not match its state root"); - const removed = yield* runDocker(["volume", "rm", volume]); - if (removed.code !== 0 && !/no such volume|not found/iu.test(removed.output)) - return yield* Effect.die(`Docker volume cleanup failed: ${removed.output}`); - }).pipe( + (options.stateRoot === undefined ? removeManagedVolume(registryRoot) : Effect.void).pipe( Effect.andThen( Effect.gen(function* () { if (yield* fs.exists(root)) yield* cleanupDockerRoot(root); 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..58a43fcc68 --- /dev/null +++ b/packages/stack/tests/helpers/integration-state.ts @@ -0,0 +1,12 @@ +import { randomUUID } from "node:crypto"; +import { inject } from "vitest"; + +/** The state root every integration test that binds a real auto-allocated port shares. */ +export const sharedStateRoot = (): string => inject("stackStateRoot"); + +/** + * 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..d6162240a1 --- /dev/null +++ b/packages/stack/tests/integration-global-setup.ts @@ -0,0 +1,47 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem } from "effect"; +import type { ProvidedContext } from "vitest"; + +import "./helpers/integration-provided-context.ts"; +import { removeManagedVolume, runDocker } from "./docker-fixture.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( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = `${root}/state`; + // No Docker fixture can have created the volume this run owns without a reachable daemon. + const dockerReachable = yield* runDocker(["info"]).pipe( + Effect.map((result) => result.code === 0), + Effect.orElseSucceed(() => false), + ); + if (dockerReachable && (yield* fs.exists(stateRoot))) + yield* removeManagedVolume(stateRoot).pipe( + Effect.ensuring(fs.remove(root, { recursive: true, force: true }).pipe(Effect.orDie)), + ); + else yield* fs.remove(root, { recursive: true, force: true }); + }).pipe(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 88e8c7d186..d3291f97fd 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ // Integration workers start real service processes and containers. maxWorkers: 4, sequence: { groupOrder: 1 }, + globalSetup: ["tests/integration-global-setup.ts"], }, }, { From bac2ab81ce00d6f0e1d66b7dc51a8d693cddc41c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 21:48:21 +0200 Subject: [PATCH 2/5] test(stack): prepare the psql artifact before the native cleanup test The native workload cleanup test used a fresh artifact cache, so every run downloaded psql inside the test body and timed out whenever the download was slow. It now uses the shared artifact cache and prepares psql in a beforeAll hook, so the test timeout covers only the cleanup retries it asserts. --- ...dRunner.native-cleanup.integration.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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) => From 42e36d2d80b6906054d2f9204bee9197fac45b52 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 21:48:23 +0200 Subject: [PATCH 3/5] test(stack): reserve native backend ports against the shared root Native backend ports now come from the same span as automatic public ports and skip only the claims they are given. Service tests that passed no claims, and an owner test that starts a native service on a private root, could take a stopped shared-root stack's saved port, which is the collision the shared root removes. - Service integration tests that can reserve native backend ports read the shared root's claims. - The Owner test that starts REST natively uses the shared root. - The native reservation, exclusion and loopback Ports tests avoid shared claimed ports and start their second scan at the port under test, so a released shared claim cannot satisfy their assertions. - The sibling regression keeps both stacks in the shared root and takes the colliding port from the actual allocation. --- packages/stack/src/Owner.integration.test.ts | 4 +- packages/stack/src/Ports.integration.test.ts | 127 +++++++++++------- .../services/Functions.integration.test.ts | 9 +- .../src/services/Mail.integration.test.ts | 9 +- .../src/services/Pooler.integration.test.ts | 3 +- .../ProcessRecipe.integration.test.ts | 4 +- .../src/services/Rest.integration.test.ts | 10 +- .../src/services/Vector.integration.test.ts | 3 +- .../stack/tests/helpers/integration-state.ts | 14 ++ 9 files changed, 121 insertions(+), 62 deletions(-) diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index bb06fa675e..1aa4f52443 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -792,8 +792,8 @@ 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`); + 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)); diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index 30966dcdd5..8a50977af1 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -3,7 +3,11 @@ import { expect, it } from "@effect/vitest"; 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 { sharedStateRoot, uniqueStackId } from "../tests/helpers/integration-state.ts"; +import { + sharedPortClaims, + sharedStateRoot, + uniqueStackId, +} from "../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -439,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"); }); @@ -456,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); @@ -467,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)), @@ -476,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)), @@ -489,46 +507,67 @@ 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"); + + // 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, accept); + const acquired = yield* ports.acquire({ ...request, stackId: competitorId }, accept); expect(acquired.port).not.toBe(probe.port); }), ).pipe(Effect.provide(NodeServices.layer)), ); -// Mirrors the unexported `scanStart` in ./Ports.ts; the regression below needs the automatic -// allocation's first candidate to find a colliding sibling. -const scanStart = (projectRoot: string, id: string, key: string) => - Math.abs(Hash.string(`${projectRoot}:${id}:${key}`)) % portSpan; - it.live( "keeps automatic allocation off a stopped sibling's saved port that is its own first candidate", () => @@ -538,32 +577,30 @@ it.live( const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "ports-sibling-project-", }); - const firstRoot = yield* fs.makeTempDirectoryScoped({ prefix: "ports-sibling-first-" }); const key = "sql"; - const firstId = "first"; - const firstStart = scanStart(projectRoot, firstId, key); - let competitorId: string | undefined; - for (let suffix = 0; suffix < 200_000 && competitorId === undefined; suffix++) { - const candidate = `competitor-${suffix}`; - if (scanStart(projectRoot, candidate, key) === firstStart) competitorId = candidate; - } - if (competitorId === undefined) - return yield* Effect.die("No competitor id shares the first candidate's scan start"); - + 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 }; - const firstState = yield* makeTestState(firstRoot); - yield* saveStack(firstState, projectRoot, firstId); - const firstPorts = yield* makePorts(firstState, "linux"); + // `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* firstPorts + const firstAcquired = yield* sharedPorts .acquire(request, accept) .pipe(Effect.provideService(Scope.Scope, firstScope)); yield* Scope.close(firstScope, Exit.void); - expect(firstAcquired.port).toBe(portBase + firstStart); + const competitorId = yield* findScanStartCollision( + projectRoot, + key, + firstAcquired.port - portBase, + ); - // Proves the prerequisite: alone, the competitor's own first candidate is the same port. + // 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); @@ -574,14 +611,14 @@ it.live( ); expect(verifyAcquired.port).toBe(firstAcquired.port); - yield* saveStack(firstState, projectRoot, competitorId); - const competitorAcquired = yield* firstPorts.acquire( + 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(firstRoot), "linux"); + const reopenedPorts = yield* makePorts(yield* makeTestState(sharedRoot), "linux"); const firstReacquired = yield* reopenedPorts.acquire(request, accept); expect(firstReacquired.port).toBe(firstAcquired.port); }), diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index a2eea0fa60..d5329af9d1 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* 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* 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* 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 de276d5051..80bc7ea66e 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 47da10e49c..28eef378db 100644 --- a/packages/stack/src/services/Rest.integration.test.ts +++ b/packages/stack/src/services/Rest.integration.test.ts @@ -11,7 +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 { sharedStateRoot, uniqueStackId } from "../../tests/helpers/integration-state.ts"; +import { + sharedPortClaims, + sharedStateRoot, + uniqueStackId, +} from "../../tests/helpers/integration-state.ts"; const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( @@ -219,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", @@ -260,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/Vector.integration.test.ts b/packages/stack/src/services/Vector.integration.test.ts index 557c37b6b0..0305e496f6 100644 --- a/packages/stack/src/services/Vector.integration.test.ts +++ b/packages/stack/src/services/Vector.integration.test.ts @@ -5,6 +5,7 @@ import { Effect, FileSystem, Layer } from "effect"; import { HttpClient, HttpClientRequest } 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, runtime: "docker" | "native") => ({ stackId: "catalog-test", @@ -33,7 +34,7 @@ describe("vector recipe", () => { endpoints: { http: { port: "auto" } }, }, options(root, runtime), - Effect.succeed([]), + sharedPortClaims, ); const vector = yield* makeService(recipe.definition, { id: "vector", diff --git a/packages/stack/tests/helpers/integration-state.ts b/packages/stack/tests/helpers/integration-state.ts index 58a43fcc68..807c47b1f6 100644 --- a/packages/stack/tests/helpers/integration-state.ts +++ b/packages/stack/tests/helpers/integration-state.ts @@ -1,9 +1,23 @@ +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, 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`). From 09612bb9468beb3c523aa8d8ac8362b085e212ca Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 2 Oct 2026 22:02:51 +0200 Subject: [PATCH 4/5] test(stack): format the shared integration state helper --- packages/stack/tests/helpers/integration-state.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/stack/tests/helpers/integration-state.ts b/packages/stack/tests/helpers/integration-state.ts index 807c47b1f6..8cdddfe9a8 100644 --- a/packages/stack/tests/helpers/integration-state.ts +++ b/packages/stack/tests/helpers/integration-state.ts @@ -11,12 +11,14 @@ 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, 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)); +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 From 743a4e3e43356a366ed1c712a41c4eef59b5f561 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 5 Oct 2026 14:58:28 +0200 Subject: [PATCH 5/5] test(stack): move owner log and analytics tests onto the shared state root --- .../src/Owner.analytics.integration.test.ts | 17 ++- .../stack/src/Owner.logs.integration.test.ts | 119 ++++++++++-------- ...State.vector-migration.integration.test.ts | 4 +- 3 files changed, 78 insertions(+), 62 deletions(-) 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.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/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 }, ], });