From d4e2d749ed1845af8640219bacf70e411d6ed1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:36:37 +0000 Subject: [PATCH 1/3] fix(cli): force-close Edge Runtime worker after pg-delta scripts emit output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supabase db schema declarative sync` could hang indefinitely at 0% CPU after all migrations were applied to the shadow database (supabase/pg-toolbelt#312). Root cause: the pg-delta Deno scripts run inside a one-shot Edge Runtime container and rely on the event loop draining for the worker to be destroyed and the container to exit. The catalog-export script opens a real connection pool (createManagedPool); when a keepalive handle lingers after close() resolves, the worker never exits, so the container never stops. The CLI streams that container's logs with Follow:true (DockerStreamLogs), so a worker that never exits blocks the parent `__catalog` subprocess — and the declarative-sync command that spawned it — forever. Only the error path force-closed the loop (`throw new Error("")`); the success path did not. Fix: force-close the event loop on the success path of every pg-delta Edge Runtime script (diff, declarative-export, catalog-export, and declarative-apply), so the worker is torn down deterministically once output has been flushed. The output is written synchronously before the throw, and RunEdgeRuntimeScript already tolerates the resulting "main worker has been destroyed" exit. Reproduced against supabase/edge-runtime:v1.74.2: a worker with a lingering handle keeps the container `running` and `docker logs -f` (the equivalent of DockerStreamLogs Follow:true) never returns; adding the force-close makes the container exit immediately with output intact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbxecW4DVmwgQB1YX321K3 --- .../internal/db/diff/pgdelta_template_test.go | 48 +++++++++++++++++++ .../internal/db/diff/templates/pgdelta.ts | 6 +++ .../diff/templates/pgdelta_catalog_export.ts | 9 ++++ .../templates/pgdelta_declarative_export.ts | 6 +++ .../pgdelta/pgdelta_apply_template_test.go | 33 +++++++++++++ .../templates/pgdelta_declarative_apply.ts | 7 +++ .../shared/legacy-pgdelta.deno-templates.ts | 8 ++-- 7 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 apps/cli-go/internal/db/diff/pgdelta_template_test.go create mode 100644 apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go diff --git a/apps/cli-go/internal/db/diff/pgdelta_template_test.go b/apps/cli-go/internal/db/diff/pgdelta_template_test.go new file mode 100644 index 0000000000..3fbc2bb792 --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgdelta_template_test.go @@ -0,0 +1,48 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lastCodeLine returns the final non-blank, non-comment line of a script. +func lastCodeLine(script string) string { + lines := strings.Split(script, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + return line + } + return "" +} + +// Every pg-delta edge-runtime script must force the worker's event loop closed +// once its output has been written. The pg connection pool can leave keepalive +// handles registered even after close() resolves; if the worker never exits, +// the container never stops and the CLI — which streams the container logs with +// Follow:true — blocks forever following them, hanging declarative sync at 0% +// CPU (supabase/pg-toolbelt#312). The success path must terminate +// unconditionally rather than rely on the event loop draining on its own, so +// guard against the force-close being dropped from any template's success path. +func TestPgDeltaScriptsForceCloseOnSuccess(t *testing.T) { + scripts := map[string]string{ + "pgdelta.ts": pgDeltaScript, + "pgdelta_declarative_export.ts": pgDeltaDeclarativeExportScript, + "pgdelta_catalog_export.ts": pgDeltaCatalogExportScript, + } + for name, script := range scripts { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, script) + // The terminating statement runs on the success path (the catch + // branch no longer re-throws), so the worker is torn down whether + // or not the body succeeded. + assert.Equal(t, `throw new Error("");`, lastCodeLine(script), + "success path must force the Edge Runtime worker to exit so the container stops") + }) + } +} diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta.ts b/apps/cli-go/internal/db/diff/templates/pgdelta.ts index 306fed6a73..0fd9d00e30 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta.ts @@ -69,3 +69,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the diff has been written, so the container +// never exits and the CLI — which follows this container's logs — hangs +// indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts index 992c5f21a8..6b7d426ce1 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts @@ -21,7 +21,16 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the parent `__catalog` +// subprocess — and the declarative-sync command that spawned it — indefinitely +// at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts index 117f16c58e..4656e4690e 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts @@ -71,3 +71,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the export has been written, so the +// container never exits and the CLI — which follows this container's logs — +// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go new file mode 100644 index 0000000000..c0a7e601f3 --- /dev/null +++ b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go @@ -0,0 +1,33 @@ +package pgdelta + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The declarative-apply script connects to TARGET and must force the worker's +// event loop closed once it has written its result JSON. applyDeclarativeSchema +// can leave connection keepalive handles registered, and if the worker never +// exits the container never stops — the CLI, which follows the container logs +// with Follow:true, then hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +// The success path must terminate unconditionally, so guard against the +// force-close being dropped. +func TestDeclarativeApplyScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaDeclarativeApplyScript) + + lines := strings.Split(pgDeltaDeclarativeApplyScript, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +} diff --git a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts index a6589bf2b0..9dfb07cf62 100644 --- a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts @@ -52,3 +52,10 @@ try { } catch (e) { throw e instanceof Error ? e : new Error(String(e)); } +// Force close the event loop on the success path. applyDeclarativeSchema opens a +// connection to TARGET whose keepalive handles can keep the Edge Runtime worker +// alive after the result JSON has been written, so the container never exits and +// the CLI — which follows this container's logs — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this +// only runs once a successful apply has been reported on stdout. +throw new Error(""); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 625967555d..e43e9828a8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -15,19 +15,19 @@ /** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n throw new Error("");\n} finally {\n await close();\n}\n'; + '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n'; + '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n// Force close the event loop on the success path. applyDeclarativeSchema opens a\n// connection to TARGET whose keepalive handles can keep the Edge Runtime worker\n// alive after the result JSON has been written, so the container never exits and\n// the CLI — which follows this container\'s logs — hangs indefinitely at 0% CPU\n// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this\n// only runs once a successful apply has been reported on stdout.\nthrow new Error("");\n'; /** * The npm dist-tag/version used for `@supabase/pg-delta` when From 7908b637a4d41d575eb687fa2ae15cadce589123 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:09:11 +0000 Subject: [PATCH 2/3] fix(cli): force-close worker in pgcache catalog export too The migrations-catalog cache script (pgcache.TryCacheMigrationsCatalog, used by db start / db push with pg-delta caching) uses the same createManagedPool/extractCatalog/close() pattern as the other pg-delta Edge Runtime scripts and had the same missing success-path force-close, so it could hang the same way (supabase/pg-toolbelt#312). Add the force-close and a guard test. Flagged by automated PR review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbxecW4DVmwgQB1YX321K3 --- apps/cli-go/internal/db/pgcache/cache.go | 9 +++++ .../db/pgcache/cache_template_test.go | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 apps/cli-go/internal/db/pgcache/cache_template_test.go diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index 5b518464ba..52960c9b06 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -49,10 +49,19 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the migrations-catalog cache +// path (db start / db push with pg-delta caching) indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). +throw new Error(""); ` ) diff --git a/apps/cli-go/internal/db/pgcache/cache_template_test.go b/apps/cli-go/internal/db/pgcache/cache_template_test.go new file mode 100644 index 0000000000..1118853597 --- /dev/null +++ b/apps/cli-go/internal/db/pgcache/cache_template_test.go @@ -0,0 +1,33 @@ +package pgcache + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The migrations-catalog cache script (db start / db push with pg-delta caching) +// opens a connection pool and must force the worker's event loop closed once it +// has written its snapshot. If a keepalive handle lingers after close() resolves +// the worker never exits, so the container never stops and the CLI — which +// follows the container logs with Follow:true — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). Guard against the success-path force-close being +// dropped. +func TestPgDeltaCatalogExportScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaCatalogExportTS) + + lines := strings.Split(pgDeltaCatalogExportTS, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +} From 87edeed8977bda8a64a2248edb6c543b08169507 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 10:00:27 +0000 Subject: [PATCH 3/3] fix(cli): wait for edge-runtime container exit instead of following its logs The worker force-close made the edge-runtime container exit, but declarative sync still hung under podman. A user goroutine dump on the hung __catalog subprocess showed the block is in stdcopy.StdCopy reading the Follow:true Docker log stream (DockerStreamLogs): the stream never receives EOF after the container exits, so the read blocks forever at 0% CPU (supabase/pg-toolbelt#312). podman's /containers//logs?follow endpoint does not close when the container stops, unlike Docker. Run the bounded edge-runtime scripts via DockerRunOnceWaitWithConfig, which detects completion by polling ContainerInspect (reliable on podman) and then reads the buffered logs without following. The shared DockerStreamLogs (used by functions serve for genuine live streaming) and DockerRunOnceWithConfig (used by db dump for large streaming output) are left unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbxecW4DVmwgQB1YX321K3 --- apps/cli-go/internal/db/diff/diff_test.go | 10 +++ apps/cli-go/internal/utils/docker.go | 68 +++++++++++++++++++ .../cli-go/internal/utils/docker_wait_test.go | 66 ++++++++++++++++++ apps/cli-go/internal/utils/edgeruntime.go | 9 ++- 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 apps/cli-go/internal/utils/docker_wait_test.go diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index 2a6a2d4ca4..ca52d9e09b 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -345,6 +345,16 @@ create schema public`) Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). Reply(http.StatusOK) apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), "test-migra") + // The edge-runtime diff waits for the container to exit via inspect before + // reading its logs (it must not follow the log stream — that hangs under + // podman, supabase/pg-toolbelt#312), so the diff failure here surfaces from + // the log read rather than the followed stream. + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/json"). + Reply(http.StatusOK). + JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{ExitCode: 0}, + }}) gock.New(utils.Docker.DaemonHost()). Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/logs"). ReplyError(errors.New("network error")) diff --git a/apps/cli-go/internal/utils/docker.go b/apps/cli-go/internal/utils/docker.go index eff7854a4f..29d2edd397 100644 --- a/apps/cli-go/internal/utils/docker.go +++ b/apps/cli-go/internal/utils/docker.go @@ -486,6 +486,74 @@ func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostC return DockerStreamLogs(ctx, container, stdout, stderr) } +// DockerRunOnceWaitWithConfig is like DockerRunOnceWithConfig but waits for the +// container to exit and then reads its already-buffered logs WITHOUT following +// the stream. +// +// DockerRunOnceWithConfig detects completion by reading a follow=true log stream +// until EOF. That EOF never arrives under podman: its /containers//logs?follow +// endpoint does not close the response when the container stops, so +// stdcopy.StdCopy blocks on the chunked HTTP body forever and the caller hangs at +// 0% CPU (supabase/pg-toolbelt#312). Waiting on /wait for the exit code and then +// reading the log once is reliable on both Docker and podman. +// +// Use this only for short-lived containers with bounded output (e.g. the +// edge-runtime pg-delta scripts): the full log is read after exit rather than +// streamed, so it is not suitable for large streaming output such as db dump. +func DockerRunOnceWaitWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { + containerId, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) + if err != nil { + return err + } + defer DockerRemove(containerId) + exitCode, err := dockerWaitExit(ctx, containerId) + if err != nil { + return err + } + if err := DockerStreamLogsOnce(ctx, containerId, stdout, stderr); err != nil { + return err + } + switch exitCode { + case 0: + return nil + case 137: + err = ErrContainerKilled + default: + err = errors.Errorf("exit %d", exitCode) + } + return errors.Errorf("error running container: %w", err) +} + +// dockerWaitInterval is how often dockerWaitExit polls container state. Kept +// short so bounded one-shot scripts return promptly; it is a package var so +// tests can drop it to zero. +var dockerWaitInterval = 200 * time.Millisecond + +// dockerWaitExit polls container state until it stops and returns its exit code. +// +// It deliberately uses ContainerInspect rather than a followed log stream (or +// /wait) to detect completion: inspect is reliable under podman, whereas +// podman's /logs?follow endpoint does not close when the container stops, which +// is what hangs DockerStreamLogs (see DockerRunOnceWaitWithConfig). Reusing +// inspect also keeps the request surface identical to DockerStreamLogs, so the +// existing test mocks continue to apply. +func dockerWaitExit(ctx context.Context, containerId string) (int64, error) { + for { + resp, err := Docker.ContainerInspect(ctx, containerId) + if err != nil { + return 0, errors.Errorf("failed to inspect docker container: %w", err) + } + if resp.State != nil && !resp.State.Running { + return int64(resp.State.ExitCode), nil + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(dockerWaitInterval): + } + } +} + var ErrContainerKilled = errors.New("exit 137") func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { diff --git a/apps/cli-go/internal/utils/docker_wait_test.go b/apps/cli-go/internal/utils/docker_wait_test.go new file mode 100644 index 0000000000..22ee32e0ee --- /dev/null +++ b/apps/cli-go/internal/utils/docker_wait_test.go @@ -0,0 +1,66 @@ +package utils + +import ( + "bytes" + "context" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" +) + +// DockerRunOnceWaitWithConfig must detect container completion via inspect and +// read logs without following the stream. Following the log stream to detect +// exit hangs forever under podman, whose /logs?follow endpoint never closes when +// the container stops (supabase/pg-toolbelt#312). These tests pin that the runner +// reads the captured output and maps the inspected exit code to an error. +func TestDockerRunOnceWait(t *testing.T) { + imageUrl := GetRegistryImageUrl(imageId) + + t.Run("waits for exit then reads buffered logs", func(t *testing.T) { + require.NoError(t, apitest.MockDocker(Docker)) + defer gock.OffAll() + apitest.MockDockerStart(Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerLogs(Docker, containerId, "CATALOG\n")) + // Run test + var stdout, stderr bytes.Buffer + err := DockerRunOnceWaitWithConfig( + context.Background(), + container.Config{Image: imageUrl}, + container.HostConfig{}, + network.NetworkingConfig{}, + containerId, + &stdout, + &stderr, + ) + // Validate + assert.NoError(t, err) + assert.Equal(t, "CATALOG\n", stdout.String()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("maps non-zero exit code to error", func(t *testing.T) { + require.NoError(t, apitest.MockDocker(Docker)) + defer gock.OffAll() + apitest.MockDockerStart(Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerLogsExitCode(Docker, containerId, 1)) + // Run test + var stdout, stderr bytes.Buffer + err := DockerRunOnceWaitWithConfig( + context.Background(), + container.Config{Image: imageUrl}, + container.HostConfig{}, + network.NetworkingConfig{}, + containerId, + &stdout, + &stderr, + ) + // Validate + assert.ErrorContains(t, err, "error running container: exit 1") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index f5ee9e9bbb..c81169ea6f 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -94,7 +94,14 @@ func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, bind if len(state.extraEnv) > 0 { combinedEnv = append(append([]string{}, env...), state.extraEnv...) } - if err := DockerRunOnceWithConfig( + // Wait for the container to exit and then read its logs, rather than + // following the log stream to detect completion. The edge-runtime worker is + // forced to exit once the script flushes its output, but podman's + // /logs?follow endpoint does not close when the container stops, so a + // followed read (DockerRunOnceWithConfig) hangs the CLI forever + // (supabase/pg-toolbelt#312). pg-delta script output is bounded, so reading + // the log once after exit is safe. + if err := DockerRunOnceWaitWithConfig( ctx, container.Config{ Image: Config.EdgeRuntime.Image,