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 4dab2dafd3..5da2648cd3 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 @@ -738,7 +738,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)); @@ -1738,7 +1738,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", ); const cp = deployMockState.runCalls.find( 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..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,8 +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. */ +// 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 && 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 || 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 7a9cf7aa93..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 && 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 || 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/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/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, " ")` 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 0db4d04c47..3242512c95 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1466,7 +1466,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 cd4d3af2d6..a197c6d776 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 () => {