From f75e97633e7bebf9e2ed40423cc3d2fe1be31e3c Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 13:24:31 +0530 Subject: [PATCH 1/6] fix(cli): give functions serve failures typed errors `functions serve` reported its config, env-file, signing-key, env-staging and Docker bring-up failures as a plain `Error`, so they all shared one unclassified fingerprint. Each site now fails with a typed error that carries a closed `reason` set where the error is built: - ServeConfigError, ServeEnvFileError, ServeFilesystemError - EdgeRuntimeDockerStepError (create/cp/start) and DockerResourceCreateError (network/volume), classified from Docker's stderr at construction Messages and exit codes are unchanged. A daemon-unreachable failure during those Docker steps now shows a start-Docker suggestion. Co-Authored-By: Claude Sonnet 5.5 --- .../src/command-internal/docker-suggest.ts | 4 + .../commands/functions/serve/SIDE_EFFECTS.md | 3 + .../functions/serve/serve.integration.test.ts | 70 +++++++ .../src/shared/functions/functions-docker.ts | 17 +- .../functions/functions-docker.unit.test.ts | 31 ++- apps/cli/src/shared/functions/serve.errors.ts | 178 +++++++++++++++++ .../functions/serve.errors.unit.test.ts | 183 ++++++++++++++++++ apps/cli/src/shared/functions/serve.ts | 145 ++++++++++---- .../telemetry/__fixtures__/error-tags.txt | 5 + .../shared/telemetry/error-actionability.ts | 2 + 10 files changed, 593 insertions(+), 45 deletions(-) diff --git a/apps/cli/src/command-internal/docker-suggest.ts b/apps/cli/src/command-internal/docker-suggest.ts index 483ae36735..b3f4d354ce 100644 --- a/apps/cli/src/command-internal/docker-suggest.ts +++ b/apps/cli/src/command-internal/docker-suggest.ts @@ -8,6 +8,10 @@ export const SUGGEST_DOCKER_INSTALL = export const SUGGEST_DOCKER_START = "Docker is no longer reachable. Start Docker, then rerun `supabase functions serve`."; +/** Command-neutral variant of `SUGGEST_DOCKER_START` for errors shared by several commands. */ +export const SUGGEST_DOCKER_START_RERUN = + "Docker is no longer reachable. Start Docker, then rerun the command."; + /** Remediation hint shown when the OOM killer kills a container. */ export const SUGGEST_CONTAINER_MEMORY_LIMIT = "The container ran out of memory and was killed. Raise the memory allocated to your container runtime (Docker Desktop: Settings > Resources), or free up memory on the host, and try again."; diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index f0cf7b2394..867e1d73e0 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -84,6 +84,8 @@ back to local keys. No scheme/host validation is performed on the discovered URL | ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, `serve_end_reason`, `edge_runtime_exit_code` | +Bring-up failures carry per-step `error_fingerprint` values naming the failing step and, for the Docker steps, a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict` or `tag:ServeEnvFileError:not_found`), instead of a shared `error:unknown`. + ## Output ### `--output-format text` @@ -169,6 +171,7 @@ per-function `import_map.json` is not read and does not fail the command. - 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`, or `15` from edge-runtime's own `SIGTERM` exit, e.g. `supabase stop` or `docker 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. A non-OOM `137` gets one re-inspect first — if `supabase stop` force-killed a runtime that ignored `SIGTERM` and the follow-up prune has already removed the container, the command still ends successfully instead of failing on a container that is already gone. 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 ` 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. That same follow-up inspect's `State.OOMKilled` also drives the memory-limit remediation on this path, so a `docker logs -f` failure over a container killed for exceeding its memory limit reports the same suggestion as the clean-exit case above, instead of a bare unattributable failure. +- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvFileError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint. - 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. - The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination ":/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index fc03242585..bcd5c3dba4 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -64,12 +64,15 @@ import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, EdgeRuntimeLogStreamLostError, + ServeEnvFileError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, } from "../../../shared/functions/serve.errors.ts"; import { actionability, + classifyCliErrorActionability, ErrorActionabilityId, + unwrapNativeFailure, } from "../../../shared/telemetry/error-actionability.ts"; import { normalizeCliError } from "../../../shared/output/normalize-error.ts"; import { @@ -841,6 +844,73 @@ describe("functions serve integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); + it.live("fails with a typed env-file error when the --env-file path is missing", () => + Effect.gen(function* () { + yield* writeCliConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + + const { layer } = setupServe(); + const error = yield* functionsServe(baseFlags({ envFile: Option.some("missing.env") })).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); + expect(error.message).toMatch(/^failed to load environment file: .*missing\.env \(.+\)$/su); + expect(classifyCliErrorActionability(error).error_fingerprint).toBe( + "tag:ServeEnvFileError:not_found", + ); + expect(deployMockState.runCalls.some((call) => call.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live.each([ + ["create", "Bind for 0.0.0.0:54321 failed: port is already allocated", "port_in_use"], + ["cp", "unexpected copy failure", "other"], + ] as const)( + "fails a %s step with a typed Docker step error read from stderr", + ([step, stderr, reason]) => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === step) { + return { exitCode: 1, stdout: "", stderr }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + yield* writeCliConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + + const { layer } = setupServe(); + const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + expect(unwrapNativeFailure(error)).toMatchObject({ + _tag: "EdgeRuntimeDockerStepError", + step, + reason, + }); + expect(error.message).toContain(stderr); + expect(classifyCliErrorActionability(error).error_fingerprint).toBe( + step === "create" + ? "tag:EdgeRuntimeDockerStepError:port_conflict" + : "tag:EdgeRuntimeDockerStepError:engine_command_copy", + ); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + it.live( "skips a disabled function and sets up the runtime before a malformed functions/.env fails", () => { diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 9ea76486bd..50800f92f5 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -16,6 +16,7 @@ import { ChildProcessSpawner } from "effect/process"; import { spawnContainerCli } from "../../command-internal/container-cli.ts"; import { makeDockerImageResolver } from "../../command-internal/docker-image-resolve.ts"; import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; +import { DockerResourceCreateError, classifyDockerResourceCreateStderr } from "./serve.errors.ts"; import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts"; import { sanitizeProjectId } from "../config/project-id.ts"; @@ -276,7 +277,13 @@ export const ensureDockerNetwork = Effect.fn("FunctionsDocker.ensureNetwork")(fu }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* nativeFailure(new Error(`failed to create docker network: ${networkMode}`)); + return yield* nativeFailure( + new DockerResourceCreateError({ + resource: "network", + reason: classifyDockerResourceCreateStderr(create.stderr), + message: `failed to create docker network: ${networkMode}`, + }), + ); } }); @@ -307,7 +314,13 @@ export const ensureDockerNamedVolume = Effect.fn("FunctionsDocker.ensureVolume") }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* nativeFailure(new Error(`failed to create docker volume: ${volumeName}`)); + return yield* nativeFailure( + new DockerResourceCreateError({ + resource: "volume", + reason: classifyDockerResourceCreateStderr(create.stderr), + message: `failed to create docker volume: ${volumeName}`, + }), + ); } }); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 69992b99d0..f1e046282c 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -24,10 +24,12 @@ import { ErrorActionabilityId, unwrapNativeFailure, } from "../telemetry/error-actionability.ts"; +import { DockerResourceCreateError } from "./serve.errors.ts"; import { buildFunctionsDockerRunArgs, containerArchiveBytes, edgeRuntimeCacheVolume, + ensureDockerNetwork, localDockerId, NativeFailure, nativeFailure, @@ -48,12 +50,13 @@ function mockStreamingChildProcessLayer( opts: { readonly stdout?: ReadonlyArray; readonly stderr?: ReadonlyArray; + readonly exitCode?: number; } = {}, ) { const spawner = ChildProcessSpawner.make(() => Effect.gen(function* () { const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: Stream.fromIterable(opts.stdout ?? []), @@ -547,3 +550,29 @@ describe("native failure compatibility", () => { expect(Runtime.getErrorExitCode(probe)).toBe(1); }); }); + +describe("ensureDockerNetwork", () => { + it.effect("fails with an address-pool reason when docker cannot allocate a subnet", () => + Effect.gen(function* () { + const error = yield* ensureDockerNetwork("supabase_network_p", "p").pipe( + Effect.provide( + mockStreamingChildProcessLayer({ + exitCode: 1, + stderr: [ + new TextEncoder().encode("all predefined address pools have been fully subnetted"), + ], + }), + ), + Effect.flip, + ); + + const cause = unwrapNativeFailure(error); + expect(cause).toBeInstanceOf(DockerResourceCreateError); + expect(cause).toMatchObject({ + resource: "network", + reason: "address_pool", + message: "failed to create docker network: supabase_network_p", + }); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 432f13c748..dab227307b 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -3,9 +3,12 @@ import { SUGGEST_CONTAINER_MEMORY_LIMIT, SUGGEST_DOCKER_INSTALL, SUGGEST_DOCKER_START, + SUGGEST_DOCKER_START_RERUN, + isDockerDaemonUnreachable, } from "../../command-internal/docker-suggest.ts"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../telemetry/error-actionability.ts"; @@ -152,3 +155,178 @@ export class ServeLocalDbInspectError extends Data.TaggedError("ServeLocalDbInsp return this.daemonDown ? SUGGEST_DOCKER_INSTALL : undefined; } } + +/** A `functions serve` input or project setting is invalid or missing before the container starts. */ +export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ + readonly message: string; + readonly reason: + | "inspect_flags" + | "signing_keys_invalid" + | "jwt_secret" + | "deno_version" + | "missing_import"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "inspect_flags": + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; + case "signing_keys_invalid": + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_content" }; + case "jwt_secret": + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "deno_version": + return { ...actionability.invalidConfig, fingerprint_suffix: "project_config" }; + case "missing_import": + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + default: + return actionability.invalidInput; + } + } +} + +/** The `--env-file` / functions env input can't be read, doesn't parse, or has an invalid name. */ +export class ServeEnvFileError extends Data.TaggedError("ServeEnvFileError")<{ + readonly message: string; + readonly reason: "read" | "syntax" | "env_name"; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "syntax": + return { ...actionability.invalidInput, fingerprint_suffix: "invalid_content" }; + case "env_name": + return { ...actionability.invalidInput, fingerprint_suffix: "functions_env" }; + default: + return ( + causeDeclaration(this.cause) ?? { + ...actionability.toolFailed, + fingerprint_suffix: "filesystem_io", + } + ); + } + } +} + +/** Reading the signing-keys file or staging env files on the host failed during `functions serve`. */ +export class ServeFilesystemError extends Data.TaggedError("ServeFilesystemError")<{ + readonly message: string; + readonly step: "signing_keys_read" | "env_staging"; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ( + causeDeclaration(this.cause) ?? { + ...actionability.toolFailed, + fingerprint_suffix: "filesystem_io", + } + ); + } +} + +export type EdgeRuntimeDockerStepReason = + | "daemon_down" + | "port_in_use" + | "name_conflict" + | "mount_denied" + | "disk_full" + | "other"; + +const edgeRuntimeStepSuffix = { + create: "engine_command_create", + cp: "engine_command_copy", + start: "engine_command_start", +} as const; + +/** A `docker create`, `docker cp` or `docker start` step for the Edge Runtime container failed. */ +export class EdgeRuntimeDockerStepError extends Data.TaggedError("EdgeRuntimeDockerStepError")<{ + readonly message: string; + readonly step: "create" | "cp" | "start"; + readonly reason: EdgeRuntimeDockerStepReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "daemon_down": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "port_in_use": + return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; + case "name_conflict": + return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; + case "mount_denied": + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + case "disk_full": + return { ...actionability.resourceLimit, fingerprint_suffix: "disk_full" }; + default: + return { + ...actionability.runtimeCrash, + fingerprint_suffix: edgeRuntimeStepSuffix[this.step] ?? "engine_command_create", + }; + } + } + + get suggestion(): string | undefined { + return this.reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined; + } +} + +/** Creating the Docker network or named volume that `functions serve` needs failed. */ +export class DockerResourceCreateError extends Data.TaggedError("DockerResourceCreateError")<{ + readonly message: string; + readonly resource: "network" | "volume"; + readonly reason: "daemon_down" | "address_pool" | "other"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "daemon_down": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "address_pool": + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + default: + return { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_create" }; + } + } + + get suggestion(): string | undefined { + return this.reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined; + } +} + +/** Reads the stderr of a failed Edge Runtime `docker create|cp|start` into a closed reason. */ +export const classifyEdgeRuntimeDockerStepStderr = ( + stderr: string, +): EdgeRuntimeDockerStepReason => { + if (isDockerDaemonUnreachable(stderr)) return "daemon_down"; + if ( + /port is already allocated|address already in use|ports are not available|bind: an attempt was made to access a socket/iu.test( + stderr, + ) + ) { + return "port_in_use"; + } + if (/is already in use by container|container name .* is already in use/iu.test(stderr)) { + return "name_conflict"; + } + if ( + /mounts denied|is not shared from the host|bind source path does not exist|has not been shared/iu.test( + stderr, + ) + ) { + return "mount_denied"; + } + if (/no space left on device/iu.test(stderr)) return "disk_full"; + return "other"; +}; + +/** Reads the stderr of a failed `docker network|volume create` into a closed reason. */ +export const classifyDockerResourceCreateStderr = ( + stderr: string, +): "daemon_down" | "address_pool" | "other" => { + if (isDockerDaemonUnreachable(stderr)) return "daemon_down"; + if ( + /pool overlaps|all predefined address pools have been fully subnetted|non-overlapping .*address pool/iu.test( + stderr, + ) + ) { + return "address_pool"; + } + return "other"; +}; diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index a3acd14d47..163bead199 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -1,14 +1,23 @@ +import { PlatformError } from "effect"; import { describe, expect, it } from "vitest"; import { SUGGEST_CONTAINER_MEMORY_LIMIT, SUGGEST_DOCKER_INSTALL, SUGGEST_DOCKER_START, + SUGGEST_DOCKER_START_RERUN, } from "../../command-internal/docker-suggest.ts"; import { actionability, classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; import { + classifyDockerResourceCreateStderr, + classifyEdgeRuntimeDockerStepStderr, DockerLogsStreamError, + DockerResourceCreateError, EdgeRuntimeContainerCrashedError, + EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, + ServeConfigError, + ServeEnvFileError, + ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, } from "./serve.errors.ts"; @@ -238,3 +247,177 @@ describe("ServeLocalDbInspectError actionability", () => { expect(result.error_fingerprint).toBe("tag:ServeLocalDbInspectError"); }); }); + +const platformCause = (tag: "NotFound" | "PermissionDenied") => + new PlatformError.PlatformError( + new PlatformError.SystemError({ _tag: tag, module: "FileSystem", method: "readFile" }), + ); + +describe("ServeConfigError actionability", () => { + it.each([ + ["inspect_flags", "user_actionable", "invalid_input", "tag:ServeConfigError:flags"], + [ + "signing_keys_invalid", + "user_actionable", + "invalid_config", + "tag:ServeConfigError:invalid_content", + ], + ["jwt_secret", "user_actionable", "invalid_config", "tag:ServeConfigError:invalid_config"], + ["deno_version", "user_actionable", "invalid_config", "tag:ServeConfigError:project_config"], + ["missing_import", "user_actionable", "invalid_input", "tag:ServeConfigError:not_found"], + ] as const)("classifies %s", (reason, kind, category, fingerprint) => { + const result = classifyCliErrorActionability(new ServeConfigError({ message: "m", reason })); + + expect(result.error_kind).toBe(kind); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("ServeEnvFileError actionability", () => { + it.each([ + ["syntax", undefined, "invalid_input", "tag:ServeEnvFileError:invalid_content"], + ["env_name", undefined, "invalid_input", "tag:ServeEnvFileError:functions_env"], + ["read", platformCause("NotFound"), "invalid_input", "tag:ServeEnvFileError:not_found"], + ["read", platformCause("PermissionDenied"), "permission", "tag:ServeEnvFileError:filesystem"], + ["read", undefined, "unknown", "tag:ServeEnvFileError:filesystem_io"], + ] as const)("classifies a %s failure with cause %#", (reason, cause, category, fingerprint) => { + const result = classifyCliErrorActionability( + new ServeEnvFileError({ message: "m", reason, cause }), + ); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("ServeFilesystemError actionability", () => { + it.each([ + [platformCause("NotFound"), "invalid_input", "tag:ServeFilesystemError:not_found"], + [platformCause("PermissionDenied"), "permission", "tag:ServeFilesystemError:filesystem"], + [undefined, "unknown", "tag:ServeFilesystemError:filesystem_io"], + ] as const)("classifies cause %#", (cause, category, fingerprint) => { + const result = classifyCliErrorActionability( + new ServeFilesystemError({ message: "m", step: "env_staging", cause }), + ); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("EdgeRuntimeDockerStepError actionability", () => { + it.each([ + [ + "create", + "daemon_down", + "docker_not_running", + "tag:EdgeRuntimeDockerStepError:docker_not_running", + ], + ["create", "port_in_use", "invalid_config", "tag:EdgeRuntimeDockerStepError:port_conflict"], + ["start", "name_conflict", "invalid_input", "tag:EdgeRuntimeDockerStepError:conflict"], + [ + "cp", + "mount_denied", + "invalid_config", + "tag:EdgeRuntimeDockerStepError:container_configuration", + ], + ["cp", "disk_full", "resource_limit", "tag:EdgeRuntimeDockerStepError:disk_full"], + ["create", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_create"], + ["cp", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_copy"], + ["start", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_start"], + ] as const)("classifies %s step failing with %s", (step, reason, category, fingerprint) => { + const error = new EdgeRuntimeDockerStepError({ message: "m", step, reason }); + + const result = classifyCliErrorActionability(error); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + expect(error.suggestion).toBe( + reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined, + ); + }); +}); + +describe("DockerResourceCreateError actionability", () => { + it.each([ + [ + "network", + "daemon_down", + "docker_not_running", + "tag:DockerResourceCreateError:docker_not_running", + ], + [ + "network", + "address_pool", + "invalid_config", + "tag:DockerResourceCreateError:container_configuration", + ], + ["volume", "other", "runtime_crash", "tag:DockerResourceCreateError:engine_command_create"], + ] as const)( + "classifies a %s create failing with %s", + (resource, reason, category, fingerprint) => { + const error = new DockerResourceCreateError({ message: "m", resource, reason }); + + const result = classifyCliErrorActionability(error); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + expect(error.suggestion).toBe( + reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined, + ); + }, + ); +}); + +describe("new serve errors built without fields", () => { + it.each([ + ServeConfigError, + ServeEnvFileError, + ServeFilesystemError, + EdgeRuntimeDockerStepError, + DockerResourceCreateError, + ])("keeps a tagged fingerprint for $name", (ErrorClass) => { + const error: unknown = Reflect.construct(ErrorClass, [{}]); + + const result = classifyCliErrorActionability(error); + + expect(result.error_fingerprint).toMatch(new RegExp(`^tag:${ErrorClass.name}`)); + }); +}); + +describe("classifyEdgeRuntimeDockerStepStderr", () => { + it.each([ + [ + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + "daemon_down", + ], + ["Bind for 0.0.0.0:54321 failed: port is already allocated", "port_in_use"], + [ + 'Conflict. The container name "/supabase_edge_runtime_x" is already in use by container "abc"', + "name_conflict", + ], + ["Mounts denied: The path /Users/x is not shared from the host", "mount_denied"], + ["write /var/lib/docker/tmp: no space left on device", "disk_full"], + ["something unexpected", "other"], + ["", "other"], + ["permission denied while trying to connect to the Docker daemon socket", "daemon_down"], + ] as const)("reads %j as %s", (stderr, reason) => { + expect(classifyEdgeRuntimeDockerStepStderr(stderr)).toBe(reason); + }); +}); + +describe("classifyDockerResourceCreateStderr", () => { + it.each([ + ["Cannot connect to the Docker daemon at unix:///var/run/docker.sock", "daemon_down"], + ["all predefined address pools have been fully subnetted", "address_pool"], + ["Pool overlaps with other one on this address space", "address_pool"], + [ + "could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network", + "address_pool", + ], + ["something unexpected", "other"], + ] as const)("reads %j as %s", (stderr, reason) => { + expect(classifyDockerResourceCreateStderr(stderr)).toBe(reason); + }); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2cbf9cc070..e9b1c2cfcc 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -19,6 +19,7 @@ import { type JsonWebKeyInput, } from "node:crypto"; import { styleText } from "node:util"; +import type { PlatformError } from "effect/PlatformError"; import { Clock, Data, @@ -93,9 +94,14 @@ import { slimImagesEnabled } from "../services/slim-images.ts"; import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, + EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, + ServeConfigError, + ServeEnvFileError, + ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, + classifyEdgeRuntimeDockerStepStderr, externalTerminationExitCodes, } from "./serve.errors.ts"; import { CliConfigKeys } from "../../config/cli-config-keys.ts"; @@ -460,14 +466,16 @@ export function toPlainFunctionRecord( ); } -/* Env file failures keep the plain error `start` and `serve` have always reported. */ -const legacyEnvFailure = (effect: Effect.Effect) => +/* A read failure keeps its filesystem cause; a parse failure has none. */ +const typedEnvFileFailure = (effect: Effect.Effect) => effect.pipe( Effect.mapError((error) => nativeFailure( - error.cause === undefined - ? new Error(error.message) - : new Error(error.message, { cause: error.cause }), + new ServeEnvFileError({ + reason: error.cause === undefined ? "syntax" : "read", + message: error.message, + cause: error.cause, + }), ), ), ); @@ -578,27 +586,34 @@ const resolveLocalAuthArtifacts = Effect.fn("functions.serve.resolveLocalAuthArt ); const fs = yield* FileSystem.FileSystem; - const signingKeys = yield* ( - signingKeysPath.length === 0 - ? Effect.succeed>([]) - : readFileUtf8(fs, signingKeysPath).pipe( - Effect.mapError((error) => nativePlatformFailure(error, signingKeysPath)), - Effect.flatMap((content) => - Effect.try({ try: () => parseSigningKeys(content), catch: nativeFailure }), + const signingKeys = yield* signingKeysPath.length === 0 + ? Effect.succeed>([]) + : readFileUtf8(fs, signingKeysPath).pipe( + Effect.mapError((error) => + nativeFailure( + new ServeFilesystemError({ + step: "signing_keys_read", + message: `failed to read signing keys: ${nativePlatformFailure(error, signingKeysPath).message}`, + cause: error, + }), ), - ) - ).pipe( - Effect.mapError((failure) => { - const cause = failure.cause; - return nativeFailure( - new Error( - cause instanceof SyntaxError - ? `failed to decode signing keys: ${cause.message}` - : `failed to read signing keys: ${cause.message}`, + ), + Effect.flatMap((content) => + Effect.try({ + try: () => parseSigningKeys(content), + catch: (cause) => + nativeFailure( + new ServeConfigError({ + reason: "signing_keys_invalid", + message: + cause instanceof SyntaxError + ? `failed to decode signing keys: ${cause.message}` + : `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ), + }), ), ); - }), - ); const jwtSecret = auth.jwt_secret === undefined || auth.jwt_secret.length === 0 @@ -606,7 +621,10 @@ const resolveLocalAuthArtifacts = Effect.fn("functions.serve.resolveLocalAuthArt : auth.jwt_secret; if (jwtSecret.length < 16) { return yield* nativeFailure( - new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), + new ServeConfigError({ + reason: "jwt_secret", + message: "Invalid config for auth.jwt_secret. Must be at least 16 characters", + }), ); } @@ -814,6 +832,15 @@ function splitEnvEntry(entry: string) { : ([entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const); } +const envStagingFailure = (error: PlatformError, pathname: string) => + nativeFailure( + new ServeFilesystemError({ + step: "env_staging", + message: nativePlatformFailure(error, pathname).message, + cause: error, + }), + ); + const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(function* ( { fs, path }: { readonly fs: FileSystem.FileSystem; readonly path: Path.Path }, env: Readonly>, @@ -831,10 +858,10 @@ const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(funct // alongside the fresh write. yield* fs .remove(dir, { recursive: true, force: true }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); yield* fs .makeDirectory(dir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); const pathname = path.join(dir, "docker.env"); // The file holds the JWT secret, anon/service-role keys, and JWKS, so keep it // owner-only rather than relying on the process umask. @@ -846,7 +873,7 @@ const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(funct .join("\n"), { mode: 0o600 }, ) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, pathname))); + .pipe(Effect.mapError((error) => envStagingFailure(error, pathname))); return { path: pathname }; }); @@ -863,7 +890,7 @@ const writeDockerMultilineEnvScript = Effect.fn("functions.serve.writeDockerMult // earlier invocation that needed multiline secrets is still reclaimed. yield* fs .remove(dir, { recursive: true, force: true }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); if (env.length === 0) { return undefined; @@ -871,7 +898,7 @@ const writeDockerMultilineEnvScript = Effect.fn("functions.serve.writeDockerMult yield* fs .makeDirectory(dir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); const scriptName = "multiline-env.sh"; const pathname = path.join(dir, scriptName); const envDir = path.join(containerDir, "values"); @@ -887,20 +914,20 @@ export ${name}="\${${name}%x}"`; .join("\n"); yield* fs .makeDirectory(hostEnvDir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, hostEnvDir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, hostEnvDir))); // The value files hold secret env values, so keep them owner-only. yield* Effect.all( env.map(([, value], index) => { const valuePath = path.join(hostEnvDir, `env-${index}`); return fs .writeFileString(valuePath, value, { mode: 0o600 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, valuePath))); + .pipe(Effect.mapError((error) => envStagingFailure(error, valuePath))); }), { concurrency: "unbounded" }, ); yield* fs .writeFileString(pathname, script, { mode: 0o600 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, pathname))); + .pipe(Effect.mapError((error) => envStagingFailure(error, pathname))); return { // `Z`: private SELinux relabel of this CLI-staged dir (supabase/cli#5989); @@ -1362,7 +1389,11 @@ const bestEffortRemoveContainer = Effect.fn("functions.serve.removeContainer")(f // passes a `messagePrefix`, since its raw stderr is uninterpretable alone. const runEdgeRuntimeDockerStep = Effect.fn("functions.serve.dockerStep")(function* ( args: ReadonlyArray, - opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {}, + opts: { + readonly step: "create" | "cp" | "start"; + readonly messagePrefix?: string; + readonly stdin?: Stream.Stream; + }, ) { const result = yield* runChildProcess("docker", args, { stdin: opts.stdin, @@ -1377,7 +1408,14 @@ const runEdgeRuntimeDockerStep = Effect.fn("functions.serve.dockerStep")(functio : detail.length > 0 ? `${opts.messagePrefix}: ${detail}` : opts.messagePrefix; - return yield* nativeFailure(new Error(message)); + const reason = classifyEdgeRuntimeDockerStepStderr(detail); + yield* Effect.annotateCurrentSpan({ + "edge_runtime.docker_step": opts.step, + "edge_runtime.failure_reason": reason, + }); + return yield* nativeFailure( + new EdgeRuntimeDockerStepError({ step: opts.step, reason, message }), + ); } }); @@ -1535,7 +1573,10 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin ); if (missingSourceWarning !== undefined) { return yield* nativeFailure( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + new ServeConfigError({ + reason: "missing_import", + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }), ); } } @@ -1629,7 +1670,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ); if (missingSourceWarning !== undefined) { return yield* nativeFailure( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + new ServeConfigError({ + reason: "missing_import", + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }), ); } for (const warning of bindWarnings) { @@ -1644,7 +1688,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const functionEnv = input.discoverFunctionEnvFiles && Option.isNone(input.envFile) ? yield* resolveFunctionEnv({ functionsDir, slug: config.slug, warn: true }).pipe( - legacyEnvFailure, + typedEnvFileFailure, ) : {}; functionsConfig[config.slug] = toFunctionContainerConfig( @@ -1678,7 +1722,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo normalizeEnvPath(input.flagCwd, pathname, path), ), warn: true, - }).pipe(legacyEnvFailure), + }).pipe(typedEnvFileFailure), ).map(([name, value]) => `${name}=${value}`), "SUPABASE_URL=http://kong:8000", `SUPABASE_ANON_KEY=${input.authArtifacts.anonKey}`, @@ -1704,7 +1748,13 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), - catch: nativeFailure, + catch: (cause) => + nativeFailure( + new ServeEnvFileError({ + reason: "env_name", + message: cause instanceof Error ? cause.message : String(cause), + }), + ), }); const dockerEnvFile = yield* writeDockerEnvFile( { fs, path }, @@ -1781,15 +1831,16 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // The container must exist for `docker cp` to have a target, and must not be running // yet so edge-runtime never races the copy. yield* Effect.uninterruptibleMask((restore) => - restore(runEdgeRuntimeDockerStep(command)).pipe( + restore(runEdgeRuntimeDockerStep(command, { step: "create" })).pipe( Effect.tap(() => Effect.sync(() => input.onContainerCreated?.())), ), ); yield* runEdgeRuntimeDockerStep(["cp", "-", `${containerId}:/`], { + step: "cp", messagePrefix: "failed to copy edge runtime main service into container", stdin: Stream.make(serveMainArchive), }); - yield* runEdgeRuntimeDockerStep(["start", containerId]); + yield* runEdgeRuntimeDockerStep(["start", containerId], { step: "start" }); return { containerId, @@ -1854,6 +1905,10 @@ const startEdgeRuntime = Effect.fn("functions.serve.startEdgeRuntime")(function* const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( resolved.edgeRuntime.deno_version, edgeRuntimeVersionOverride, + ).pipe( + Effect.mapError((error) => + nativeFailure(new ServeConfigError({ reason: "deno_version", message: error.message })), + ), ); yield* assertLocalDbRunning(projectId); @@ -1962,7 +2017,13 @@ export const serveFunctions = Effect.fn("functions.serve")(function* ( buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); return resolvedInspectMode; }, - catch: nativeFailure, + catch: (cause) => + nativeFailure( + new ServeConfigError({ + reason: "inspect_flags", + message: cause instanceof Error ? cause.message : String(cause), + }), + ), }); const gracePeriod = dependencies.timers?.shutdownSignalGracePeriod ?? shutdownSignalGracePeriod; const retryDelay = dependencies.timers?.dockerLogRetryDelay ?? dockerLogRetryDelay; diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index aaef9c3cc8..b06548290e 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -204,6 +204,7 @@ DockerRemoveAllListError DockerRemoveAllNetworkPruneError DockerRemoveAllStopError DockerRemoveAllVolumePruneError +DockerResourceCreateError DockerRunError DocsExamplesError DomainsCnameError @@ -211,6 +212,7 @@ DomainsNetworkError DomainsUnexpectedStatusError DuplicateRemoteProjectIdError EdgeRuntimeContainerCrashedError +EdgeRuntimeDockerStepError EdgeRuntimeLogStreamLostError EdgeRuntimeScriptError EncryptionNetworkError @@ -447,6 +449,9 @@ SecretsUnsetUnexpectedStatusError SeedConfigLoadError SeedMissingProjectConfigError SeedWorkdirError +ServeConfigError +ServeEnvFileError +ServeFilesystemError ServeLocalDbInspectError ServeLocalDbNotRunningError ServiceVersionNotFoundError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 62c9ac7231..70d0ed9a2a 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -106,8 +106,10 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "daemon_upgrade_restart", "database", "database_bootstrap", + "disk_full", "docker_not_running", "engine_command", + "engine_command_copy", "engine_command_create", "engine_command_inspect", "engine_command_remove", From cad71a1217d1eec4cf31aff8ce685a7ea4d61443 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 14:52:27 +0530 Subject: [PATCH 2/6] fix(cli): type functions env-file failures in serve and start A missing explicit --env-file now reports reason not_found; other read failures keep their filesystem cause and parse failures report syntax. Legacy start and functions serve report ServeEnvFileError instead of a plain error. Co-Authored-By: Claude Sonnet 5.5 --- .../functions/serve/serve.integration.test.ts | 47 +++++-------------- .../commands/start/start.integration.test.ts | 4 +- apps/cli/src/shared/functions/serve.errors.ts | 6 ++- .../functions/serve.errors.unit.test.ts | 9 +++- apps/cli/src/shared/functions/serve.ts | 10 +++- 5 files changed, 34 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index bcd5c3dba4..47e6d9101c 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -826,14 +826,12 @@ describe("functions serve integration", () => { const { layer } = setupServe(); const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); - } - expect(normalizeCliError(error).code).toBe("UnknownError"); + expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); + expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`); + expect(error.message).toContain("unexpected character '-' in variable name"); + expect(error.message).not.toContain("secret-value"); + expect(error.message).not.toContain('near "API-KEY=secret-value"'); + expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); expect( deployMockState.runCalls.filter( (call) => call.command === "docker" && call.args[0] === "create", @@ -844,26 +842,6 @@ describe("functions serve integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); - it.live("fails with a typed env-file error when the --env-file path is missing", () => - Effect.gen(function* () { - yield* writeCliConfig(['project_id = "test-project"', ""].join("\n")); - yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags({ envFile: Option.some("missing.env") })).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); - expect(error.message).toMatch(/^failed to load environment file: .*missing\.env \(.+\)$/su); - expect(classifyCliErrorActionability(error).error_fingerprint).toBe( - "tag:ServeEnvFileError:not_found", - ); - expect(deployMockState.runCalls.some((call) => call.args[0] === "create")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.live.each([ ["create", "Bind for 0.0.0.0:54321 failed: port is already allocated", "port_in_use"], ["cp", "unexpected copy failure", "other"], @@ -4405,12 +4383,13 @@ describe("functions serve integration", () => { }), ).pipe(Effect.provide(layer), Effect.flip); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(".env"); - expect(error.message).toContain("no such file or directory"); - } - expect(normalizeCliError(error).code).toBe("UnknownError"); + expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); + expect(error.message).toContain(".env"); + expect(error.message).toContain("no such file or directory"); + expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(classifyCliErrorActionability(error).error_fingerprint).toBe( + "tag:ServeEnvFileError:not_found", + ); expect( deployMockState.runCalls.filter( (call) => call.command === "docker" && call.args[0] === "create", diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 9156f1b1f2..83ec5b120c 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -5701,7 +5701,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), ); - it.effect("fails on a malformed supabase/functions/.env with the legacy error code", () => + it.effect("fails on a malformed supabase/functions/.env with a typed env-file error", () => Effect.gen(function* () { const { layer, workdir } = yield* setup(); @@ -5712,7 +5712,7 @@ content_path = "./supabase/templates/custom_notice.html" expect(error.message).toContain("failed to parse environment file:"); expect(error.message).not.toContain("secret-value"); - expect(normalizeCliError(error).code).toBe("UnknownError"); + expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), ); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index dab227307b..702e90f01f 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -184,14 +184,16 @@ export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ } } -/** The `--env-file` / functions env input can't be read, doesn't parse, or has an invalid name. */ +/** The `--env-file` / functions env input is missing, can't be read, doesn't parse, or has an invalid name. */ export class ServeEnvFileError extends Data.TaggedError("ServeEnvFileError")<{ readonly message: string; - readonly reason: "read" | "syntax" | "env_name"; + readonly reason: "not_found" | "read" | "syntax" | "env_name"; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { + case "not_found": + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; case "syntax": return { ...actionability.invalidInput, fingerprint_suffix: "invalid_content" }; case "env_name": diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index 163bead199..dd5b6db10e 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -278,8 +278,13 @@ describe("ServeEnvFileError actionability", () => { it.each([ ["syntax", undefined, "invalid_input", "tag:ServeEnvFileError:invalid_content"], ["env_name", undefined, "invalid_input", "tag:ServeEnvFileError:functions_env"], - ["read", platformCause("NotFound"), "invalid_input", "tag:ServeEnvFileError:not_found"], - ["read", platformCause("PermissionDenied"), "permission", "tag:ServeEnvFileError:filesystem"], + ["not_found", undefined, "invalid_input", "tag:ServeEnvFileError:not_found"], + [ + "read", + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), + "unknown", + "tag:ServeEnvFileError:filesystem_io", + ], ["read", undefined, "unknown", "tag:ServeEnvFileError:filesystem_io"], ] as const)("classifies a %s failure with cause %#", (reason, cause, category, fingerprint) => { const result = classifyCliErrorActionability( diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index e9b1c2cfcc..98fc91054a 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -31,6 +31,7 @@ import { Exit, Fiber, Option, + Predicate, Redacted, Result, Schema, @@ -466,13 +467,18 @@ export function toPlainFunctionRecord( ); } -/* A read failure keeps its filesystem cause; a parse failure has none. */ +/* Parse failures carry no cause; a missing file is a Node `ENOENT`. */ const typedEnvFileFailure = (effect: Effect.Effect) => effect.pipe( Effect.mapError((error) => nativeFailure( new ServeEnvFileError({ - reason: error.cause === undefined ? "syntax" : "read", + reason: + error.cause === undefined + ? "syntax" + : Predicate.hasProperty(error.cause, "code") && error.cause.code === "ENOENT" + ? "not_found" + : "read", message: error.message, cause: error.cause, }), From 41e4330f1f7a49217193dbac54a4c00e0ccecb6e Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 15:01:40 +0530 Subject: [PATCH 3/6] test(cli): assert typed serve errors end to end and document deploy behaviour Docker step cases state their expected fingerprint and suggestion in the table, with daemon_down and start rows added. Inspect flag, multiline env name and directory env-file cases assert the typed error code. The serve telemetry note no longer claims per-step fingerprints for filesystem failures, and deploy documents the Docker network and volume error code. Co-Authored-By: Claude Sonnet 5.5 --- .../commands/functions/deploy/SIDE_EFFECTS.md | 1 + .../commands/functions/serve/SIDE_EFFECTS.md | 2 +- .../functions/serve/serve.integration.test.ts | 68 +++++++++++++--- .../functions/serve.errors.unit.test.ts | 79 ++++++++++++++----- 4 files changed, 120 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 4074209f75..028d87eb7c 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -119,6 +119,7 @@ The `--output`/`-o` flag does not change deploy output. - Bundles locally with Docker by default (`--use-docker` defaults to true and is hidden); `--use-api` selects server-side bundling, and a stopped Docker daemon falls back to it after a `WARNING: Docker is not running`. - Local Docker bundling mounts existing local values declared under an import map's `scopes` read-only, including targets outside the nearest Git root; each such out-of-root mount prints a `WARN` naming the host path. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. API source uploads retain their existing source-root restrictions. - `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes. +- Local Docker bundling creates the project network and the Deno cache volume before the first bundle. If either creation fails, `--output json` reports `error.code` `DockerResourceCreateError` instead of `UnknownError`; the message and exit code are unchanged. When the Docker daemon is unreachable, the failure shows a start-Docker suggestion instead of the generic `--debug` hint. - Prints the target banner before anything else. - **Consent:** one question, `Deploy N Function(s)` on the target, asked after planning and before the first bundle, upload or delete. The default answer is No. `--prune` adds diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 867e1d73e0..340060f894 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -84,7 +84,7 @@ back to local keys. No scheme/host validation is performed on the discovered URL | ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, `serve_end_reason`, `edge_runtime_exit_code` | -Bring-up failures carry per-step `error_fingerprint` values naming the failing step and, for the Docker steps, a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict` or `tag:ServeEnvFileError:not_found`), instead of a shared `error:unknown`. +Bring-up failures carry a specific `error_fingerprint` instead of a shared `error:unknown`: the Edge Runtime `docker create`, `docker cp` and `docker start` steps each have their own fingerprint plus a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict`), env-file failures name the reason (for example `tag:ServeEnvFileError:not_found`), and host filesystem failures are classified from the underlying filesystem error. ## Output diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 47e6d9101c..dc100f35aa 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -44,7 +44,10 @@ import { import { CommandSettings } from "../../../config/command-settings.service.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; -import { SUGGEST_CONTAINER_MEMORY_LIMIT } from "../../../command-internal/docker-suggest.ts"; +import { + SUGGEST_CONTAINER_MEMORY_LIMIT, + SUGGEST_DOCKER_START_RERUN, +} 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 { @@ -842,12 +845,60 @@ describe("functions serve integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); + it.live("fails with a read env-file error when the --env-file path is a directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* writeCliConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile(path.join("envdir", "keep"), ""); + + const { layer } = setupServe(); + const error = yield* functionsServe(baseFlags({ envFile: Option.some("envdir") })).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error.message).toContain("failed to load environment file:"); + expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(classifyCliErrorActionability(error).error_fingerprint).toBe( + "tag:ServeEnvFileError:filesystem_io", + ); + expect(deployMockState.runCalls.some((call) => call.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live.each([ - ["create", "Bind for 0.0.0.0:54321 failed: port is already allocated", "port_in_use"], - ["cp", "unexpected copy failure", "other"], + [ + "create", + "Bind for 0.0.0.0:54321 failed: port is already allocated", + "port_in_use", + "tag:EdgeRuntimeDockerStepError:port_conflict", + undefined, + ], + [ + "create", + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + "daemon_down", + "tag:EdgeRuntimeDockerStepError:docker_not_running", + SUGGEST_DOCKER_START_RERUN, + ], + [ + "cp", + "unexpected copy failure", + "other", + "tag:EdgeRuntimeDockerStepError:engine_command_copy", + undefined, + ], + [ + "start", + "unexpected start failure", + "other", + "tag:EdgeRuntimeDockerStepError:engine_command_start", + undefined, + ], ] as const)( "fails a %s step with a typed Docker step error read from stderr", - ([step, stderr, reason]) => { + ([step, stderr, reason, fingerprint, suggestion]) => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); @@ -880,11 +931,8 @@ describe("functions serve integration", () => { reason, }); expect(error.message).toContain(stderr); - expect(classifyCliErrorActionability(error).error_fingerprint).toBe( - step === "create" - ? "tag:EdgeRuntimeDockerStepError:port_conflict" - : "tag:EdgeRuntimeDockerStepError:engine_command_copy", - ); + expect(classifyCliErrorActionability(error).error_fingerprint).toBe(fingerprint); + expect(normalizeCliError(error).suggestion).toBe(suggestion); }).pipe(Effect.provide(BunServices.layer)); }, ); @@ -1360,6 +1408,7 @@ describe("functions serve integration", () => { expect(error.message).toContain("invalid multiline environment variable name"); expect(error.message).toContain("FOO.BAR"); } + expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); expect( deployMockState.runCalls.filter( (call) => call.command === "docker" && call.args[0] === "create", @@ -3902,6 +3951,7 @@ describe("functions serve integration", () => { "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", ); } + expect(normalizeCliError(error).code).toBe("ServeConfigError"); expect(deployMockState.runCalls).toHaveLength(0); expect(deployMockState.volumeCalls).toHaveLength(0); expect(deployMockState.networkCalls).toHaveLength(0); diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index dd5b6db10e..bf163660fa 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -318,30 +318,63 @@ describe("EdgeRuntimeDockerStepError actionability", () => { "daemon_down", "docker_not_running", "tag:EdgeRuntimeDockerStepError:docker_not_running", + SUGGEST_DOCKER_START_RERUN, + ], + [ + "create", + "port_in_use", + "invalid_config", + "tag:EdgeRuntimeDockerStepError:port_conflict", + undefined, + ], + [ + "start", + "name_conflict", + "invalid_input", + "tag:EdgeRuntimeDockerStepError:conflict", + undefined, ], - ["create", "port_in_use", "invalid_config", "tag:EdgeRuntimeDockerStepError:port_conflict"], - ["start", "name_conflict", "invalid_input", "tag:EdgeRuntimeDockerStepError:conflict"], [ "cp", "mount_denied", "invalid_config", "tag:EdgeRuntimeDockerStepError:container_configuration", + undefined, + ], + ["cp", "disk_full", "resource_limit", "tag:EdgeRuntimeDockerStepError:disk_full", undefined], + [ + "create", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_create", + undefined, + ], + [ + "cp", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_copy", + undefined, + ], + [ + "start", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_start", + undefined, ], - ["cp", "disk_full", "resource_limit", "tag:EdgeRuntimeDockerStepError:disk_full"], - ["create", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_create"], - ["cp", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_copy"], - ["start", "other", "runtime_crash", "tag:EdgeRuntimeDockerStepError:engine_command_start"], - ] as const)("classifies %s step failing with %s", (step, reason, category, fingerprint) => { - const error = new EdgeRuntimeDockerStepError({ message: "m", step, reason }); + ] as const)( + "classifies %s step failing with %s", + (step, reason, category, fingerprint, suggestion) => { + const error = new EdgeRuntimeDockerStepError({ message: "m", step, reason }); - const result = classifyCliErrorActionability(error); + const result = classifyCliErrorActionability(error); - expect(result.error_category).toBe(category); - expect(result.error_fingerprint).toBe(fingerprint); - expect(error.suggestion).toBe( - reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined, - ); - }); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + expect(error.suggestion).toBe(suggestion); + }, + ); }); describe("DockerResourceCreateError actionability", () => { @@ -351,26 +384,32 @@ describe("DockerResourceCreateError actionability", () => { "daemon_down", "docker_not_running", "tag:DockerResourceCreateError:docker_not_running", + SUGGEST_DOCKER_START_RERUN, ], [ "network", "address_pool", "invalid_config", "tag:DockerResourceCreateError:container_configuration", + undefined, + ], + [ + "volume", + "other", + "runtime_crash", + "tag:DockerResourceCreateError:engine_command_create", + undefined, ], - ["volume", "other", "runtime_crash", "tag:DockerResourceCreateError:engine_command_create"], ] as const)( "classifies a %s create failing with %s", - (resource, reason, category, fingerprint) => { + (resource, reason, category, fingerprint, suggestion) => { const error = new DockerResourceCreateError({ message: "m", resource, reason }); const result = classifyCliErrorActionability(error); expect(result.error_category).toBe(category); expect(result.error_fingerprint).toBe(fingerprint); - expect(error.suggestion).toBe( - reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined, - ); + expect(error.suggestion).toBe(suggestion); }, ); }); From f4b3a64d56c7dd5e5a100b00a468eb2333c07144 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 15:06:46 +0530 Subject: [PATCH 4/6] refactor(cli): drop typed serve sites that config validation pre-empts Signing-key, jwt_secret and deno_version failures are rejected by shared config validation before serve's own checks run, so the typed variants were unreachable. Serve keeps its plain errors for them, with messages unchanged. Co-Authored-By: Claude Sonnet 5.5 --- apps/cli/src/shared/functions/serve.errors.ts | 16 +----- .../functions/serve.errors.unit.test.ts | 12 +---- apps/cli/src/shared/functions/serve.ts | 53 +++++++------------ 3 files changed, 22 insertions(+), 59 deletions(-) diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 702e90f01f..7270aed71e 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -159,23 +159,12 @@ export class ServeLocalDbInspectError extends Data.TaggedError("ServeLocalDbInsp /** A `functions serve` input or project setting is invalid or missing before the container starts. */ export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ readonly message: string; - readonly reason: - | "inspect_flags" - | "signing_keys_invalid" - | "jwt_secret" - | "deno_version" - | "missing_import"; + readonly reason: "inspect_flags" | "missing_import"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "inspect_flags": return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; - case "signing_keys_invalid": - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_content" }; - case "jwt_secret": - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; - case "deno_version": - return { ...actionability.invalidConfig, fingerprint_suffix: "project_config" }; case "missing_import": return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; default: @@ -209,10 +198,9 @@ export class ServeEnvFileError extends Data.TaggedError("ServeEnvFileError")<{ } } -/** Reading the signing-keys file or staging env files on the host failed during `functions serve`. */ +/** Staging env files on the host failed during `functions serve`. */ export class ServeFilesystemError extends Data.TaggedError("ServeFilesystemError")<{ readonly message: string; - readonly step: "signing_keys_read" | "env_staging"; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index bf163660fa..1841b96446 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -256,14 +256,6 @@ const platformCause = (tag: "NotFound" | "PermissionDenied") => describe("ServeConfigError actionability", () => { it.each([ ["inspect_flags", "user_actionable", "invalid_input", "tag:ServeConfigError:flags"], - [ - "signing_keys_invalid", - "user_actionable", - "invalid_config", - "tag:ServeConfigError:invalid_content", - ], - ["jwt_secret", "user_actionable", "invalid_config", "tag:ServeConfigError:invalid_config"], - ["deno_version", "user_actionable", "invalid_config", "tag:ServeConfigError:project_config"], ["missing_import", "user_actionable", "invalid_input", "tag:ServeConfigError:not_found"], ] as const)("classifies %s", (reason, kind, category, fingerprint) => { const result = classifyCliErrorActionability(new ServeConfigError({ message: "m", reason })); @@ -302,9 +294,7 @@ describe("ServeFilesystemError actionability", () => { [platformCause("PermissionDenied"), "permission", "tag:ServeFilesystemError:filesystem"], [undefined, "unknown", "tag:ServeFilesystemError:filesystem_io"], ] as const)("classifies cause %#", (cause, category, fingerprint) => { - const result = classifyCliErrorActionability( - new ServeFilesystemError({ message: "m", step: "env_staging", cause }), - ); + const result = classifyCliErrorActionability(new ServeFilesystemError({ message: "m", cause })); expect(result.error_category).toBe(category); expect(result.error_fingerprint).toBe(fingerprint); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 98fc91054a..f1adbf621b 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -592,34 +592,27 @@ const resolveLocalAuthArtifacts = Effect.fn("functions.serve.resolveLocalAuthArt ); const fs = yield* FileSystem.FileSystem; - const signingKeys = yield* signingKeysPath.length === 0 - ? Effect.succeed>([]) - : readFileUtf8(fs, signingKeysPath).pipe( - Effect.mapError((error) => - nativeFailure( - new ServeFilesystemError({ - step: "signing_keys_read", - message: `failed to read signing keys: ${nativePlatformFailure(error, signingKeysPath).message}`, - cause: error, - }), + const signingKeys = yield* ( + signingKeysPath.length === 0 + ? Effect.succeed>([]) + : readFileUtf8(fs, signingKeysPath).pipe( + Effect.mapError((error) => nativePlatformFailure(error, signingKeysPath)), + Effect.flatMap((content) => + Effect.try({ try: () => parseSigningKeys(content), catch: nativeFailure }), ), - ), - Effect.flatMap((content) => - Effect.try({ - try: () => parseSigningKeys(content), - catch: (cause) => - nativeFailure( - new ServeConfigError({ - reason: "signing_keys_invalid", - message: - cause instanceof SyntaxError - ? `failed to decode signing keys: ${cause.message}` - : `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - ), - }), + ) + ).pipe( + Effect.mapError((failure) => { + const cause = failure.cause; + return nativeFailure( + new Error( + cause instanceof SyntaxError + ? `failed to decode signing keys: ${cause.message}` + : `failed to read signing keys: ${cause.message}`, ), ); + }), + ); const jwtSecret = auth.jwt_secret === undefined || auth.jwt_secret.length === 0 @@ -627,10 +620,7 @@ const resolveLocalAuthArtifacts = Effect.fn("functions.serve.resolveLocalAuthArt : auth.jwt_secret; if (jwtSecret.length < 16) { return yield* nativeFailure( - new ServeConfigError({ - reason: "jwt_secret", - message: "Invalid config for auth.jwt_secret. Must be at least 16 characters", - }), + new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), ); } @@ -841,7 +831,6 @@ function splitEnvEntry(entry: string) { const envStagingFailure = (error: PlatformError, pathname: string) => nativeFailure( new ServeFilesystemError({ - step: "env_staging", message: nativePlatformFailure(error, pathname).message, cause: error, }), @@ -1911,10 +1900,6 @@ const startEdgeRuntime = Effect.fn("functions.serve.startEdgeRuntime")(function* const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( resolved.edgeRuntime.deno_version, edgeRuntimeVersionOverride, - ).pipe( - Effect.mapError((error) => - nativeFailure(new ServeConfigError({ reason: "deno_version", message: error.message })), - ), ); yield* assertLocalDbRunning(projectId); From 1d580c0c02a71b310f8a96e8dabb0878a61c511d Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 15:18:51 +0530 Subject: [PATCH 5/6] docs(cli): document typed serve errors for start and widen the Docker suggestion Document the typed bring-up errors in start's SIDE_EFFECTS.md, say that ServeEnvFileError also covers configured secrets, and make the start-Docker suggestion cover an inaccessible Docker socket. Co-Authored-By: Claude Sonnet 5.5 --- apps/cli/src/command-internal/docker-suggest.ts | 4 ++-- apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 9 +++++++++ apps/cli/src/shared/functions/serve.errors.ts | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/command-internal/docker-suggest.ts b/apps/cli/src/command-internal/docker-suggest.ts index b3f4d354ce..85932886d1 100644 --- a/apps/cli/src/command-internal/docker-suggest.ts +++ b/apps/cli/src/command-internal/docker-suggest.ts @@ -8,9 +8,9 @@ export const SUGGEST_DOCKER_INSTALL = export const SUGGEST_DOCKER_START = "Docker is no longer reachable. Start Docker, then rerun `supabase functions serve`."; -/** Command-neutral variant of `SUGGEST_DOCKER_START` for errors shared by several commands. */ +/** Command-neutral variant of `SUGGEST_DOCKER_START` for errors shared by several commands; also covers a socket the user cannot access. */ export const SUGGEST_DOCKER_START_RERUN = - "Docker is no longer reachable. Start Docker, then rerun the command."; + "Docker is not reachable. Start Docker, or check that your user can access its socket, then rerun the command."; /** Remediation hint shown when the OOM killer kills a container. */ export const SUGGEST_CONTAINER_MEMORY_LIMIT = diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 340060f894..415d4e9a2d 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -171,7 +171,7 @@ per-function `import_map.json` is not read and does not fail the command. - 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`, or `15` from edge-runtime's own `SIGTERM` exit, e.g. `supabase stop` or `docker 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. A non-OOM `137` gets one re-inspect first — if `supabase stop` force-killed a runtime that ignored `SIGTERM` and the follow-up prune has already removed the container, the command still ends successfully instead of failing on a container that is already gone. 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 ` 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. That same follow-up inspect's `State.OOMKilled` also drives the memory-limit remediation on this path, so a `docker logs -f` failure over a container killed for exceeding its memory limit reports the same suggestion as the clean-exit case above, instead of a bare unattributable failure. -- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvFileError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint. +- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvFileError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint; the suggestion also asks the user to check access to the Docker socket, since a socket permission failure takes the same path. `ServeEnvFileError` also covers an invalid multiline env name from a configured secret. - 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. - The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination ":/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 2c4c72505a..b8c36de02b 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -85,6 +85,15 @@ tar archive) → `docker start` sequence is assembled by that shared core, not b health-checked via an HTTP probe through Kong (`/functions/v1/_internal/health`), not a Docker healthcheck — mirroring PostgREST's own probe shape. +Its bring-up failures use the same typed errors as `functions serve` (`ServeConfigError` for +the missing-import check, `ServeEnvFileError` for a malformed `supabase/functions/.env` or an +invalid multiline env name from an env file or configured secret, `ServeFilesystemError` for +staging the env files on the host, and `EdgeRuntimeDockerStepError` for the container's +`docker create`/`cp`/`start`), so with `--output json` the `error.code` is the error's name +instead of `UnknownError`. A failure to reach the daemon, including a Docker socket the user +cannot access, shows a start-Docker suggestion instead of the generic `--debug` hint. +Messages and exit codes are unchanged. + ### Readiness gating Postgres's own wait does not read Docker's health status: Docker runs every `HEALTHCHECK` with diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 7270aed71e..4b7a3eb0bb 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -173,7 +173,7 @@ export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ } } -/** The `--env-file` / functions env input is missing, can't be read, doesn't parse, or has an invalid name. */ +/** The functions env input, an env file or a configured secret, is missing, can't be read, doesn't parse, or has an invalid name. */ export class ServeEnvFileError extends Data.TaggedError("ServeEnvFileError")<{ readonly message: string; readonly reason: "not_found" | "read" | "syntax" | "env_name"; From fa310d051af100a6c0c281c88f2f85a3922b7352 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 5 Nov 2026 15:38:22 +0530 Subject: [PATCH 6/6] refactor(cli): rename ServeEnvFileError to ServeEnvError The error also covers invalid names from configured secrets, so a source-neutral name fits. The class is unreleased, so no fingerprint changes. Co-Authored-By: Claude Sonnet 5.5 --- .../commands/functions/serve/SIDE_EFFECTS.md | 4 ++-- .../functions/serve/serve.integration.test.ts | 18 +++++++++--------- apps/cli/src/commands/start/SIDE_EFFECTS.md | 2 +- .../commands/start/start.integration.test.ts | 2 +- apps/cli/src/shared/functions/serve.errors.ts | 2 +- .../shared/functions/serve.errors.unit.test.ts | 18 +++++++++--------- apps/cli/src/shared/functions/serve.ts | 6 +++--- .../telemetry/__fixtures__/error-tags.txt | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 415d4e9a2d..31fe9f087b 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -84,7 +84,7 @@ back to local keys. No scheme/host validation is performed on the discovered URL | ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, `serve_end_reason`, `edge_runtime_exit_code` | -Bring-up failures carry a specific `error_fingerprint` instead of a shared `error:unknown`: the Edge Runtime `docker create`, `docker cp` and `docker start` steps each have their own fingerprint plus a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict`), env-file failures name the reason (for example `tag:ServeEnvFileError:not_found`), and host filesystem failures are classified from the underlying filesystem error. +Bring-up failures carry a specific `error_fingerprint` instead of a shared `error:unknown`: the Edge Runtime `docker create`, `docker cp` and `docker start` steps each have their own fingerprint plus a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict`), env-file failures name the reason (for example `tag:ServeEnvError:not_found`), and host filesystem failures are classified from the underlying filesystem error. ## Output @@ -171,7 +171,7 @@ per-function `import_map.json` is not read and does not fail the command. - 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`, or `15` from edge-runtime's own `SIGTERM` exit, e.g. `supabase stop` or `docker 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. A non-OOM `137` gets one re-inspect first — if `supabase stop` force-killed a runtime that ignored `SIGTERM` and the follow-up prune has already removed the container, the command still ends successfully instead of failing on a container that is already gone. 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 ` 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. That same follow-up inspect's `State.OOMKilled` also drives the memory-limit remediation on this path, so a `docker logs -f` failure over a container killed for exceeding its memory limit reports the same suggestion as the clean-exit case above, instead of a bare unattributable failure. -- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvFileError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint; the suggestion also asks the user to check access to the Docker socket, since a socket permission failure takes the same path. `ServeEnvFileError` also covers an invalid multiline env name from a configured secret. +- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint; the suggestion also asks the user to check access to the Docker socket, since a socket permission failure takes the same path. - 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. - The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination ":/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index dc100f35aa..d43cbb9e9d 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -67,7 +67,7 @@ import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, EdgeRuntimeLogStreamLostError, - ServeEnvFileError, + ServeEnvError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, } from "../../../shared/functions/serve.errors.ts"; @@ -829,12 +829,12 @@ describe("functions serve integration", () => { const { layer } = setupServe(); const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); + expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvError); expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`); expect(error.message).toContain("unexpected character '-' in variable name"); expect(error.message).not.toContain("secret-value"); expect(error.message).not.toContain('near "API-KEY=secret-value"'); - expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(normalizeCliError(error).code).toBe("ServeEnvError"); expect( deployMockState.runCalls.filter( (call) => call.command === "docker" && call.args[0] === "create", @@ -859,9 +859,9 @@ describe("functions serve integration", () => { ); expect(error.message).toContain("failed to load environment file:"); - expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(normalizeCliError(error).code).toBe("ServeEnvError"); expect(classifyCliErrorActionability(error).error_fingerprint).toBe( - "tag:ServeEnvFileError:filesystem_io", + "tag:ServeEnvError:filesystem_io", ); expect(deployMockState.runCalls.some((call) => call.args[0] === "create")).toBe(false); }).pipe(Effect.provide(BunServices.layer)), @@ -1408,7 +1408,7 @@ describe("functions serve integration", () => { expect(error.message).toContain("invalid multiline environment variable name"); expect(error.message).toContain("FOO.BAR"); } - expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(normalizeCliError(error).code).toBe("ServeEnvError"); expect( deployMockState.runCalls.filter( (call) => call.command === "docker" && call.args[0] === "create", @@ -4433,12 +4433,12 @@ describe("functions serve integration", () => { }), ).pipe(Effect.provide(layer), Effect.flip); - expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvFileError); + expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvError); expect(error.message).toContain(".env"); expect(error.message).toContain("no such file or directory"); - expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(normalizeCliError(error).code).toBe("ServeEnvError"); expect(classifyCliErrorActionability(error).error_fingerprint).toBe( - "tag:ServeEnvFileError:not_found", + "tag:ServeEnvError:not_found", ); expect( deployMockState.runCalls.filter( diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index b8c36de02b..e50f532c50 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -86,7 +86,7 @@ health-checked via an HTTP probe through Kong (`/functions/v1/_internal/health`) Docker healthcheck — mirroring PostgREST's own probe shape. Its bring-up failures use the same typed errors as `functions serve` (`ServeConfigError` for -the missing-import check, `ServeEnvFileError` for a malformed `supabase/functions/.env` or an +the missing-import check, `ServeEnvError` for a malformed `supabase/functions/.env` or an invalid multiline env name from an env file or configured secret, `ServeFilesystemError` for staging the env files on the host, and `EdgeRuntimeDockerStepError` for the container's `docker create`/`cp`/`start`), so with `--output json` the `error.code` is the error's name diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 83ec5b120c..32c5da4202 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -5712,7 +5712,7 @@ content_path = "./supabase/templates/custom_notice.html" expect(error.message).toContain("failed to parse environment file:"); expect(error.message).not.toContain("secret-value"); - expect(normalizeCliError(error).code).toBe("ServeEnvFileError"); + expect(normalizeCliError(error).code).toBe("ServeEnvError"); }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), ); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 4b7a3eb0bb..b86c5f3d1d 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -174,7 +174,7 @@ export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ } /** The functions env input, an env file or a configured secret, is missing, can't be read, doesn't parse, or has an invalid name. */ -export class ServeEnvFileError extends Data.TaggedError("ServeEnvFileError")<{ +export class ServeEnvError extends Data.TaggedError("ServeEnvError")<{ readonly message: string; readonly reason: "not_found" | "read" | "syntax" | "env_name"; readonly cause?: unknown; diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index 1841b96446..6f4ac4d86a 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -16,7 +16,7 @@ import { EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, ServeConfigError, - ServeEnvFileError, + ServeEnvError, ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, @@ -266,21 +266,21 @@ describe("ServeConfigError actionability", () => { }); }); -describe("ServeEnvFileError actionability", () => { +describe("ServeEnvError actionability", () => { it.each([ - ["syntax", undefined, "invalid_input", "tag:ServeEnvFileError:invalid_content"], - ["env_name", undefined, "invalid_input", "tag:ServeEnvFileError:functions_env"], - ["not_found", undefined, "invalid_input", "tag:ServeEnvFileError:not_found"], + ["syntax", undefined, "invalid_input", "tag:ServeEnvError:invalid_content"], + ["env_name", undefined, "invalid_input", "tag:ServeEnvError:functions_env"], + ["not_found", undefined, "invalid_input", "tag:ServeEnvError:not_found"], [ "read", Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), "unknown", - "tag:ServeEnvFileError:filesystem_io", + "tag:ServeEnvError:filesystem_io", ], - ["read", undefined, "unknown", "tag:ServeEnvFileError:filesystem_io"], + ["read", undefined, "unknown", "tag:ServeEnvError:filesystem_io"], ] as const)("classifies a %s failure with cause %#", (reason, cause, category, fingerprint) => { const result = classifyCliErrorActionability( - new ServeEnvFileError({ message: "m", reason, cause }), + new ServeEnvError({ message: "m", reason, cause }), ); expect(result.error_category).toBe(category); @@ -407,7 +407,7 @@ describe("DockerResourceCreateError actionability", () => { describe("new serve errors built without fields", () => { it.each([ ServeConfigError, - ServeEnvFileError, + ServeEnvError, ServeFilesystemError, EdgeRuntimeDockerStepError, DockerResourceCreateError, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index f1adbf621b..6f2350efb6 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -98,7 +98,7 @@ import { EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, ServeConfigError, - ServeEnvFileError, + ServeEnvError, ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, @@ -472,7 +472,7 @@ const typedEnvFileFailure = (effect: Effect.Effect nativeFailure( - new ServeEnvFileError({ + new ServeEnvError({ reason: error.cause === undefined ? "syntax" @@ -1745,7 +1745,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo try: () => validateDockerMultilineEnvNames(multilineDockerEnv), catch: (cause) => nativeFailure( - new ServeEnvFileError({ + new ServeEnvError({ reason: "env_name", message: cause instanceof Error ? cause.message : String(cause), }), diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index b06548290e..0677268c68 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -450,7 +450,7 @@ SeedConfigLoadError SeedMissingProjectConfigError SeedWorkdirError ServeConfigError -ServeEnvFileError +ServeEnvError ServeFilesystemError ServeLocalDbInspectError ServeLocalDbNotRunningError