From 0a1eea58177bcbf79a03c0f69e10beeef8bb7948 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 17 Aug 2026 16:41:03 +0200 Subject: [PATCH 1/7] fix(stack): reject copied ordinary identities --- packages/stack/src/managed/manager.ts | 66 +++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 585ecaabfc..d926650f9f 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -41,10 +41,15 @@ import { type WorkspaceDiscovery, } from "./environment.ts"; import { GitConfigStore, inspectWorkspace } from "./git.ts"; -import { updateGitCheckoutLocationOwned } from "./identity.ts"; +import { + canonicalizeManagedWorkspacePathWithFileSystem, + readOrdinaryWorkspaceIdentityWithFileSystem, + updateGitCheckoutLocationOwned, +} from "./identity.ts"; import { ManagedExactPortOccupiedError, InvalidManagedStackNameError, + InvalidManagedIdentityError, ManagedPortAllocationError, ManagedStackNotFoundError, ManagedStackNotStoppedError, @@ -381,6 +386,48 @@ const makeManager = ( const store = yield* makeStackStore(stateRoot); const lifecycleLock = Semaphore.makeUnsafe(1); + const validateOrdinaryWorkspaceIdentity = ( + discovery: WorkspaceDiscovery, + ): Effect.Effect => + Effect.gen(function* () { + if (discovery.workspace.kind !== "folder") return; + const listings = yield* store.list(); + const matching = listings + .filter(isHealthyDocument) + .map((listing) => listing.document) + .filter( + (document) => + document.identity.workspaceId === discovery.identity.workspaceId && + document.identity.checkoutId === discovery.identity.checkoutId && + document.identity.contextId === discovery.identity.contextId && + document.identity.localProjectKey === discovery.identity.localProjectKey, + ); + for (const document of matching) { + const persistedPath = document.workspace.path; + if (!(yield* fileSystem.exists(persistedPath))) continue; + const canonicalPersistedPath = yield* provideDependencies( + canonicalizeManagedWorkspacePathWithFileSystem(persistedPath), + ); + if (canonicalPersistedPath === discovery.workspace.path) continue; + const marker = yield* provideDependencies( + readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), + ); + if ( + marker !== undefined && + marker.workspaceId === discovery.identity.workspaceId && + marker.checkoutId === discovery.identity.checkoutId && + marker.contextId === discovery.identity.contextId + ) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: + "This ordinary workspace identity is already in use at another folder. Delete the current copied folder's .supabase/identity.json so a new identity can be generated.", + }), + ); + } + } + }); + const allocateManagedPorts = ( ownership: ControlOwnership, request: AllocateManagedPortsRequest, @@ -615,6 +662,7 @@ const makeManager = ( Effect.gen(function* () { const stackName = yield* validateManagedStackName(request.stackName ?? "default"); const discovery = yield* provideDependencies(discoverEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); } @@ -635,6 +683,7 @@ const makeManager = ( Effect.gen(function* () { const stackName = yield* validateManagedStackName(request.stackName ?? "default"); const discovery = yield* provideDependencies(ensureEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); } @@ -660,6 +709,7 @@ const makeManager = ( ); return yield* Effect.gen(function* () { const refreshed = yield* provideDependencies(ensureEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(refreshed); if (refreshed.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(refreshed.reason)); } @@ -910,8 +960,18 @@ const makeManager = ( return { stateRoot: store.stateRoot, - discoverWorkspace: (path) => provideDependencies(discoverEnvironment(path)), - ensureWorkspace: (path) => provideDependencies(ensureEnvironment(path)), + discoverWorkspace: (path) => + Effect.gen(function* () { + const discovery = yield* provideDependencies(discoverEnvironment(path)); + yield* validateOrdinaryWorkspaceIdentity(discovery); + return discovery; + }), + ensureWorkspace: (path) => + Effect.gen(function* () { + const discovery = yield* provideDependencies(ensureEnvironment(path)); + yield* validateOrdinaryWorkspaceIdentity(discovery); + return discovery; + }), acquireControl: (stackId) => provideDependencies(acquireControl({ stackId })), probeControl: (stackId) => provideDependencies(probeControl(stackId)), readStack, From 4016be1a6aaa298d527138ddc8485f0704624009 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 17 Aug 2026 23:16:40 +0200 Subject: [PATCH 2/7] fix(stack): close copied identity validation gaps --- ...naged-manager-projects.integration.test.ts | 95 ++++++++++++++++++- ...naged-manager-recovery.integration.test.ts | 91 +++++++++++++++++- packages/stack/src/managed/manager.ts | 10 +- 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index c14e1f8311..b014e21fc6 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -1,13 +1,23 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"; -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { acquireControl, ControlTransport } from "./managed/control.ts"; +import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { listStacks as listStackSummaries, resolveStackSummary } from "./discovery.ts"; import { makeRepository } from "../tests/helpers/git-workspace.ts"; @@ -23,6 +33,89 @@ afterEach(() => cleanupRoots(roots)); const setup = () => setupManagedManager(roots); describe("managed stack projects journeys", () => { + it.live("rejects copied ordinary workspace identity until the original path is moved", () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const ownership = yield* acquireControl({ stackId }); + if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + const initial = yield* manager.startStack({ + workspacePath: workspace, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + yield* initial.lease.releaseAll; + + const copied = join(workspace, "..", "workspace-copy"); + cpSync(workspace, copied, { recursive: true }); + const copiedDiscovery = yield* manager.discoverWorkspace(copied).pipe(Effect.exit); + expect(Exit.isFailure(copiedDiscovery)).toBe(true); + if (Exit.isFailure(copiedDiscovery)) { + const error = Cause.squash(copiedDiscovery.cause); + expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); + if (error instanceof Error) { + expect(error.message).toContain("Delete"); + expect(error.message).toContain(".supabase/identity.json"); + } + } + const copiedEnsure = yield* manager.ensureWorkspace(copied).pipe(Effect.exit); + expect(Exit.isFailure(copiedEnsure)).toBe(true); + if (Exit.isFailure(copiedEnsure)) { + expect(Cause.squash(copiedEnsure.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + + const copiedStart = yield* manager + .startStack({ + workspacePath: copied, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }) + .pipe(Effect.exit); + expect(Exit.isFailure(copiedStart)).toBe(true); + if (Exit.isFailure(copiedStart)) { + const error = Cause.squash(copiedStart.cause); + expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); + if (error instanceof Error) { + expect(error.message).toContain("Delete"); + expect(error.message).toContain(".supabase/identity.json"); + } + } + + rmSync(copied, { recursive: true, force: true }); + renameSync(workspace, copied); + const movedDiscovery = yield* manager.discoverWorkspace(copied); + expect(movedDiscovery.state).toBe("ready"); + writeFileSync(workspace, "stale workspace path"); + const moved = yield* manager.startStack({ + workspacePath: copied, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + expect(moved.stack.id).toBe(stackId); + expect(moved.stack.workspace.path).toBe(realpathSync(copied)); + expect((yield* manager.inspectStack(stackId))?.workspace.path).toBe(realpathSync(copied)); + yield* moved.lease.releaseAll; + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live("rejects empty and ASCII-control stack names before resolving a stack", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 4e9435154c..23ec12baf6 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -1,9 +1,10 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime } from "effect"; import { HttpServer } from "effect/unstable/http"; import { chmodSync, + cpSync, mkdirSync, mkdtempSync, realpathSync, @@ -13,7 +14,11 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; -import { deriveRepairOwnershipId, ManagedStackManager } from "./managed/manager.ts"; +import { + deriveRepairOwnershipId, + ManagedStackManager, + managedStackManagerLayer, +} from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { acquireControl, ControlTransport } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; @@ -40,6 +45,88 @@ afterEach(() => cleanupRoots(roots)); const setup = () => setupManagedManager(roots); describe("managed stack recovery journeys", () => { + it.live("revalidates a copied ordinary workspace after a concurrent first start", () => { + const root = mkdtempSync(join(tmpdir(), "managed-manager-race-test-")); + roots.push(root); + const workspace = join(root, "workspace"); + const copied = join(root, "workspace-copy"); + const stateRoot = join(root, "state"); + mkdirSync(workspace); + const gate = { enabled: true, blocked: false }; + const gatedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return { + ...base, + readFileString: (path: string, options?: Parameters[1]) => { + if (!gate.enabled || gate.blocked || !path.endsWith("stack.json")) { + return base.readFileString(path, options); + } + gate.blocked = true; + return Effect.gen(function* () { + yield* Deferred.succeed(readStarted, void 0); + yield* Deferred.await(releaseRead); + return yield* base.readFileString(path, options); + }); + }, + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); + const managerLayer = managedStackManagerLayer({ stateRoot }).pipe( + Layer.provide(gatedFileSystemLayer), + Layer.provide(NodePath.layer), + Layer.provide(gitConfigStoreLayer), + Layer.provide(controlTransportLayer), + ); + let readStarted!: Deferred.Deferred; + let releaseRead!: Deferred.Deferred; + return Effect.scoped( + Effect.gen(function* () { + readStarted = yield* Deferred.make(); + releaseRead = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + cpSync(workspace, copied, { recursive: true }); + const stackId = deriveStackId(environment.identity, "default"); + const ownership = yield* acquireControl({ stackId }); + if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + + const readFiber = yield* Effect.forkScoped( + manager.readStack({ workspacePath: copied, portDocument: automaticDocument() }), + ); + yield* Deferred.await(readStarted); + const startFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const started = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + yield* started.lease.releaseAll; + }), + ); + yield* Fiber.join(startFiber); + gate.enabled = false; + yield* Deferred.succeed(releaseRead, void 0); + const read = yield* Fiber.join(readFiber).pipe(Effect.exit); + expect(Exit.isFailure(read)).toBe(true); + if (Exit.isFailure(read)) { + expect(Cause.squash(read.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live("fences start publication while checkout repair is owned", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index d926650f9f..d807dd47dd 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -404,7 +404,14 @@ const makeManager = ( ); for (const document of matching) { const persistedPath = document.workspace.path; - if (!(yield* fileSystem.exists(persistedPath))) continue; + const persistedInfo = yield* fileSystem + .stat(persistedPath) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (persistedInfo === undefined || persistedInfo.type !== "Directory") continue; const canonicalPersistedPath = yield* provideDependencies( canonicalizeManagedWorkspacePathWithFileSystem(persistedPath), ); @@ -669,6 +676,7 @@ const makeManager = ( const stackId = deriveStackId(discovery.identity, stackName); const existing = yield* store.read(stackId); if (existing === undefined) return undefined; + yield* validateOrdinaryWorkspaceIdentity(discovery); const drift = stackDrift(existing, request.portDocument); return drift.length === 0 ? existing : { ...existing, drift }; }); From 59ebbe7173bf6af1bd6ad29b8226dedeae449caf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 18 Aug 2026 08:00:57 +0200 Subject: [PATCH 3/7] fix(stack): revalidate copied identities on attach --- ...naged-manager-projects.integration.test.ts | 7 +- packages/stack/src/managed/manager.ts | 4 ++ .../stack/src/supervisor.integration.test.ts | 67 +++++++++++++++++-- packages/stack/src/supervisor.ts | 9 +++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index b014e21fc6..d88a09795f 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -20,7 +20,7 @@ import { acquireControl, ControlTransport } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { listStacks as listStackSummaries, resolveStackSummary } from "./discovery.ts"; -import { makeRepository } from "../tests/helpers/git-workspace.ts"; +import { git, makeRepository } from "../tests/helpers/git-workspace.ts"; import { automaticDocument, cleanupRoots, @@ -90,6 +90,11 @@ describe("managed stack projects journeys", () => { } } + git(workspace, "init", "-q", "-b", "main"); + const copiedAfterOriginalGit = yield* manager.discoverWorkspace(copied); + expect(copiedAfterOriginalGit.state).toBe("ready"); + rmSync(join(workspace, ".git"), { recursive: true, force: true }); + rmSync(copied, { recursive: true, force: true }); renameSync(workspace, copied); const movedDiscovery = yield* manager.discoverWorkspace(copied); diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index d807dd47dd..0db8766d52 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -416,6 +416,10 @@ const makeManager = ( canonicalizeManagedWorkspacePathWithFileSystem(persistedPath), ); if (canonicalPersistedPath === discovery.workspace.path) continue; + const persistedInspection = yield* provideDependencies( + inspectWorkspace(canonicalPersistedPath), + ); + if (persistedInspection.kind !== "ordinary-folder") continue; const marker = yield* provideDependencies( readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), ); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index da96e82da0..65d12f26ee 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -4,16 +4,18 @@ import { fork, type ChildProcess } from "node:child_process"; import { createServer as createHttpServer } from "node:http"; import { createConnection, createServer } from "node:net"; import { + cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, + watch, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; @@ -73,6 +75,28 @@ const workspace = async (): Promise<{ return { root, stateRoot, stackId: deriveStackId(identity, "default") }; }; +const waitForFile = (path: string): Promise => + new Promise((resolve, reject) => { + if (existsSync(path)) { + resolve(); + return; + } + const watcher = watch(dirname(path), (_eventType, filename) => { + if (filename?.toString() === basename(path) && existsSync(path)) { + watcher.close(); + resolve(); + } + }); + watcher.once("error", (cause) => { + watcher.close(); + reject(cause); + }); + if (existsSync(path)) { + watcher.close(); + resolve(); + } + }); + const messageFor = ( roots: { readonly root: string; @@ -633,10 +657,7 @@ describe("detached supervisor child journeys", () => { }); void child.started.catch(() => undefined); try { - const deadline = Date.now() + 2_000; - while (!existsSync(ensureReady) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(ensureReady); expect(existsSync(ensureReady)).toBe(true); const endpoint = await Effect.runPromise(controlEndpoint(roots.stackId)); const response = await fetch(`${endpoint.url}/stop`, { method: "POST" }); @@ -651,6 +672,42 @@ describe("detached supervisor child journeys", () => { } }); + test("rejects an attached contender whose copied workspace collides after owner validation", async () => { + const roots = await workspace(); + const copied = `${roots.root}-copy`; + const ensureReady = join(roots.root, "ensure-ready"); + const ensureRelease = join(roots.root, "ensure-release"); + const owner = spawnChild(messageFor(roots), { + environment: { + SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, + SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, + }, + }); + let contender: ChildHandle | undefined; + try { + await waitForFile(ensureReady); + expect(existsSync(ensureReady)).toBe(true); + + cpSync(roots.root, copied, { recursive: true }); + contender = spawnChild(messageFor(roots, { workspacePath: copied })); + await contender.attachedBeforeReady; + + writeFileSync(ensureRelease, "release"); + const started = await owner.started; + expect(started.attached).not.toBe(true); + await expect(contender.started).rejects.toThrow( + /ordinary workspace identity.*\.supabase\/identity\.json/, + ); + await remoteStop(started.endpoint); + await waitForExit(owner.child); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + rmSync(copied, { recursive: true, force: true }); + cleanupRoots(roots); + } + }); + test("does not mark an existing stopped document failed when discovery fails after control bind", async () => { const roots = await workspace(); const initial = spawnChild(messageFor(roots)); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index ca0aa68f0a..5cce993d18 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -415,6 +415,15 @@ const runManaged = ( ), ); if (acquisition._tag === "Attached") { + const revalidated = yield* manager.ensureWorkspace(input.workspacePath); + const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new SupervisorStartError({ + message: "Workspace identity changed before supervisor attach", + }), + ); + } yield* sendMessage({ type: "started", endpoint: acquisition.endpoint, attached: true }); process.disconnect?.(); return; From fe98edff739861b324e63447e4d8cdc4187e1079 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 18 Aug 2026 09:13:54 +0200 Subject: [PATCH 4/7] test(stack): isolate remaining managed stack waits --- ...naged-manager-recovery.integration.test.ts | 82 ++++++++++++++--- .../stack/src/supervisor.integration.test.ts | 88 +++++++++++++++---- 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 23ec12baf6..2bad6395ad 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -2,6 +2,7 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime } from "effect"; import { HttpServer } from "effect/unstable/http"; +import { randomBytes } from "node:crypto"; import { chmodSync, cpSync, @@ -17,6 +18,7 @@ import { afterEach, describe, expect } from "vitest"; import { deriveRepairOwnershipId, ManagedStackManager, + type ManagedStackManagerShape, managedStackManagerLayer, } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; @@ -39,11 +41,62 @@ import { } from "../tests/helpers/managed-manager.ts"; const roots: Array = []; -const COLLIDING_STACK_A = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; -const COLLIDING_STACK_B = `${COLLIDING_STACK_A.slice(0, 10)}${"f".repeat(54)}`; afterEach(() => cleanupRoots(roots)); const setup = () => setupManagedManager(roots); +const acquireIsolatedCollisionOwner = () => + Effect.gen(function* () { + for (let attempt = 0; attempt < 32; attempt += 1) { + const stackId = randomBytes(32).toString("hex"); + const collidingStackId = `${stackId.slice(0, 10)}${randomBytes(27).toString("hex")}`; + const acquisition = yield* acquireControl({ stackId }).pipe( + Effect.timeout("1 second"), + Effect.exit, + ); + if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + return { collidingStackId, ownership: acquisition.value }; + } + } + return yield* Effect.fail(new Error("failed to acquire an isolated collision endpoint")); + }); + +const acquireIsolatedStackOwner = (workspacePath: string) => + Effect.gen(function* () { + const environment = yield* ensureEnvironment(workspacePath); + for (let attempt = 0; attempt < 32; attempt += 1) { + const stackName = `test-${randomBytes(8).toString("hex")}`; + const stackId = deriveStackId(environment.identity, stackName); + const acquisition = yield* acquireControl({ stackId }).pipe( + Effect.timeout("1 second"), + Effect.exit, + ); + if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + return { stackName, ownership: acquisition.value }; + } + } + return yield* Effect.fail(new Error("failed to acquire an isolated stack endpoint")); + }); + +const startWithIsolatedOwner = ( + manager: ManagedStackManagerShape, + workspacePath: string, + portDocument: ReturnType, + lifecycle: "stopped" | "running" = "stopped", +) => + Effect.scoped( + Effect.gen(function* () { + const { stackName, ownership } = yield* acquireIsolatedStackOwner(workspacePath); + const result = yield* manager.startStack({ + workspacePath, + stackName, + portDocument, + ownership, + lifecycle, + }); + return { ...result, stackName }; + }), + ); + describe("managed stack recovery journeys", () => { it.live("revalidates a copied ordinary workspace after a concurrent first start", () => { const root = mkdtempSync(join(tmpdir(), "managed-manager-race-test-")); @@ -148,7 +201,6 @@ describe("managed stack recovery journeys", () => { Effect.gen(function* () { const manager = yield* ManagedStackManager; const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); const repairId = deriveRepairOwnershipId(environment.identity); const repairOwner = yield* acquireControl({ stackId: repairId }); if (repairOwner._tag !== "Owned") throw new Error("expected repair ownership"); @@ -160,13 +212,14 @@ describe("managed stack recovery journeys", () => { ), ); yield* Effect.promise(() => repairDaemon.runPromise(DaemonServer)); - const stackOwner = yield* acquireControl({ stackId }); - if (stackOwner._tag !== "Owned") throw new Error("expected stack ownership"); + const stackOwner = yield* acquireIsolatedStackOwner(workspace); + const stackId = deriveStackId(environment.identity, stackOwner.stackName); const startFiber = yield* manager .startStack({ workspacePath: workspace, + stackName: stackOwner.stackName, portDocument: automaticDocument(), - ownership: stackOwner, + ownership: stackOwner.ownership, }) .pipe(Effect.forkScoped); yield* Deferred.await(repairRead).pipe(Effect.timeout("1 second")); @@ -192,11 +245,10 @@ describe("managed stack recovery journeys", () => { return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; - const ownership = yield* acquireControl({ stackId: COLLIDING_STACK_A }); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); + const { collidingStackId, ownership } = yield* acquireIsolatedCollisionOwner(); const rejected = yield* manager .allocateManagedPorts(ownership, { - stackId: COLLIDING_STACK_B, + stackId: collidingStackId, portDocument: automaticDocument(), }) .pipe(Effect.exit); @@ -229,10 +281,11 @@ describe("managed stack recovery journeys", () => { mkdirSync(secondProject, { recursive: true }); const manager = yield* ManagedStackManager; const original = yield* Effect.scoped( - startWithOwner(manager, firstProject, automaticDocument(), "running"), + startWithIsolatedOwner(manager, firstProject, automaticDocument(), "running"), ); + const originalStackName = original.stackName; const secondary = yield* Effect.scoped( - startWithOwner(manager, secondProject, automaticDocument(), "stopped", "secondary"), + startWithIsolatedOwner(manager, secondProject, automaticDocument(), "stopped"), ); const originalId = original.stack.id; const originalPort = original.stack.ports[0]?.port; @@ -244,7 +297,11 @@ describe("managed stack recovery journeys", () => { const discovery = yield* manager.discoverWorkspace(movedFirstProject); if (discovery.state !== "needsRepair") throw new Error("expected repair"); const blockedRead = yield* manager - .readStack({ workspacePath: movedFirstProject, portDocument: automaticDocument() }) + .readStack({ + workspacePath: movedFirstProject, + stackName: originalStackName, + portDocument: automaticDocument(), + }) .pipe(Effect.exit); expect(Exit.isFailure(blockedRead)).toBe(true); if (Exit.isFailure(blockedRead)) { @@ -259,6 +316,7 @@ describe("managed stack recovery journeys", () => { } const deleteBeforeRepair = yield* deleteManagedStack({ workspacePath: movedFirstProject, + stackName: originalStackName, }).pipe(Effect.exit); expect(Exit.isFailure(deleteBeforeRepair)).toBe(true); const blockedId = [originalId, secondaryId].sort().at(-1); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index 65d12f26ee..e16b9d11df 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -6,6 +6,7 @@ import { createConnection, createServer } from "node:net"; import { cpSync, existsSync, + type FSWatcher, mkdtempSync, mkdirSync, readFileSync, @@ -36,6 +37,7 @@ const errorChildEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-error-child.ts", import.meta.url), ); const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; +const FILE_WAIT_TIMEOUT_MS = 30_000; type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; @@ -81,19 +83,30 @@ const waitForFile = (path: string): Promise => resolve(); return; } - const watcher = watch(dirname(path), (_eventType, filename) => { + let settled = false; + let timeout: ReturnType | undefined; + let watcher: FSWatcher | undefined; + const settle = (continuation: () => void) => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + watcher?.close(); + continuation(); + }; + watcher = watch(dirname(path), (_eventType, filename) => { if (filename?.toString() === basename(path) && existsSync(path)) { - watcher.close(); - resolve(); + settle(resolve); } }); watcher.once("error", (cause) => { - watcher.close(); - reject(cause); + settle(() => reject(cause)); }); + timeout = setTimeout( + () => settle(() => reject(new Error(`timed out waiting for file ${path}`))), + FILE_WAIT_TIMEOUT_MS, + ); if (existsSync(path)) { - watcher.close(); - resolve(); + settle(resolve); } }); @@ -432,21 +445,58 @@ const readStackDocument = (roots: { return undefined; }; -const waitForStackDocument = async ( - roots: { readonly stateRoot: string }, - lifecycle: string, -): Promise<{ +type StackDocument = { readonly id: string; readonly lifecycle: string; readonly ports: ReadonlyArray<{ port: number }>; -}> => { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - const document = readStackDocument(roots); - if (document?.lifecycle === lifecycle) return document; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`timed out waiting for stack document lifecycle ${lifecycle}`); + readonly launch?: { readonly mode: string; readonly versions: Record }; +}; + +const waitForStackDocument = async ( + roots: { readonly stateRoot: string; readonly stackId: string }, + lifecycle: string, +): Promise => { + const documentPath = managedStackDocumentPath(roots.stateRoot, roots.stackId); + const stackDirectory = dirname(documentPath); + await waitForFile(dirname(stackDirectory)); + await waitForFile(stackDirectory); + await waitForFile(documentPath); + const readDocument = (): StackDocument | undefined => { + try { + return JSON.parse(readFileSync(documentPath, "utf8")) as StackDocument; + } catch { + return undefined; + } + }; + const existing = readDocument(); + if (existing?.lifecycle === lifecycle) return existing; + + return new Promise((resolve, reject) => { + let watcher: FSWatcher | undefined; + let timeout: ReturnType | undefined; + let settled = false; + const settle = (continuation: () => void) => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + watcher?.close(); + continuation(); + }; + const check = () => { + const document = readDocument(); + if (document?.lifecycle === lifecycle) { + settle(() => resolve(document)); + } + }; + const fail = (cause: unknown) => settle(() => reject(cause)); + watcher = watch(stackDirectory, () => check()); + watcher.once("error", fail); + timeout = setTimeout( + () => fail(new Error(`timed out waiting for stack document lifecycle ${lifecycle}`)), + FILE_WAIT_TIMEOUT_MS, + ); + check(); + }); }; describe("detached supervisor child journeys", () => { From 9410f5d691a316dc9ab20dda190b3f56c1e01147 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 18 Aug 2026 10:21:57 +0200 Subject: [PATCH 5/7] fix(stack): close copied identity lifecycle races --- ...aged-manager-lifecycle.integration.test.ts | 97 +++++++++++- ...naged-manager-projects.integration.test.ts | 45 ++++++ packages/stack/src/managed/lifecycle.ts | 9 ++ packages/stack/src/managed/manager.ts | 10 +- .../stack/src/supervisor.integration.test.ts | 145 +++++++++++++++--- packages/stack/src/supervisor.ts | 4 +- 6 files changed, 281 insertions(+), 29 deletions(-) diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index 0954f2b1d7..086183941f 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -1,13 +1,22 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Deferred, Effect, Fiber, FileSystem, Layer, ManagedRuntime, Schedule } from "effect"; +import { + Cause, + Deferred, + Effect, + Fiber, + FileSystem, + Layer, + ManagedRuntime, + Schedule, +} from "effect"; import { HttpServer } from "effect/unstable/http"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager, managedStackManagerLayer } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl } from "./managed/control.ts"; +import { acquireControl, ControlTransport } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; @@ -304,6 +313,88 @@ describe("managed stack lifecycle journeys", () => { ); }); + it.live( + "keeps a stack document when its identity changes before delete ownership settles", + () => { + const { layer, workspace } = setup(); + const copied = join(workspace, "..", "delete-race-copy"); + let armed = false; + let markerSwapped!: Deferred.Deferred; + const gatedTransport = Layer.effect( + ControlTransport, + Effect.gen(function* () { + const base = yield* ControlTransport; + return { + ...base, + read: (endpoint: Parameters[0]) => + Effect.gen(function* () { + if (armed) { + armed = false; + writeFileSync( + join(copied, ".supabase", "identity.json"), + readFileSync(join(workspace, ".supabase", "identity.json")), + ); + yield* Deferred.succeed(markerSwapped, void 0); + } + return yield* base.read(endpoint); + }), + } satisfies typeof base; + }), + ).pipe(Layer.provide(controlTransportLayer)); + const managerLayer = layer.pipe(Layer.provide(gatedTransport)); + return Effect.scoped( + Effect.gen(function* () { + markerSwapped = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const originalEnvironment = yield* ensureEnvironment(workspace); + const originalStackId = deriveStackId(originalEnvironment.identity, "default"); + const originalOwner = yield* acquireControl({ stackId: originalStackId }); + if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); + const original = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: originalOwner, + lifecycle: "stopped", + }); + yield* releaseLease(original); + yield* originalOwner.close; + + mkdirSync(copied); + const copiedEnvironment = yield* ensureEnvironment(copied); + const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); + const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); + if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); + const copiedStack = yield* manager.startStack({ + workspacePath: copied, + portDocument: automaticDocument(), + ownership: copiedOwner, + lifecycle: "stopped", + }); + yield* releaseLease(copiedStack); + + armed = true; + const deleting = yield* Effect.forkScoped(deleteManagedStack({ workspacePath: copied })); + yield* Deferred.await(markerSwapped); + yield* copiedOwner.close; + const result = yield* Fiber.join(deleting).pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + expect(yield* manager.inspectStack(copiedStackId)).toBeDefined(); + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(gatedTransport), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + ); + }, + ); + it.live("deletes an owned stack when its document path is a directory", () => { const { layer, stateRoot, workspace } = setup(); return Effect.scoped( diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index d88a09795f..f5a54d265e 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -121,6 +121,51 @@ describe("managed stack projects journeys", () => { ); }); + it.live( + "keeps a live ordinary owner as collision evidence after the old path becomes Git", + () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const ownership = yield* acquireControl({ stackId }); + if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + const started = yield* manager.startStack({ + workspacePath: workspace, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "running", + }); + yield* started.lease.releaseAll; + + const copied = join(workspace, "..", "workspace-copy-live-owner"); + cpSync(workspace, copied, { recursive: true }); + git(workspace, "init", "-q", "-b", "main"); + const blocked = yield* manager.discoverWorkspace(copied).pipe(Effect.exit); + expect(Exit.isFailure(blocked)).toBe(true); + if (Exit.isFailure(blocked)) { + expect(Cause.squash(blocked.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + + yield* ownership.close; + const allowed = yield* manager.discoverWorkspace(copied); + expect(allowed.state).toBe("ready"); + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }, + ); + it.live("rejects empty and ASCII-control stack names before resolving a stack", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index 90cb704711..925db33593 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -11,6 +11,7 @@ import { ManagedStackAttachedError, ManagedStackControlRequiredError, ManagedStackManager, + ManagedWorkspaceRepairConflictError, workspaceRepairConflict, type ManagedStackManagerError, type ManagedStackLaunchUpdate, @@ -273,6 +274,14 @@ export const deleteManagedStack = ( ), Effect.mapError(() => new ManagedStackAttachedError({ stackId })), ); + const revalidatedStackId = yield* stackIdForInput(manager, input); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before delete", + }), + ); + } const result = yield* manager.deleteStack(stackId, acquisition); if (result.outcome === "already-absent") return yield* Effect.fail(noRunningStack(input)); }), diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 0db8766d52..22f2e993bd 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -419,7 +419,15 @@ const makeManager = ( const persistedInspection = yield* provideDependencies( inspectWorkspace(canonicalPersistedPath), ); - if (persistedInspection.kind !== "ordinary-folder") continue; + if ( + persistedInspection.kind === "git-checkout" && + (document.lifecycle === "starting" || document.lifecycle === "running") + ) { + const owner = yield* provideDependencies(probeControl(document.id)); + if (owner === undefined) continue; + } else if (persistedInspection.kind !== "ordinary-folder") { + continue; + } const marker = yield* provideDependencies( readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), ); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index e16b9d11df..61c50b335e 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -5,6 +5,7 @@ import { createServer as createHttpServer } from "node:http"; import { createConnection, createServer } from "node:net"; import { cpSync, + chmodSync, existsSync, type FSWatcher, mkdtempSync, @@ -28,7 +29,9 @@ import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts" import { resolveConfig } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; +import { reservePortSet } from "./PortAllocator.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; +import { git } from "../tests/helpers/git-workspace.ts"; const childEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-child.ts", import.meta.url), @@ -39,6 +42,30 @@ const errorChildEntryPoint = fileURLToPath( const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; const FILE_WAIT_TIMEOUT_MS = 30_000; +const reserveWorkspacePorts = async ( + controlPort: number, +): Promise<{ readonly apiPort: number; readonly dbPort: number }> => { + const lease = await Effect.runPromise( + reservePortSet( + [ + { field: "apiPort", selection: { kind: "automatic" as const } }, + { field: "dbPort", selection: { kind: "automatic" as const } }, + ], + { reserved: new Set([controlPort]) }, + ), + ); + try { + const apiPort = lease.ports.apiPort; + const dbPort = lease.ports.dbPort; + if (apiPort === undefined || dbPort === undefined) { + throw new Error("expected isolated supervisor test ports"); + } + return { apiPort, dbPort }; + } finally { + await Effect.runPromise(lease.releaseAll); + } +}; + type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; interface ChildHandle { @@ -51,30 +78,43 @@ const workspace = async (): Promise<{ readonly root: string; readonly stateRoot: string; readonly stackId: string; + readonly apiPort: number; + readonly dbPort: number; }> => { - const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); - const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-state-")); - mkdirSync(join(root, ".supabase"), { recursive: true }); - const identity: EnvironmentIdentity = { - workspaceId: randomUUID(), - checkoutId: randomUUID(), - contextId: randomUUID(), - localProjectKey: ".", - }; - writeFileSync( - join(root, ".supabase", "identity.json"), - `${JSON.stringify( - { - version: 1, - workspaceId: identity.workspaceId, - checkoutId: identity.checkoutId, - contextId: identity.contextId, - }, - null, - 2, - )}\n`, - ); - return { root, stateRoot, stackId: deriveStackId(identity, "default") }; + for (let attempt = 0; attempt < 32; attempt += 1) { + const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); + const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-state-")); + const identity: EnvironmentIdentity = { + workspaceId: randomUUID(), + checkoutId: randomUUID(), + contextId: randomUUID(), + localProjectKey: ".", + }; + const stackId = deriveStackId(identity, "default"); + const endpoint = await Effect.runPromise(controlEndpoint(stackId)); + if (!(await canBind(endpoint.port))) { + rmSync(root, { recursive: true, force: true }); + rmSync(stateRoot, { recursive: true, force: true }); + continue; + } + const { apiPort, dbPort } = await reserveWorkspacePorts(endpoint.port); + mkdirSync(join(root, ".supabase"), { recursive: true }); + writeFileSync( + join(root, ".supabase", "identity.json"), + `${JSON.stringify( + { + version: 1, + workspaceId: identity.workspaceId, + checkoutId: identity.checkoutId, + contextId: identity.contextId, + }, + null, + 2, + )}\n`, + ); + return { root, stateRoot, stackId, apiPort, dbPort }; + } + throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); }; const waitForFile = (path: string): Promise => @@ -115,6 +155,8 @@ const messageFor = ( readonly root: string; readonly stateRoot: string; readonly stackId: string; + readonly apiPort: number; + readonly dbPort: number; }, overrides: Partial = {}, ): SupervisorStartMessage => ({ @@ -141,7 +183,7 @@ const messageFor = ( }, portIntents: { activeFields: ["apiPort", "dbPort"], - document: {}, + document: { api: { port: roots.apiPort }, db: { port: roots.dbPort } }, }, ...overrides, }); @@ -758,6 +800,61 @@ describe("detached supervisor child journeys", () => { } }); + test("rejects a copied contender after dead-owner takeover before Docker cleanup", async () => { + const roots = await workspace(); + const copied = `${roots.root}-copy`; + const dockerBin = join(roots.root, "fake-docker-bin"); + const dockerSentinel = join(roots.root, "docker-called"); + mkdirSync(dockerBin); + const docker = join(dockerBin, "docker"); + writeFileSync(docker, `#!/bin/sh\nprintf called >> ${dockerSentinel}\n`); + chmodSync(docker, 0o755); + const owner = spawnChild(messageFor(roots), { testMode: "hold-start" }); + void owner.started.catch(() => undefined); + let contender: ChildHandle | undefined; + let fakeOwner: ReturnType | undefined; + try { + const starting = await waitForStackDocument(roots, "starting"); + const endpoint = await Effect.runPromise(controlEndpoint(starting.id)); + const stop = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + expect(stop.status).toBe(202); + await waitForExit(owner.child); + expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); + + cpSync(roots.root, copied, { recursive: true }); + const originalGit = join(roots.root, ".git"); + git(roots.root, "init", "-q", "-b", "main"); + git(roots.root, "commit", "-q", "--allow-empty", "-m", "init"); + fakeOwner = await listenOwnerSequence( + endpoint, + starting.id, + Array.from({ length: 100 }, () => "stopping" as const), + ); + contender = spawnChild(messageFor(roots, { workspacePath: copied }), { + environment: { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }, + }); + await contender.attachedBeforeReady; + + rmSync(originalGit, { recursive: true, force: true }); + const documentPath = managedStackDocumentPath(roots.stateRoot, starting.id); + const document = JSON.parse(readFileSync(documentPath, "utf8")) as Record; + writeFileSync(documentPath, JSON.stringify({ ...document, lifecycle: "running" })); + fakeOwner.close(); + fakeOwner = undefined; + + await expect(contender.started).rejects.toThrow( + /ordinary workspace identity.*\.supabase\/identity\.json/, + ); + expect(existsSync(dockerSentinel)).toBe(false); + } finally { + fakeOwner?.close(); + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + rmSync(copied, { recursive: true, force: true }); + cleanupRoots(roots); + } + }); + test("does not mark an existing stopped document failed when discovery fails after control bind", async () => { const roots = await workspace(); const initial = spawnChild(messageFor(roots)); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index 5cce993d18..478b489ac9 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -414,7 +414,7 @@ const runManaged = ( : Effect.fail(error), ), ); - if (acquisition._tag === "Attached") { + if (initialAcquisition._tag === "Attached") { const revalidated = yield* manager.ensureWorkspace(input.workspacePath); const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); if (revalidatedStackId !== stackId) { @@ -424,6 +424,8 @@ const runManaged = ( }), ); } + } + if (acquisition._tag === "Attached") { yield* sendMessage({ type: "started", endpoint: acquisition.endpoint, attached: true }); process.disconnect?.(); return; From 056bb12395a0c6d31ab1881107904e8cdaa03bb0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 18 Aug 2026 10:45:46 +0200 Subject: [PATCH 6/7] test(stack): synchronize attached owner handoff --- .../stack/src/supervisor.integration.test.ts | 19 ++++++++++++++++--- .../stack/tests/helpers/supervisor-child.ts | 13 +++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index 61c50b335e..609b2587d3 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -1043,20 +1043,33 @@ describe("detached supervisor child journeys", () => { test("bounds attached-owner recovery to one startup deadline", { timeout: 10_000 }, async () => { const roots = await workspace(); const input = messageFor(roots); - const environment = { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400" }; - const owner = spawnChild(input, { testMode: "hold-start", environment }); + const attachedReady = join(roots.root, "attached-before-ready-ready"); + const attachedRelease = join(roots.root, "attached-before-ready-release"); + const owner = spawnChild(input, { + testMode: "hold-start", + environment: { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400" }, + }); void owner.started.catch(() => undefined); let contender: ChildHandle | undefined; let fakeOwner: ReturnType | undefined; try { const document = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - contender = spawnChild(input, { testMode: "hold-start", environment }); + contender = spawnChild(input, { + testMode: "hold-start", + environment: { + SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400", + SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, + SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, + }, + }); void contender.started.catch(() => undefined); await contender.attachedBeforeReady; + await waitForFile(attachedReady); await kill(owner.child); fakeOwner = await listenStartingOwner(endpoint, document.id); + writeFileSync(attachedRelease, "release"); await expect(contender.started).rejects.toMatchObject({ message: expect.stringContaining("Timed out resolving attached supervisor owner"), }); diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 1ee26bc9e2..5c8945b3c3 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -135,8 +135,17 @@ const testRuntime = ({ }); }; +const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { + const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; + const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; + if (readyFile === undefined || releaseFile === undefined) return Effect.void; + return Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( + Effect.andThen(waitForFile(releaseFile)), + ); +}; + const sendTestStage = (): Effect.Effect => - Effect.callback((resume) => { + Effect.callback((resume) => { if (process.send === undefined || !process.connected) { resume(Effect.void); return Effect.void; @@ -159,7 +168,7 @@ const sendTestStage = (): Effect.Effect => ); } return Effect.void; - }); + }).pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); const resolutionTimeout = (): Duration.Input => { const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); From ad9a77b68404cec635791f9ff2b1965e953ad59b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 18 Aug 2026 11:14:43 +0200 Subject: [PATCH 7/7] fix(stack): close copied identity lifecycle gaps --- ...aged-manager-lifecycle.integration.test.ts | 80 ++++++++++++++++++ ...naged-manager-projects.integration.test.ts | 45 ---------- packages/stack/src/managed/lifecycle.ts | 8 ++ packages/stack/src/managed/manager.ts | 10 +-- .../stack/src/supervisor.integration.test.ts | 83 +++++++++---------- .../stack/tests/helpers/supervisor-child.ts | 32 ++++++- 6 files changed, 156 insertions(+), 102 deletions(-) diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index 086183941f..f75f0df0d0 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -395,6 +395,86 @@ describe("managed stack lifecycle journeys", () => { }, ); + it.live("rejects stop when workspace identity changes while control ownership settles", () => { + const { layer, workspace } = setup(); + const copied = join(workspace, "..", "stop-race-copy"); + let armed = false; + let markerSwapped!: Deferred.Deferred; + const gatedTransport = Layer.effect( + ControlTransport, + Effect.gen(function* () { + const base = yield* ControlTransport; + return { + ...base, + read: (endpoint: Parameters[0]) => + Effect.gen(function* () { + if (armed) { + armed = false; + writeFileSync( + join(copied, ".supabase", "identity.json"), + readFileSync(join(workspace, ".supabase", "identity.json")), + ); + yield* Deferred.succeed(markerSwapped, void 0); + } + return yield* base.read(endpoint); + }), + } satisfies typeof base; + }), + ).pipe(Layer.provide(controlTransportLayer)); + const managerLayer = layer.pipe(Layer.provide(gatedTransport)); + return Effect.scoped( + Effect.gen(function* () { + markerSwapped = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const originalEnvironment = yield* ensureEnvironment(workspace); + const originalStackId = deriveStackId(originalEnvironment.identity, "default"); + const originalOwner = yield* acquireControl({ stackId: originalStackId }); + if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); + const original = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: originalOwner, + lifecycle: "stopped", + }); + yield* releaseLease(original); + yield* originalOwner.close; + + mkdirSync(copied); + const copiedEnvironment = yield* ensureEnvironment(copied); + const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); + const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); + if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); + const copiedStack = yield* manager.startStack({ + workspacePath: copied, + portDocument: automaticDocument(), + ownership: copiedOwner, + lifecycle: "starting", + }); + yield* releaseLease(copiedStack); + + armed = true; + const stopping = yield* Effect.forkScoped(stopManagedStack({ workspacePath: copied })); + yield* Deferred.await(markerSwapped); + yield* copiedOwner.close; + const result = yield* Fiber.join(stopping).pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + expect((yield* manager.inspectStack(copiedStackId))?.lifecycle).toBe("starting"); + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(gatedTransport), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(httpTransportClientLayer), + ); + }); + it.live("deletes an owned stack when its document path is a directory", () => { const { layer, stateRoot, workspace } = setup(); return Effect.scoped( diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index f5a54d265e..d88a09795f 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -121,51 +121,6 @@ describe("managed stack projects journeys", () => { ); }); - it.live( - "keeps a live ordinary owner as collision evidence after the old path becomes Git", - () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId }); - if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); - const started = yield* manager.startStack({ - workspacePath: workspace, - stackName: "default", - portDocument: automaticDocument(), - ownership, - lifecycle: "running", - }); - yield* started.lease.releaseAll; - - const copied = join(workspace, "..", "workspace-copy-live-owner"); - cpSync(workspace, copied, { recursive: true }); - git(workspace, "init", "-q", "-b", "main"); - const blocked = yield* manager.discoverWorkspace(copied).pipe(Effect.exit); - expect(Exit.isFailure(blocked)).toBe(true); - if (Exit.isFailure(blocked)) { - expect(Cause.squash(blocked.cause)).toMatchObject({ - _tag: "InvalidManagedIdentityError", - }); - } - - yield* ownership.close; - const allowed = yield* manager.discoverWorkspace(copied); - expect(allowed.state).toBe("ready"); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }, - ); - it.live("rejects empty and ASCII-control stack names before resolving a stack", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index 925db33593..5387a01b25 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -128,6 +128,14 @@ export const stopManagedStack = ( const document = yield* resolveManagedDocument(input); const stackId = document.id; const acquisition = yield* manager.acquireControl(stackId); + const revalidatedStackId = yield* stackIdForInput(manager, input); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before stop", + }), + ); + } if (acquisition._tag === "Owned") { if ( document.lifecycle === "running" || diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 22f2e993bd..0db8766d52 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -419,15 +419,7 @@ const makeManager = ( const persistedInspection = yield* provideDependencies( inspectWorkspace(canonicalPersistedPath), ); - if ( - persistedInspection.kind === "git-checkout" && - (document.lifecycle === "starting" || document.lifecycle === "running") - ) { - const owner = yield* provideDependencies(probeControl(document.id)); - if (owner === undefined) continue; - } else if (persistedInspection.kind !== "ordinary-folder") { - continue; - } + if (persistedInspection.kind !== "ordinary-folder") continue; const marker = yield* provideDependencies( readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), ); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index 609b2587d3..a87342d194 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -17,7 +17,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; @@ -29,7 +29,6 @@ import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts" import { resolveConfig } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; -import { reservePortSet } from "./PortAllocator.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; import { git } from "../tests/helpers/git-workspace.ts"; @@ -42,30 +41,6 @@ const errorChildEntryPoint = fileURLToPath( const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; const FILE_WAIT_TIMEOUT_MS = 30_000; -const reserveWorkspacePorts = async ( - controlPort: number, -): Promise<{ readonly apiPort: number; readonly dbPort: number }> => { - const lease = await Effect.runPromise( - reservePortSet( - [ - { field: "apiPort", selection: { kind: "automatic" as const } }, - { field: "dbPort", selection: { kind: "automatic" as const } }, - ], - { reserved: new Set([controlPort]) }, - ), - ); - try { - const apiPort = lease.ports.apiPort; - const dbPort = lease.ports.dbPort; - if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected isolated supervisor test ports"); - } - return { apiPort, dbPort }; - } finally { - await Effect.runPromise(lease.releaseAll); - } -}; - type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; interface ChildHandle { @@ -78,8 +53,6 @@ const workspace = async (): Promise<{ readonly root: string; readonly stateRoot: string; readonly stackId: string; - readonly apiPort: number; - readonly dbPort: number; }> => { for (let attempt = 0; attempt < 32; attempt += 1) { const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); @@ -97,7 +70,6 @@ const workspace = async (): Promise<{ rmSync(stateRoot, { recursive: true, force: true }); continue; } - const { apiPort, dbPort } = await reserveWorkspacePorts(endpoint.port); mkdirSync(join(root, ".supabase"), { recursive: true }); writeFileSync( join(root, ".supabase", "identity.json"), @@ -112,7 +84,7 @@ const workspace = async (): Promise<{ 2, )}\n`, ); - return { root, stateRoot, stackId, apiPort, dbPort }; + return { root, stateRoot, stackId }; } throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); }; @@ -133,10 +105,8 @@ const waitForFile = (path: string): Promise => watcher?.close(); continuation(); }; - watcher = watch(dirname(path), (_eventType, filename) => { - if (filename?.toString() === basename(path) && existsSync(path)) { - settle(resolve); - } + watcher = watch(dirname(path), () => { + if (existsSync(path)) settle(resolve); }); watcher.once("error", (cause) => { settle(() => reject(cause)); @@ -155,8 +125,6 @@ const messageFor = ( readonly root: string; readonly stateRoot: string; readonly stackId: string; - readonly apiPort: number; - readonly dbPort: number; }, overrides: Partial = {}, ): SupervisorStartMessage => ({ @@ -183,7 +151,7 @@ const messageFor = ( }, portIntents: { activeFields: ["apiPort", "dbPort"], - document: { api: { port: roots.apiPort }, db: { port: roots.dbPort } }, + document: {}, }, ...overrides, }); @@ -278,6 +246,7 @@ const spawnChild = ( child.once("error", onError); child.once("exit", onExit); }); + void started.catch(() => undefined); void attachedBeforeReady.catch(() => undefined); child.send(input); return { child, started, attachedBeforeReady }; @@ -416,6 +385,7 @@ const listenOwnerSequence = async ( ownershipId: string, states: ReadonlyArray<"starting" | "stopping">, onRead: (state: "starting" | "stopping") => void = () => undefined, + closeAfterSequence = true, ): Promise> => { for (let attempt = 0; attempt < 100; attempt += 1) { let reads = 0; @@ -437,7 +407,7 @@ const listenOwnerSequence = async ( ready: false, }), () => { - if (reads >= states.length) server.close(); + if (closeAfterSequence && reads >= states.length) server.close(); }, ); }); @@ -458,6 +428,31 @@ const listenOwnerSequence = async ( throw new Error(`timed out binding fake owner at ${endpoint.url}`); }; +const listenStoppingOwner = async ( + endpoint: ControlEndpoint, + ownershipId: string, +): Promise<{ + readonly server: ReturnType; + readonly release: () => void; +}> => { + const server = await listenOwnerSequence( + endpoint, + ownershipId, + ["stopping"], + () => undefined, + false, + ); + let released = false; + return { + server, + release: () => { + if (released) return; + released = true; + server.close(); + }, + }; +}; + const cleanupRoots = (roots: { readonly root: string; readonly stateRoot: string }): void => { rmSync(roots.root, { recursive: true, force: true }); rmSync(roots.stateRoot, { recursive: true, force: true }); @@ -813,6 +808,7 @@ describe("detached supervisor child journeys", () => { void owner.started.catch(() => undefined); let contender: ChildHandle | undefined; let fakeOwner: ReturnType | undefined; + let releaseFakeOwner: (() => void) | undefined; try { const starting = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(starting.id)); @@ -825,11 +821,9 @@ describe("detached supervisor child journeys", () => { const originalGit = join(roots.root, ".git"); git(roots.root, "init", "-q", "-b", "main"); git(roots.root, "commit", "-q", "--allow-empty", "-m", "init"); - fakeOwner = await listenOwnerSequence( - endpoint, - starting.id, - Array.from({ length: 100 }, () => "stopping" as const), - ); + const stoppingOwner = await listenStoppingOwner(endpoint, starting.id); + fakeOwner = stoppingOwner.server; + releaseFakeOwner = stoppingOwner.release; contender = spawnChild(messageFor(roots, { workspacePath: copied }), { environment: { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }, }); @@ -839,7 +833,8 @@ describe("detached supervisor child journeys", () => { const documentPath = managedStackDocumentPath(roots.stateRoot, starting.id); const document = JSON.parse(readFileSync(documentPath, "utf8")) as Record; writeFileSync(documentPath, JSON.stringify({ ...document, lifecycle: "running" })); - fakeOwner.close(); + releaseFakeOwner?.(); + releaseFakeOwner = undefined; fakeOwner = undefined; await expect(contender.started).rejects.toThrow( diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 5c8945b3c3..6f28597d78 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -2,7 +2,8 @@ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { BunFileSystem, BunServices } from "@effect/platform-bun"; import { Effect, Layer, Stream, Duration } from "effect"; import { createServer, type Server } from "node:net"; -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, type FSWatcher, watch, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { runSupervisor, SupervisorStartError, @@ -139,9 +140,32 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; if (readyFile === undefined || releaseFile === undefined) return Effect.void; - return Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( - Effect.andThen(waitForFile(releaseFile)), - ); + return Effect.callback((resume) => { + let settled = false; + let watcher: FSWatcher | undefined; + const cleanup = () => { + watcher?.close(); + watcher = undefined; + }; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(result); + }; + const resolveIfReleased = () => { + if (existsSync(releaseFile)) settle(Effect.void); + }; + try { + watcher = watch(dirname(releaseFile), () => resolveIfReleased()); + watcher.once("error", (cause) => settle(Effect.die(cause))); + writeFileSync(readyFile, "ready"); + resolveIfReleased(); + } catch (cause) { + settle(Effect.die(cause)); + } + return Effect.sync(cleanup); + }); }; const sendTestStage = (): Effect.Effect =>