From e62aae7df964c3e236fda1978d3773a5c3ee27f2 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 14 Aug 2026 20:03:28 +0200 Subject: [PATCH 1/3] fix(cli): exec start wrappers and bump postgres-meta so docker stop is not 10s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack `supabase stop --no-backup` is gated by a 10s Docker SIGTERM grace period: five long-running containers keep `sh` (or a hung Node shutdown) as PID 1, so SIGTERM never reaches the real process and every `docker stop` burns the full timeout before SIGKILL. Stops run in parallel, so wall time = slowest container. Workstream A — `exec` the real process in every CLI-authored wrapper, the same deliberate divergence from Go's scripts as the Postgres entrypoint (timing is outside the Go-parity surface, ADR 0016): - Kong: `exec ./docker-entrypoint.sh kong docker-start …` - Vector: `exec vector --config …` (the Logflare wget wait-loop still runs in the wrapper shell before the exec) - Logflare: `exec sh run.sh` outer + `exec ./logflare start` inside run.sh (the migrate && start sequencing is unchanged — #6088) - Edge runtime: `exec edge-runtime start …` in the long-lived serve/ start container, and in the ephemeral script-runner for cancellation latency Workstream B — postgres-meta v0.98.0 (released today, contains postgres-meta#1103, which fixes the circular onClose hang that made v0.97.0 ride the close-with-grace 10s timeout; the canary measured ~230-320ms). Dockerfile pin bumped + versions synced. Command paths, stdout/stderr text, exit codes, telemetry, and documented side effects are unchanged; no `-t`, no `docker rm -f`, no grace-period env. Co-Authored-By: Claude Fable 5 --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- .../functions/serve/serve.integration.test.ts | 4 ++-- .../commands/start/services/kong.service.ts | 6 +++++- .../start/services/kong.service.unit.test.ts | 2 +- .../commands/start/services/logflare.service.ts | 8 +++++++- .../services/logflare.service.unit.test.ts | 2 +- .../commands/start/services/vector.service.ts | 6 +++++- .../start/services/vector.service.unit.test.ts | 2 +- .../legacy-edge-runtime-script.service.ts | 9 +++++++-- .../legacy-edge-runtime-script.unit.test.ts | 17 +++++++++++------ apps/cli/src/shared/functions/serve.ts | 7 ++++++- .../cli/src/shared/functions/serve.unit.test.ts | 5 +++-- packages/stack/src/ServiceCatalog.ts | 12 ++++++------ 13 files changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index ac091812af..a4a9fab6c7 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -4,7 +4,7 @@ FROM supabase/postgres:17.6.1.158 AS pg FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit FROM postgrest/postgrest:v16.1 AS postgrest -FROM supabase/postgres-meta:v0.97.0 AS pgmeta +FROM supabase/postgres-meta:v0.98.0 AS pgmeta FROM supabase/studio:2026.08.10-sha-5b68af1 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index d0bcc47a72..274407d463 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -728,7 +728,7 @@ describe("legacy functions serve integration", () => { toDockerPath(tempRoot.current), ]); expect(dockerRun.args[dockerRun.args.length - 1]).toBe( - "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", + "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", ); const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); @@ -1728,7 +1728,7 @@ describe("legacy functions serve integration", () => { const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; expect(commandScript).toBe( - "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", + "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", ); expect( extractFlagValues(dockerRun.args, "-v").some((value) => diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.ts b/apps/cli/src/legacy/commands/start/services/kong.service.ts index ec72cd667c..fe02d7d869 100644 --- a/apps/cli/src/legacy/commands/start/services/kong.service.ts +++ b/apps/cli/src/legacy/commands/start/services/kong.service.ts @@ -172,9 +172,13 @@ export function legacyBuildKongEmailTemplateBind( return `${hostPath}:${dockerPath}:rw`; } +// `exec` so Kong (not `sh`) is PID 1 and `docker stop`'s SIGTERM reaches it directly — without +// it every stop burns the full 10s grace period before SIGKILL. Same deliberate divergence from +// Go's script as the Postgres entrypoint (`db-bootstrap/postgres.service.ts`); stop timing is +// outside the Go-parity surface (ADR 0016). const LEGACY_KONG_ENTRYPOINT_HEAD = "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + - "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n"; + "exec ./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n"; /** * Builds the surviving (non-secret) half of the Kong entrypoint: only the diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts index 61f219e2b0..1e80c2cee5 100644 --- a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts @@ -117,7 +117,7 @@ describe("legacyBuildKongEntrypointScript", () => { const script = legacyBuildKongEntrypointScript("NGINX_TEMPLATE"); expect(script).toBe( "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + - "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n" + + "exec ./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n" + "NGINX_TEMPLATE\nEOF\n", ); }); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index 229ccef273..fe0fab4888 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -51,8 +51,14 @@ const LEGACY_LOGFLARE_API_KEY = "api-key"; * running Logflare against an unmigrated database lets Oban die on the * missing `public.oban_jobs` table instead. */ +// Both `exec`s are load-bearing for stop time (SIGTERM must reach Logflare's BEAM as PID 1, not +// an intermediate `sh` — without them every `docker stop` burns the full 10s grace period): +// `exec sh run.sh` replaces the outer wrapper shell, and `exec ./logflare start` replaces +// `run.sh`'s own shell once the migrate step succeeded. The `migrate && start` sequencing is +// unchanged and deliberate — see the doc comment above (#6088). Stop timing is outside the +// Go-parity surface (ADR 0016). const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = - "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate &&\n./logflare start --sname logflare\nEOF\n"; + "cat <<'EOF' > run.sh && exec sh run.sh\n./logflare eval Logflare.Release.migrate &&\nexec ./logflare start --sname logflare\nEOF\n"; export interface LegacyLogflareContainerSpecInput { /** diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts index 7a9cf7aa93..3e36e9c560 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -32,7 +32,7 @@ describe("legacyBuildLogflareContainerSpec", () => { expect(spec.entrypoint).toBe("sh"); expect(spec.cmd).toEqual([ "-c", - "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate &&\n./logflare start --sname logflare\nEOF\n", + "cat <<'EOF' > run.sh && exec sh run.sh\n./logflare eval Logflare.Release.migrate &&\nexec ./logflare start --sname logflare\nEOF\n", ]); expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); expect(spec.ports).toEqual([{ hostPort: "54327", containerPort: "4000" }]); diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index 496f740566..307a72dc8e 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -290,7 +290,11 @@ export function legacyBuildVectorEntrypointScript(vectorYaml: string, logflareId vectorYaml + "\nEOF\nuntil wget --no-verbose --tries=1 --spider http://" + logflareId + - ":4000/health 2>/dev/null; do sleep 2; done\nvector --config /etc/vector/vector.yaml\n" + // `exec` so Vector (not `sh`) is PID 1 and `docker stop`'s SIGTERM reaches it directly — + // without it every stop burns the full 10s grace period. The wget wait-loop above still runs + // in the wrapper shell BEFORE the exec, unchanged. Stop timing is outside the Go-parity + // surface (ADR 0016). + ":4000/health 2>/dev/null; do sleep 2; done\nexec vector --config /etc/vector/vector.yaml\n" ); } diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts index 69429ea9dd..7fe06b5f61 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts @@ -227,7 +227,7 @@ describe("legacyBuildVectorEntrypointScript", () => { "VECTOR_YAML" + "\nEOF\nuntil wget --no-verbose --tries=1 --spider http://" + "supabase_analytics_proj" + - ":4000/health 2>/dev/null; do sleep 2; done\nvector --config /etc/vector/vector.yaml\n", + ":4000/health 2>/dev/null; do sleep 2; done\nexec vector --config /etc/vector/vector.yaml\n", ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts index 1c6531d6af..edf993124f 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts @@ -104,7 +104,12 @@ export function legacyBuildEdgeRuntimeEntrypoint( files: ReadonlyArray, cmd: string, ): string { - if (files.length === 0) return `${cmd}\n`; + // `exec` diverges from Go's byte-for-byte script on purpose: edge-runtime (not `sh`) becomes + // PID 1, so an early `docker stop`/`rm -f` SIGTERM reaches it directly instead of burning the + // 10s grace period. These containers are `--rm` and normally exit on their own, so this is a + // cancellation-latency nicety, not a stop-path requirement. Timing is outside the Go-parity + // surface (ADR 0016). + if (files.length === 0) return `exec ${cmd}\n`; let head = ""; let bodies = ""; files.forEach((file, index) => { @@ -112,5 +117,5 @@ export function legacyBuildEdgeRuntimeEntrypoint( head += `cat <<'${sentinel}' > ${file.name} && `; bodies += `${file.content}\n${sentinel}\n`; }); - return `${head}${cmd}\n${bodies}`; + return `${head}exec ${cmd}\n${bodies}`; } diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.unit.test.ts index e8d668a0ad..d20590f859 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.unit.test.ts @@ -37,7 +37,9 @@ describe("legacyBuildEdgeRuntimeStartCmd", () => { describe("legacyBuildEdgeRuntimeEntrypoint", () => { it("returns just the command (newline-terminated) when there are no files", () => { - expect(legacyBuildEdgeRuntimeEntrypoint([], "edge-runtime start")).toBe("edge-runtime start\n"); + expect(legacyBuildEdgeRuntimeEntrypoint([], "edge-runtime start")).toBe( + "exec edge-runtime start\n", + ); }); it("writes a single file via a sentinel here-document then runs the command", () => { @@ -45,10 +47,11 @@ describe("legacyBuildEdgeRuntimeEntrypoint", () => { [{ name: "index.ts", content: "console.log(1);" }], "edge-runtime start --main-service=. --port=5", ); - // Byte-for-byte port of Go's buildEdgeRuntimeEntrypoint: openers (joined with - // ` && `) precede the command, then the bodies with their sentinels. + // Port of Go's buildEdgeRuntimeEntrypoint — openers (joined with ` && `) precede the + // command, then the bodies with their sentinels — plus a deliberate `exec` divergence so + // edge-runtime is PID 1 (ADR 0016: timing is outside the parity surface). expect(out).toBe( - "cat <<'__EDGE_RT_FILE_0__' > index.ts && edge-runtime start --main-service=. --port=5\n" + + "cat <<'__EDGE_RT_FILE_0__' > index.ts && exec edge-runtime start --main-service=. --port=5\n" + "console.log(1);\n__EDGE_RT_FILE_0__\n", ); }); @@ -62,13 +65,15 @@ describe("legacyBuildEdgeRuntimeEntrypoint", () => { "CMD", ); expect(out).toBe( - "cat <<'__EDGE_RT_FILE_0__' > index.ts && cat <<'__EDGE_RT_FILE_1__' > .npmrc && CMD\n" + + "cat <<'__EDGE_RT_FILE_0__' > index.ts && cat <<'__EDGE_RT_FILE_1__' > .npmrc && exec CMD\n" + "A\n__EDGE_RT_FILE_0__\nB\n__EDGE_RT_FILE_1__\n", ); }); it("preserves file contents that themselves contain EOF-like text", () => { const out = legacyBuildEdgeRuntimeEntrypoint([{ name: "index.ts", content: "EOF\nmore" }], "C"); - expect(out).toBe("cat <<'__EDGE_RT_FILE_0__' > index.ts && C\nEOF\nmore\n__EDGE_RT_FILE_0__\n"); + expect(out).toBe( + "cat <<'__EDGE_RT_FILE_0__' > index.ts && exec C\nEOF\nmore\n__EDGE_RT_FILE_0__\n", + ); }); }); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 8d15ec157b..91c0238687 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1441,7 +1441,12 @@ export function buildServeEntrypointCommand( command: ReadonlyArray, multilineEnvScriptPath?: string, ) { - return `${multilineEnvScriptPath === undefined ? "" : `. ${multilineEnvScriptPath}\n`}${command.join(" ")} + // `exec` so edge-runtime (not `sh`) is PID 1 and `docker stop`'s SIGTERM reaches it directly — + // without it every stop burns the full 10s grace period before SIGKILL. The optional + // multiline-env `.` sourcing above still runs in the wrapper shell before the exec, and its + // exported env survives into the exec'd process. Stop timing is outside the Go-parity surface + // (ADR 0016). + return `${multilineEnvScriptPath === undefined ? "" : `. ${multilineEnvScriptPath}\n`}exec ${command.join(" ")} `; } diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts index 5194857742..5ae4883753 100644 --- a/apps/cli/src/shared/functions/serve.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.unit.test.ts @@ -7,13 +7,14 @@ import { buildServeEntrypointCommand } from "./serve.ts"; describe("buildServeEntrypointCommand", () => { it("returns the runtime command without embedding the template body", () => { const script = buildServeEntrypointCommand(["edge-runtime", "start"]); - expect(script).toBe("edge-runtime start\n"); + // `exec` so edge-runtime is PID 1 — docker stop must not burn the 10s SIGTERM grace period. + expect(script).toBe("exec edge-runtime start\n"); expect(script).not.toContain("Deno.serve"); }); it("sources the multiline env script before the runtime command when provided", () => { const script = buildServeEntrypointCommand(["edge-runtime", "start"], "/root/env.sh"); - expect(script).toContain(". /root/env.sh\nedge-runtime start"); + expect(script).toContain(". /root/env.sh\nexec edge-runtime start"); }); it("keeps the spawned command short even with the real bundled template", async () => { diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index aa004a98cf..9508225f28 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -131,7 +131,7 @@ export const SERVICE_CATALOG = { postgrest: { name: "postgrest", configKey: "postgrest", - defaultVersion: "14.16", + defaultVersion: "16.1", runtimeSupport: "native-preferred", artifact: { docker: { ownership: "supabase", repository: "postgrest", tagPrefix: "v" }, @@ -201,7 +201,7 @@ export const SERVICE_CATALOG = { realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "2.123.1", + defaultVersion: "2.124.4", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } }, activation: { startup: "eager", activates: [], owns: [] }, @@ -210,7 +210,7 @@ export const SERVICE_CATALOG = { storage: { name: "storage", configKey: "storage", - defaultVersion: "1.68.1", + defaultVersion: "1.69.0", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } }, activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, @@ -237,7 +237,7 @@ export const SERVICE_CATALOG = { pgmeta: { name: "pgmeta", configKey: "pgmeta", - defaultVersion: "0.96.6", + defaultVersion: "0.98.0", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "postgres-meta", tagPrefix: "v" }, @@ -248,7 +248,7 @@ export const SERVICE_CATALOG = { studio: { name: "studio", configKey: "studio", - defaultVersion: "2026.08.03-sha-022b374", + defaultVersion: "2026.08.10-sha-5b68af1", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "studio" } }, activation: { startup: "eager", activates: ["analytics"], owns: [] }, @@ -257,7 +257,7 @@ export const SERVICE_CATALOG = { analytics: { name: "analytics", configKey: "analytics", - defaultVersion: "1.49.2", + defaultVersion: "1.50.2", runtimeSupport: "docker-only", artifact: { docker: { ownership: "supabase", repository: "logflare" } }, activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, From 8f03e4c51e563cc5a7bc819cd250261fd96a2efd Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 14 Aug 2026 20:11:01 +0200 Subject: [PATCH 2/3] docs(cli): record the measured logflare SIGTERM hang next to its exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a real stack: with beam.smp as PID 1 (both execs in place), docker stop -t 10 on the logflare container still burns the full grace period (~10.5s) — Logflare's own SIGTERM shutdown hangs upstream, the same class of problem postgres-meta#1103 fixed for pg-meta. The execs stay (necessary, one less layer, load-bearing once upstream fixes its handler); the comment no longer claims they alone fix stop time. Co-Authored-By: Claude Fable 5 --- .../commands/start/services/logflare.service.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index fe0fab4888..0191e708e8 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -51,12 +51,15 @@ const LEGACY_LOGFLARE_API_KEY = "api-key"; * running Logflare against an unmigrated database lets Oban die on the * missing `public.oban_jobs` table instead. */ -// Both `exec`s are load-bearing for stop time (SIGTERM must reach Logflare's BEAM as PID 1, not -// an intermediate `sh` — without them every `docker stop` burns the full 10s grace period): -// `exec sh run.sh` replaces the outer wrapper shell, and `exec ./logflare start` replaces -// `run.sh`'s own shell once the migrate step succeeded. The `migrate && start` sequencing is -// unchanged and deliberate — see the doc comment above (#6088). Stop timing is outside the -// Go-parity surface (ADR 0016). +// Both `exec`s route `docker stop`'s SIGTERM to Logflare's BEAM as PID 1 (`exec sh run.sh` +// replaces the outer wrapper shell; `exec ./logflare start` replaces `run.sh`'s own shell once +// the migrate step succeeded) — necessary but, measured, NOT yet sufficient: with beam.smp as +// PID 1 a `docker stop -t 10` still burns the full grace period (~10.5s), so Logflare's own +// SIGTERM shutdown hangs upstream, the same class of problem postgres-meta had before +// postgres-meta#1103. Keep the execs (one less layer between the signal and the BEAM, and they +// become load-bearing the moment upstream fixes its handler); the remaining ~10s needs an +// upstream Logflare image fix. The `migrate && start` sequencing is unchanged and deliberate — +// see the doc comment above (#6088). Stop timing is outside the Go-parity surface (ADR 0016). const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = "cat <<'EOF' > run.sh && exec sh run.sh\n./logflare eval Logflare.Release.migrate &&\nexec ./logflare start --sname logflare\nEOF\n"; From 787493f3bd20d99d206112f00862c14d82af2298 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 14 Aug 2026 20:22:30 +0200 Subject: [PATCH 3/3] fix(cli): exec the postgres entrypoint and trap-supervise logflare so stop finishes in seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the fast-stop work: - The three postgres entrypoint variants exec docker-entrypoint.sh, so Postgres is PID 1 and docker stop's SIGTERM triggers its own fast shutdown (~4s) instead of burning the 10s grace period. Same change #6184 carries for the shadow's benefit; included here so stop is fast without waiting on that PR. - Logflare gets a trap-and-escalate supervisor instead of a bare exec: measured with beam.smp as PID 1, docker stop STILL burned the full grace period (Logflare's SIGTERM shutdown hangs upstream, the class of problem postgres-meta#1103 fixed for pg-meta). run.sh's shell now stays PID 1 deliberately, forwards SIGTERM to the BEAM, waits up to 3s for a graceful exit, then SIGKILLs — the same end state as today's post-timeout kill, ~7s sooner, and the graceful path wins first if upstream ever fixes its handler. Exit codes are preserved via the interrupted-wait idiom (validated: hung child stops in ~3.4s exit 137; clean child exit code propagates). Co-Authored-By: Claude Fable 5 --- .../start/services/logflare.service.ts | 37 ++++++++++++++----- .../services/logflare.service.unit.test.ts | 11 +++++- .../shared/db-bootstrap/postgres.service.ts | 9 +++-- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index 0191e708e8..108e8fddb2 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -51,17 +51,34 @@ const LEGACY_LOGFLARE_API_KEY = "api-key"; * running Logflare against an unmigrated database lets Oban die on the * missing `public.oban_jobs` table instead. */ -// Both `exec`s route `docker stop`'s SIGTERM to Logflare's BEAM as PID 1 (`exec sh run.sh` -// replaces the outer wrapper shell; `exec ./logflare start` replaces `run.sh`'s own shell once -// the migrate step succeeded) — necessary but, measured, NOT yet sufficient: with beam.smp as -// PID 1 a `docker stop -t 10` still burns the full grace period (~10.5s), so Logflare's own -// SIGTERM shutdown hangs upstream, the same class of problem postgres-meta had before -// postgres-meta#1103. Keep the execs (one less layer between the signal and the BEAM, and they -// become load-bearing the moment upstream fixes its handler); the remaining ~10s needs an -// upstream Logflare image fix. The `migrate && start` sequencing is unchanged and deliberate — -// see the doc comment above (#6088). Stop timing is outside the Go-parity surface (ADR 0016). +// A trap-and-escalate supervisor rather than a bare `exec`: measured with beam.smp as PID 1 +// (a plain `exec` chain), `docker stop -t 10` STILL burned the full grace period — Logflare's +// own SIGTERM shutdown hangs upstream, the same class of problem postgres-meta had before +// postgres-meta#1103. So `run.sh`'s shell stays PID 1 deliberately, with a real handler: +// forward SIGTERM to the BEAM, give its graceful shutdown a 3s window, then SIGKILL it. Today's +// post-timeout outcome is already SIGKILL, so escalating early produces the identical end state +// ~7s sooner (Logflare's durable state lives in Postgres/BigQuery, both crash-safe) — and if +// upstream ever fixes its handler, the graceful window wins first and the KILL never fires. +// The `migrate`-then-`start` sequencing (a failed migrate exits the container so the +// `unless-stopped` policy retries) is unchanged and deliberate — see the doc comment above +// (#6088). Stop timing is outside the Go-parity surface (ADR 0016). +// `run.sh` line by line: migrate (exit on failure — the container-restart retry above), start +// the BEAM in the background, install the TERM handler, then `wait`. A trapped signal interrupts +// `wait` with a >128 status, so the follow-up `wait` collects the BEAM's real exit status once +// the trap's TERM-then-KILL escalation finishes; the `127` guard keeps the first status when the +// BEAM was already reaped (a second `wait` on a reaped pid is an error). A clean BEAM exit takes +// the single-`wait` path untouched. const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = - "cat <<'EOF' > run.sh && exec sh run.sh\n./logflare eval Logflare.Release.migrate &&\nexec ./logflare start --sname logflare\nEOF\n"; + "cat <<'EOF' > run.sh && exec sh run.sh\n" + + "./logflare eval Logflare.Release.migrate || exit $?\n" + + "./logflare start --sname logflare &\n" + + "BEAM_PID=$!\n" + + 'trap \'kill -TERM "$BEAM_PID" 2>/dev/null; n=0; while [ "$n" -lt 3 ] && kill -0 "$BEAM_PID" 2>/dev/null; do n=$((n+1)); sleep 1; done; kill -KILL "$BEAM_PID" 2>/dev/null\' TERM\n' + + 'wait "$BEAM_PID"\n' + + "code=$?\n" + + 'if [ "$code" -gt 128 ]; then wait "$BEAM_PID" 2>/dev/null; code2=$?; [ "$code2" -ne 127 ] && code=$code2; fi\n' + + 'exit "$code"\n' + + "EOF\n"; export interface LegacyLogflareContainerSpecInput { /** diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts index 3e36e9c560..33b5db08cb 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -32,7 +32,16 @@ describe("legacyBuildLogflareContainerSpec", () => { expect(spec.entrypoint).toBe("sh"); expect(spec.cmd).toEqual([ "-c", - "cat <<'EOF' > run.sh && exec sh run.sh\n./logflare eval Logflare.Release.migrate &&\nexec ./logflare start --sname logflare\nEOF\n", + "cat <<'EOF' > run.sh && exec sh run.sh\n" + + "./logflare eval Logflare.Release.migrate || exit $?\n" + + "./logflare start --sname logflare &\n" + + "BEAM_PID=$!\n" + + 'trap \'kill -TERM "$BEAM_PID" 2>/dev/null; n=0; while [ "$n" -lt 3 ] && kill -0 "$BEAM_PID" 2>/dev/null; do n=$((n+1)); sleep 1; done; kill -KILL "$BEAM_PID" 2>/dev/null\' TERM\n' + + 'wait "$BEAM_PID"\n' + + "code=$?\n" + + 'if [ "$code" -gt 128 ]; then wait "$BEAM_PID" 2>/dev/null; code2=$?; [ "$code2" -ne 127 ] && code=$code2; fi\n' + + 'exit "$code"\n' + + "EOF\n", ]); expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); expect(spec.ports).toEqual([{ hostPort: "54327", containerPort: "4000" }]); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 461744bf1f..8a859b38d4 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -261,10 +261,11 @@ function legacyPostgresExtraEnv( * The final command is `exec`'d — a deliberate divergence from Go's script * (which leaves `sh` as PID 1, so SIGTERM is never forwarded and every * `docker stop` burns the full 10s grace period before SIGKILL; with `exec`, - * Postgres is PID 1 and stops in ~1s). Applies to all three entrypoint - * variants below. Timing is not part of the Go-parity surface (ADR 0016); - * see `shadow-cache.ts`'s own doc comment for why fast shutdown matters to the shadow baseline - * cache's cold path. + * Postgres is PID 1 and stops in ~4s on a full local `db`, ~1s on an + * empty shadow). Applies to all three entrypoint variants below. Timing is + * not part of the Go-parity surface (ADR 0016); see `shadow-cache.ts`'s own + * doc comment for why fast shutdown matters to the shadow baseline cache's + * cold path. * * Otherwise byte-for-byte derived from Go's raw-string concatenation — * `NewContainerConfig(args ...string)` splices `strings.Join(args, " ")`