-
Notifications
You must be signed in to change notification settings - Fork 523
fix(cli): report a killed edge runtime container instead of a lost log stream #6615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,17 +61,19 @@ back to local keys. No scheme/host validation is performed on the discovered URL | |
|
|
||
| ## Exit Codes | ||
|
|
||
| | Code | Condition | | ||
| | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `0` | clean shutdown after `SIGINT` or `SIGTERM` | | ||
| | `0` | the edge-runtime container stops on its own with exit code `0` | | ||
| | `0` | the edge-runtime container is torn down by an external supervisor — exit `129`/`130`/`131`/`143` (`SIGHUP`/`SIGINT`/`SIGQUIT`/`SIGTERM`), e.g. `supabase stop` run in another terminal | | ||
| | `0` | the edge-runtime container is already gone by the time a follow-up `docker container inspect` runs after the log stream ended | | ||
| | `0` | an edge-runtime startup failure or log-stream failure lands within the shutdown grace period (~50ms) of a `SIGINT`/`SIGTERM` | | ||
| | `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | | ||
| | `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | | ||
| | `1` | env file, signing key, import map, or function bind resolution failure | | ||
| | `1` | edge-runtime container startup, log streaming, or restart loop failure — including the edge-runtime container crashing with any exit code other than `0`, `137`, or `129`/`130`/`131`/`143` | | ||
| | Code | Condition | | ||
| | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `0` | clean shutdown after `SIGINT` or `SIGTERM` | | ||
| | `0` | the edge-runtime container stops on its own with exit code `0` | | ||
| | `0` | the edge-runtime container is torn down by an external supervisor — exit `129`/`130`/`131`/`143` (`SIGHUP`/`SIGINT`/`SIGQUIT`/`SIGTERM`), e.g. `supabase stop` run in another terminal | | ||
| | `0` | the edge-runtime container is already gone by the time a follow-up `docker container inspect` runs after the log stream ended | | ||
| | `0` | an edge-runtime startup failure or log-stream failure lands within the shutdown grace period (~50ms) of a `SIGINT`/`SIGTERM` | | ||
| | `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | | ||
| | `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | | ||
| | `1` | env file, signing key, import map, or function bind resolution failure | | ||
| | `1` | edge-runtime container startup, log streaming, or restart loop failure — including the edge-runtime container crashing with any exit code other than `0` or `129`/`130`/`131`/`143` | | ||
| | `1` | the edge-runtime container is killed for exceeding its memory limit — exit `137` with `State.OOMKilled` | | ||
| | `1` | the edge-runtime container is killed from outside the CLI — exit `137` without `State.OOMKilled` | | ||
|
Comment on lines
+75
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ NIT · The exit-code documentation incorrectly restricts the memory-limit classification to exit 137 even though the implementation classifies any Evidence: apps/cli/src/shared/functions/serve.errors.ts:29-35 checks Suggested fix: Describe |
||
|
|
||
| ## Telemetry Events Fired | ||
|
|
||
|
|
@@ -121,7 +123,7 @@ Long-running raw log / error events only; there is no terminal `result` event on | |
| - Config, project dotenv discovery, and function discovery all resolve from `<workdir>` with no ancestor search (CLI-2285), so they can never disagree. | ||
| - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect <candidate>` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull <candidate>` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. | ||
| - Runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). | ||
| - A container that stops on its own with exit code `0`, or that is torn down by an external supervisor (exit `129`/`130`/`131`/`143`, e.g. `supabase stop` in another terminal), or that is already gone by the time a follow-up inspect runs, all end the command successfully — each prints its own distinct line (see Output above) rather than the user-initiated `Stopped serving …` line, so scrollback can tell "I stopped it" from "the runtime walked out" or "a supervisor tore it down". In a `functions serve &` CI step this means a runtime that exits on its own does not fail the step; the distinct message is the only signal, and a downstream failure otherwise only surfaces later as connection-refused. Exit `137` (SIGKILL, e.g. an OOM kill) is retried by re-attaching to the log stream rather than failing the command. Any other non-zero container exit fails the command; the error message includes the container id. Only a watched-file change restarts the container itself — none of these outcomes ever restart it. | ||
| - A container that stops on its own with exit code `0`, or that is torn down by an external supervisor (exit `129`/`130`/`131`/`143`, e.g. `supabase stop` in another terminal), or that is already gone by the time a follow-up inspect runs, all end the command successfully — each prints its own distinct line (see Output above) rather than the user-initiated `Stopped serving …` line, so scrollback can tell "I stopped it" from "the runtime walked out" or "a supervisor tore it down". In a `functions serve &` CI step this means a runtime that exits on its own does not fail the step; the distinct message is the only signal, and a downstream failure otherwise only surfaces later as connection-refused. Exit `137` (SIGKILL) fails the command: `State.OOMKilled` separates a container that hit its memory limit, which reports the memory-allocation remediation, from one killed by something the CLI cannot identify. Any other non-zero container exit fails the command; the error message includes the container id. Only a watched-file change restarts the container itself — none of these outcomes ever restart it. | ||
| - A `docker logs -f` re-attach (the daemon can close the stream while the container keeps running) resumes with `--since <last forwarded log timestamp>` instead of replaying the full log history, and is capped at 5 consecutive re-attaches that forward no new output; exceeding the cap fails the command with a tagged error instead of looping forever. | ||
| - On the log-stream path, the Docker-daemon-unreachable classification comes from a follow-up `docker container inspect` failure, not from `docker logs -f`'s own stderr text. | ||
| - The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ import { | |
| } from "../../../../tests/helpers/mocks.ts"; | ||
| import { CommandSettings } from "../../../config/command-settings.service.ts"; | ||
| import { functionsGoConfigCompat } from "../../../command-internal/functions-go-config.ts"; | ||
| import { SUGGEST_CONTAINER_MEMORY_LIMIT } from "../../../command-internal/docker-suggest.ts"; | ||
| import { DebugFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts"; | ||
| import { FileWatcher, type FileWatchEvent } from "../../../shared/runtime/file-watcher.service.ts"; | ||
| import { | ||
|
|
@@ -2119,13 +2120,18 @@ describe("functions serve integration", () => { | |
| } | ||
|
|
||
| // Models `inspectContainerState`'s `docker container inspect --format {{json .State}}` reply. | ||
| function inspectStateBehavior(running: boolean, exitCode = 0): LogProcessBehavior { | ||
| function inspectStateBehavior( | ||
| running: boolean, | ||
| exitCode = 0, | ||
| oomKilled = false, | ||
| ): LogProcessBehavior { | ||
| return { | ||
| exitCode: 0, | ||
| stdout: JSON.stringify({ | ||
| Status: running ? "running" : "exited", | ||
| Running: running, | ||
| ExitCode: exitCode, | ||
| OOMKilled: oomKilled, | ||
| }), | ||
| stderr: "", | ||
| }; | ||
|
|
@@ -2344,6 +2350,79 @@ describe("functions serve integration", () => { | |
| }, | ||
| ); | ||
|
|
||
| it.live( | ||
| "fails as an out-of-memory kill, without retrying, when the container is OOM-killed (exit 137)", | ||
| () => { | ||
| deployMockState.runHandler = baseDockerRunHandler(); | ||
| const childSpawner = mockDockerLogSpawner([ | ||
| { exitCode: 0 }, | ||
| inspectStateBehavior(false, 137, true), | ||
| ]); | ||
|
|
||
| return Effect.gen(function* () { | ||
| yield* Effect.promise(writeHelloFunction); | ||
|
|
||
| const { layer } = setupServe({ childSpawner }); | ||
| // A container killed for exceeding its memory limit never comes back, so a | ||
| // regression to re-attaching burns the whole cap before failing; bound the | ||
| // wait so that shows up as a timeout rather than a slow pass. | ||
| const error = yield* functionsServe(baseFlags()).pipe( | ||
| Effect.provide(layer), | ||
| Effect.timeout(Duration.seconds(5)), | ||
| Effect.flip, | ||
| ); | ||
|
Comment on lines
+2366
to
+2373
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ NIT · The five-second timeout does not provide the stated retry-regression detection and unnecessarily includes potentially slow command bring-up. Evidence: apps/cli/src/shared/functions/serve.ts:131 and 145-147 define five 400ms retry sleeps before Suggested fix: Remove the timeout, or correct its rationale and give cold command bring-up sufficient headroom. |
||
|
|
||
| expect(error).toBeInstanceOf(EdgeRuntimeContainerCrashedError); | ||
| if (error instanceof EdgeRuntimeContainerCrashedError) { | ||
| expect(error.exitCode).toBe(137); | ||
| expect(error.oomKilled).toBe(true); | ||
| expect(error.suggestion).toBe(SUGGEST_CONTAINER_MEMORY_LIMIT); | ||
| expect(error[ErrorActionabilityId]).toEqual({ | ||
| ...actionability.resourceLimit, | ||
| fingerprint_suffix: "out_of_memory", | ||
| }); | ||
| } | ||
| expect(containerInspectCalls(childSpawner)).toHaveLength(1); | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| it.live( | ||
| "fails as unattributable, without retrying, when the container is killed from outside the CLI (exit 137)", | ||
| () => { | ||
| deployMockState.runHandler = baseDockerRunHandler(); | ||
| const childSpawner = mockDockerLogSpawner([ | ||
| { exitCode: 0 }, | ||
| inspectStateBehavior(false, 137, false), | ||
| ]); | ||
|
|
||
| return Effect.gen(function* () { | ||
| yield* Effect.promise(writeHelloFunction); | ||
|
|
||
| const { layer } = setupServe({ childSpawner }); | ||
| const error = yield* functionsServe(baseFlags()).pipe( | ||
| Effect.provide(layer), | ||
| Effect.timeout(Duration.seconds(5)), | ||
| Effect.flip, | ||
| ); | ||
|
|
||
| expect(error).toBeInstanceOf(EdgeRuntimeContainerCrashedError); | ||
| if (error instanceof EdgeRuntimeContainerCrashedError) { | ||
| expect(error.exitCode).toBe(137); | ||
| expect(error.oomKilled).toBe(false); | ||
| expect(error.suggestion).toBeUndefined(); | ||
| const declaration = error[ErrorActionabilityId]; | ||
| expect(declaration).toEqual({ | ||
| ...actionability.unknown, | ||
| fingerprint_suffix: "container_killed", | ||
| }); | ||
| expect(declaration.error_kind).not.toBe("internal_bug"); | ||
| } | ||
| expect(containerInspectCalls(childSpawner)).toHaveLength(1); | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| it.live( | ||
| "ends the session normally, with a distinct message, when the container exits gracefully (exit 0)", | ||
| () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MINOR ·
correctness· source: codexThe remediation overstates
OOMKilledas proof that the container exceeded its own configured memory limit.Evidence: apps/cli/src/command-internal/docker-suggest.ts:13 attributes the kill to "its memory limit," and apps/cli/src/command-internal/docker-lifecycle.ts:269-271 derives that conclusion from only the boolean
State.OOMKilled, without inspecting any configured limit or broader runtime memory pressure.Suggested fix: Describe the event as an OOM-killer termination and advise checking both container limits and available host/runtime memory.