From f58e413c541c51ef8dab79f033125cb2526f63dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:42:49 +0000 Subject: [PATCH 1/5] test(cli): wait for each stalled sso list request before advancing the clock The stalled-list test advanced the TestClock in fixed steps right after forking the command. When the command's setup took longer than those steps, the request timeout was scheduled after the clock had already moved, and the test hung until the real test timeout. The mock now signals each stalled attempt and the test advances the clock only after that attempt is in flight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MBEk1JxfLqmcaNWvN6MRyG --- .../sso/list/list.integration.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index d4db9de584..392862de07 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Fiber, FileSystem, Option, Schema } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { TestClock } from "effect/testing"; @@ -88,6 +88,8 @@ function setup(opts: SetupOpts = {}) { const status = opts.status ?? 200; const body = opts.body ?? { items: [PROVIDER_ITEM] }; const gate = opts.upgradeGate; + const stalledAttempts = [Deferred.makeUnsafe(), Deferred.makeUnsafe()] as const; + let stalledCount = 0; const api = mockCommandPlatformApi({ network: opts.network, @@ -95,7 +97,11 @@ function setup(opts: SetupOpts = {}) { const url = request.url; if (url.includes("/config/auth/sso/providers")) { if (opts.stallProviders === true) { - return Effect.never; + const arrived = stalledAttempts[stalledCount++]; + return Effect.andThen( + arrived === undefined ? Effect.void : Deferred.succeed(arrived, undefined), + Effect.never, + ); } return Effect.succeed(jsonResponse(request, status, body)); } @@ -155,7 +161,7 @@ function setup(opts: SetupOpts = {}) { outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }); - return { layer, out, api, analytics, telemetry, cache }; + return { layer, out, api, analytics, telemetry, cache, stalledAttempts }; } describe("sso list integration", () => { @@ -395,7 +401,12 @@ describe("sso list integration", () => { }); it.effect("fails a stalled list within two attempts and warns about the project status", () => { - const { layer, out, api } = setup({ + const { + layer, + out, + api, + stalledAttempts: [firstAttempt, secondAttempt], + } = setup({ stallProviders: true, projectStatus: "INACTIVE", apiMaxRetries: Option.none(), @@ -404,9 +415,11 @@ describe("sso list integration", () => { const fiber = yield* Effect.forkChild( Effect.exit(ssoList({ remote: Option.none(), projectRef: Option.none() })), ); - for (let second = 0; second < 25; second++) { - yield* TestClock.adjust("1 second"); - } + yield* Deferred.await(firstAttempt); + // Covers the 10s attempt timeout plus the first retry's backoff, capped at 250ms. + yield* TestClock.adjust("11 seconds"); + yield* Deferred.await(secondAttempt); + yield* TestClock.adjust("11 seconds"); const exit = yield* Fiber.join(fiber); expect(Exit.isFailure(exit) && Cause.pretty(exit.cause)).toContain("SsoListNetworkError"); From bb875ed6d59d3331240ff26f91ca168e72fa0652 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:45:16 +0000 Subject: [PATCH 2/5] test(cli-e2e): allocate e2e stack ports below the ephemeral range The e2e helper picked stack ports by binding port 0 and releasing it. Those ports come from the kernel's ephemeral range, so an outbound connection on the runner could take one before Docker published it, failing the stack start with `address already in use`. Ports now come from a fixed range below the Linux and macOS ephemeral ranges, probed on every interface as Docker binds them, and still partitioned per e2e worker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MBEk1JxfLqmcaNWvN6MRyG --- apps/cli/tests/helpers/cli.ts | 71 +++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index d4f30c6882..ccdf3eb911 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -16,6 +16,7 @@ import { Path, Predicate, PubSub, + Random, Schema, Scope, Stream, @@ -290,47 +291,53 @@ export const withTempCliProject = ( const portError = (error: Error) => new E2ePortError({ message: error.message, cause: error }); -const pickReleasedPort = Effect.callback((resume) => { - const server = createServer(); - let settled = false; - const settle = (result: Effect.Effect) => { - if (settled) return; - settled = true; - resume(result); - }; - /* Stays attached after cancellation so a late listen error is never an unhandled event. */ - server.on("error", (error) => settle(Effect.fail(portError(error)))); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address == null || typeof address === "string") { - server.close(() => - settle(Effect.fail(new E2ePortError({ message: "Failed to allocate a free port" }))), - ); - return; - } - server.close((error) => { - settle(error ? Effect.fail(portError(error)) : Effect.succeed(address.port)); +const isPortTaken = (error: NodeJS.ErrnoException) => + error.code === "EADDRINUSE" || error.code === "EACCES"; + +/** Succeeds with whether `port` could be bound on every interface, as Docker publishes it. */ +const probePort = (port: number) => + Effect.callback((resume) => { + const server = createServer(); + let settled = false; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + resume(result); + }; + /* Stays attached after cancellation so a late listen error is never an unhandled event. */ + server.on("error", (error: NodeJS.ErrnoException) => + settle(isPortTaken(error) ? Effect.succeed(false) : Effect.fail(portError(error))), + ); + server.listen(port, "0.0.0.0", () => { + server.close((error) => { + settle(error ? Effect.fail(portError(error)) : Effect.succeed(true)); + }); + }); + return Effect.sync(() => { + settled = true; + server.close(); }); }); - return Effect.sync(() => { - settled = true; - server.close(); - }); -}); -/** - * Concurrent e2e workers each keep only ports in their own slot, so a port one worker picked and - * released before Docker binds it is never handed to another worker. Slots skip the low bit - * because Linux hands out mostly odd ports for `listen(0)`. +/* + * Ports come from below the Linux (32768+) and macOS (49152+) ephemeral ranges, so an outbound + * connection cannot take a probed port before Docker binds it. Concurrent e2e workers each keep + * only ports in their own slot, so one worker never hands out a port another worker picked. */ +const FIXED_PORT_RANGE_START = 20_000; +const FIXED_PORT_RANGE_END = 32_768; + const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); const pickFreePort = Effect.gen(function* () { const e2eWorkers = Number((yield* envOrEmpty("SUPABASE_E2E_WORKERS")) || "1"); const e2eWorkerSlot = Number((yield* envOrEmpty("VITEST_POOL_ID")) || "1") % e2eWorkers; - for (let attempt = 0; attempt < 1000; attempt++) { - const port = yield* pickReleasedPort; - if (Math.floor(port / 2) % e2eWorkers === e2eWorkerSlot) return port; + const span = FIXED_PORT_RANGE_END - FIXED_PORT_RANGE_START; + const offset = yield* Random.nextIntBetween(0, span, { halfOpen: true }); + for (let step = 0; step < span; step++) { + const port = FIXED_PORT_RANGE_START + ((offset + step) % span); + if (port % e2eWorkers !== e2eWorkerSlot) continue; + if (yield* probePort(port)) return port; } return yield* new E2ePortError({ message: `No free port for e2e worker slot ${e2eWorkerSlot} of ${e2eWorkers}`, From 2582905772b030f45b187ad0658836dab9bbf5ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:46:30 +0000 Subject: [PATCH 3/5] test(stack): name the stalled phase in the storage crash-safety test The kill-between-staging-and-publishing test hit the 20s test timeout without saying which step hung; only the writer's readiness had its own deadline. Each remaining phase (helper image, prepare, PG_VERSION seed, killed writer exit and pipe close, markInitialized retry, and both cleanups) now fails with a tagged error naming the phase well before the test timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MBEk1JxfLqmcaNWvN6MRyG --- .../DockerDatabaseStorage.integration.test.ts | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 259b482ae9..c379cbe225 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -6,6 +6,7 @@ import { ConfigProvider, Data, Deferred, + Duration, Effect, Exit, Fiber, @@ -71,6 +72,19 @@ class DockerTestError extends Data.TaggedError("DockerTestError")<{ class PublishBarrierError extends Data.TaggedError("PublishBarrierError")<{ readonly message: string; }> {} +class PhaseStallError extends Data.TaggedError("PhaseStallError")<{ + readonly message: string; +}> {} + +/** Fails with the stalled phase's name before the test's own timeout hides which step hung. */ +const phaseDeadline = + (phase: string, seconds = 5) => + (self: Effect.Effect) => + Effect.timeoutOrElse(self, { + duration: Duration.seconds(seconds), + orElse: () => + Effect.fail(new PhaseStallError({ message: `${phase} did not finish within ${seconds}s` })), + }); const dockerStoragePublishLoopFixture = fileURLToPath( new URL("../../tests/docker-storage-publish-loop-fixture.ts", import.meta.url), @@ -1111,7 +1125,9 @@ describe.runIf(testEngine === "docker")( const cacheRoot = path.join(root, "cache"); yield* fs.makeDirectory(instanceRoot, { recursive: true }); const markerPath = path.join(instanceRoot, ".supabase-database-storage.json"); - const helperImage = yield* preparedPostgresImage(selection.database); + const helperImage = yield* preparedPostgresImage(selection.database).pipe( + phaseDeadline("helper image preparation", 10), + ); const storage = yield* makeDockerDatabaseStorage({ runtime: "docker", target: dockerTarget, @@ -1134,9 +1150,11 @@ describe.runIf(testEngine === "docker")( // the global test-run teardown, since a container that mounted it may not yet be // reaped by the time that teardown runs. yield* Effect.addFinalizer(() => - storage.destroyData(selection.database).pipe(Effect.ignore), + storage + .destroyData(selection.database) + .pipe(Effect.ignore, phaseDeadline("destroyData cleanup"), Effect.orDie), ); - yield* storage.prepare(selection.database); + yield* storage.prepare(selection.database).pipe(phaseDeadline("prepare")); const before = yield* Schema.decodeEffect(Schema.fromJsonString(Marker))( yield* fs.readFileString(markerPath), ); @@ -1160,6 +1178,8 @@ describe.runIf(testEngine === "docker")( : engine(["rm", "--force", ...ids]).pipe(Effect.asVoid); }), Effect.ignore, + phaseDeadline(`helper container cleanup for volume ${volume}`), + Effect.orDie, ), ); // `markInitialized` (below, and inside the killed writer) checks this file through a @@ -1174,7 +1194,7 @@ describe.runIf(testEngine === "docker")( "/bin/sh", "-c", `set -eu; mkdir -p ${quote(`/store/${before.namespace}/data`)}; printf 17 > ${quote(`/store/${before.namespace}/data/PG_VERSION`)}`, - ]); + ]).pipe(phaseDeadline(`PG_VERSION seed in volume ${volume}`)); const writer = yield* spawner.spawn( ChildProcess.make( @@ -1223,9 +1243,9 @@ describe.runIf(testEngine === "docker")( }), ); yield* Effect.sync(() => process.kill(Number(writer.pid), "SIGKILL")); - yield* writer.exitCode.pipe(Effect.ignore); - yield* Fiber.join(output); - yield* Fiber.join(diagnostics); + yield* writer.exitCode.pipe(Effect.ignore, phaseDeadline("killed writer exit")); + yield* Fiber.join(output).pipe(phaseDeadline("killed writer stdout close")); + yield* Fiber.join(diagnostics).pipe(phaseDeadline("killed writer stderr close")); // The killed writer never reached the rename: the prior marker is untouched, and only // its staging file, never a partial target, is left behind. @@ -1237,7 +1257,9 @@ describe.runIf(testEngine === "docker")( (yield* fs.readDirectory(instanceRoot)).filter((entry) => entry.endsWith(".tmp")), ).toHaveLength(1); - yield* storage.markInitialized(selection.database); + yield* storage + .markInitialized(selection.database) + .pipe(phaseDeadline("markInitialized retry")); const afterRetry = yield* Schema.decodeEffect(Schema.fromJsonString(Marker))( yield* fs.readFileString(markerPath), ); From 872a38be8c6b974c86efa602bb4c4852afdd5ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:51:49 +0000 Subject: [PATCH 4/5] test(cli-e2e): never hand out the same e2e port twice in one worker Each pick drew a fresh random start in the fixed range, so the many ports a temp stack project allocates at once could repeat within a worker's slot. The picker now walks the range from one random start per worker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MBEk1JxfLqmcaNWvN6MRyG --- apps/cli/tests/helpers/cli.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index ccdf3eb911..d92eec6270 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -329,14 +329,20 @@ const FIXED_PORT_RANGE_END = 32_768; const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); +const FIXED_PORT_SPAN = FIXED_PORT_RANGE_END - FIXED_PORT_RANGE_START; +/* Walks the range from a random start, so one worker never hands out the same port twice. */ +let nextPortOffset: number | undefined; + const pickFreePort = Effect.gen(function* () { const e2eWorkers = Number((yield* envOrEmpty("SUPABASE_E2E_WORKERS")) || "1"); const e2eWorkerSlot = Number((yield* envOrEmpty("VITEST_POOL_ID")) || "1") % e2eWorkers; - const span = FIXED_PORT_RANGE_END - FIXED_PORT_RANGE_START; - const offset = yield* Random.nextIntBetween(0, span, { halfOpen: true }); - for (let step = 0; step < span; step++) { - const port = FIXED_PORT_RANGE_START + ((offset + step) % span); + const start = + nextPortOffset ?? (yield* Random.nextIntBetween(0, FIXED_PORT_SPAN, { halfOpen: true })); + for (let step = 0; step < FIXED_PORT_SPAN; step++) { + const offset = (start + step) % FIXED_PORT_SPAN; + const port = FIXED_PORT_RANGE_START + offset; if (port % e2eWorkers !== e2eWorkerSlot) continue; + nextPortOffset = offset + 1; if (yield* probePort(port)) return port; } return yield* new E2ePortError({ From fb720ffde2e99a69ee6f6e4ab59a4d74634b0641 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 5 Nov 2026 09:50:39 +0100 Subject: [PATCH 5/5] test(cli-e2e): pick e2e ports below the stack's allocation ranges The fixed 20000-32767 range was the stack's public auto-port range, so a concurrent stack-mode e2e could bind a port another worker had probed but not yet published. Ports now come from 1024-9999, below both stack ranges and the OS ephemeral ranges, and are also probed on loopback because macOS lets a wildcard bind succeed over a loopback-only listener. --- apps/cli/tests/helpers/cli.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index d92eec6270..70eab016f2 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -294,8 +294,8 @@ const portError = (error: Error) => new E2ePortError({ message: error.message, c const isPortTaken = (error: NodeJS.ErrnoException) => error.code === "EADDRINUSE" || error.code === "EACCES"; -/** Succeeds with whether `port` could be bound on every interface, as Docker publishes it. */ -const probePort = (port: number) => +/** Succeeds with whether `port` could be bound on `host`. */ +const probePort = (port: number, host: string) => Effect.callback((resume) => { const server = createServer(); let settled = false; @@ -308,7 +308,7 @@ const probePort = (port: number) => server.on("error", (error: NodeJS.ErrnoException) => settle(isPortTaken(error) ? Effect.succeed(false) : Effect.fail(portError(error))), ); - server.listen(port, "0.0.0.0", () => { + server.listen(port, host, () => { server.close((error) => { settle(error ? Effect.fail(portError(error)) : Effect.succeed(true)); }); @@ -320,12 +320,13 @@ const probePort = (port: number) => }); /* - * Ports come from below the Linux (32768+) and macOS (49152+) ephemeral ranges, so an outbound - * connection cannot take a probed port before Docker binds it. Concurrent e2e workers each keep - * only ports in their own slot, so one worker never hands out a port another worker picked. + * Ports come from below the stack's own allocation ranges (10000+, see `packages/stack/src/Ports.ts`) + * and the OS ephemeral ranges, so neither a concurrent stack nor an outbound connection can take a + * probed port before Docker binds it. Concurrent e2e workers each keep only ports in their own + * slot, so one worker never hands out a port another worker picked. */ -const FIXED_PORT_RANGE_START = 20_000; -const FIXED_PORT_RANGE_END = 32_768; +const FIXED_PORT_RANGE_START = 1_024; +const FIXED_PORT_RANGE_END = 10_000; const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); @@ -343,7 +344,8 @@ const pickFreePort = Effect.gen(function* () { const port = FIXED_PORT_RANGE_START + offset; if (port % e2eWorkers !== e2eWorkerSlot) continue; nextPortOffset = offset + 1; - if (yield* probePort(port)) return port; + // Docker publishes on every interface; macOS lets that bind succeed over a loopback listener. + if ((yield* probePort(port, "0.0.0.0")) && (yield* probePort(port, "127.0.0.1"))) return port; } return yield* new E2ePortError({ message: `No free port for e2e worker slot ${e2eWorkerSlot} of ${e2eWorkers}`,