diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 436f181c09..7416259ac0 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -413,7 +413,7 @@ describe.runIf(testEngine === "docker")( ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("reports a helper readiness timeout alongside Docker warnings", () => + it.effect("reports a helper readiness timeout with Docker warnings and the helper state", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -422,6 +422,7 @@ describe.runIf(testEngine === "docker")( const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-helper-timeout-" }); const waiting = yield* Deferred.make(); const commands: string[][] = []; + let attachKilled = false; const warning = "WARNING: The requested image's platform does not match the host platform"; const spawner = ChildProcessSpawner.make((command) => @@ -431,22 +432,36 @@ describe.runIf(testEngine === "docker")( commands.push([...command.args]); const creating = command.args[0] === "create"; const attaching = command.args[0] === "start"; + const encode = (text: string) => Stream.succeed(new TextEncoder().encode(text)); + // `--rm` removes the helper once its attach is killed, so later inspection fails. + const removed = command.args[0] === "inspect" && attachKilled; return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(0), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(removed ? 1 : 0)), isRunning: Effect.succeed(false), - kill: () => Effect.void, + kill: () => + Effect.sync(() => { + if (attaching) attachKilled = true; + }), stdin: Sink.drain, stdout: attaching ? Stream.fromEffect(Deferred.succeed(waiting, undefined)).pipe( Stream.drain, Stream.concat(Stream.never), ) - : Stream.empty, + : command.args[0] === "inspect" && !removed + ? encode("running (exit 0)\n") + : command.args[0] === "logs" + ? encode("supabase-helper-ready\n") + : Stream.empty, // Docker prints the platform warning while creating the container, before start. stderr: creating - ? Stream.succeed(new TextEncoder().encode(`${warning}\n`)) - : Stream.empty, + ? encode(`${warning}\n`) + : removed + ? encode("Error: No such container\n") + : command.args[0] === "logs" + ? encode("helper stderr line\n") + : Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -482,7 +497,12 @@ describe.runIf(testEngine === "docker")( yield* TestClock.adjust("30 seconds"); const failure = yield* Fiber.join(operation); expect(failure.message).toBe( - `Database helper did not become ready within 30 seconds: ${warning}`, + [ + `Database helper did not become ready within 30 seconds: ${warning}`, + "helper state: running (exit 0)", + "helper logs: supabase-helper-ready", + "helper stderr line", + ].join("\n"), ); const create = commands.find((args) => args[0] === "create"); const name = create?.[create.indexOf("--name") + 1]; diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index c5a6c145fe..1191857a3c 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -375,7 +375,10 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ); const engineCommand = Effect.fn("DockerDatabaseStorage.engineCommand")( - (args: ReadonlyArray): Effect.Effect => + ( + args: ReadonlyArray, + output: "stdout" | "all" = "stdout", + ): Effect.Effect => Effect.scoped( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ @@ -415,7 +418,9 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") "engine", stderr.trim() || `Container engine exited with ${code}`, ); - return stdout.trim(); + return output === "all" + ? [stdout.trim(), stderr.trim()].filter((part) => part !== "").join("\n") + : stdout.trim(); }), ).pipe(Effect.mapError((cause) => errorFor("engine", cause))), ); @@ -638,6 +643,33 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ) .pipe(Effect.mapError((cause) => errorFor("helper", cause))); const stderr = yield* stderrTail(child); + const stalledHelperState = Effect.fn("DockerDatabaseStorage.stalledHelperState")( + function* () { + const [state, logs] = yield* Effect.all( + [ + engineCommand([ + "inspect", + "--format", + "{{.State.Status}} (exit {{.State.ExitCode}}){{if .State.Error}}: {{.State.Error}}{{end}}", + name, + ]), + // `logs` relays the helper's stderr on the engine's own stderr. + engineCommand(["logs", "--tail", "20", name], "all"), + ].map((command) => + command.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.succeed("unavailable: timed out after 5 seconds"), + }), + Effect.catch((cause) => Effect.succeed(`unavailable: ${cause.message}`)), + ), + ), + { concurrency: "unbounded" }, + ); + yield* Effect.annotateCurrentSpan("helper.state", state); + return `helper state: ${state}\nhelper logs: ${logs || "(empty)"}`; + }, + ); return yield* child.stdout.pipe( Stream.decodeText, Stream.splitLines, @@ -650,6 +682,11 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") exit.value.value === "supabase-helper-ready" ? Effect.succeed(name) : Effect.gen(function* () { + const timedOut = + Exit.isFailure(exit) && + Option.exists(Cause.findErrorOption(exit.cause), Cause.isTimeoutError); + // Inspect before the kill: `--rm` removes the helper once its attach ends. + const state = timedOut ? yield* stalledHelperState() : ""; yield* child .kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" }) .pipe(Effect.ignore); @@ -659,7 +696,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ); // Docker prints warnings such as a platform mismatch on `create`, so keep that // output alongside what `start` wrote. - const tail = [createTail, diagnostic.trim()] + const tail = [createTail, diagnostic.trim(), state] .filter((part) => part !== "") .join("\n"); const reason = Exit.isFailure(exit)