From 66f1181046666b58467223c9a3cd67f8ec9e67fc Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:28:09 +0100 Subject: [PATCH 1/2] perf(wrangler): remove execa in favour of tinyexec --- .changeset/thin-ghosts-shout.md | 5 + .vscode/settings.json | 2 +- packages/create-cloudflare/package.json | 1 - packages/wrangler/package.json | 2 +- packages/wrangler/src/__tests__/init.test.ts | 78 +++++++++++----- .../src/__tests__/pages/deploy.test.ts | 4 +- .../wrangler/src/__tests__/vitest.setup.ts | 10 +- .../src/deployment-bundle/run-custom-build.ts | 44 +++++---- packages/wrangler/src/init.ts | 48 ++++++---- packages/wrangler/src/package-manager.ts | 9 +- pnpm-lock.yaml | 93 +------------------ 11 files changed, 133 insertions(+), 163 deletions(-) create mode 100644 .changeset/thin-ghosts-shout.md diff --git a/.changeset/thin-ghosts-shout.md b/.changeset/thin-ghosts-shout.md new file mode 100644 index 00000000000..64bee82763c --- /dev/null +++ b/.changeset/thin-ghosts-shout.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +Migrate to `tinyexec` for process execution. diff --git a/.vscode/settings.json b/.vscode/settings.json index 853f043eed6..678d13135f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,6 @@ "endgroup", "esbuild", "eslintcache", - "execa", "fedramp", "filestat", "haikunate", @@ -46,6 +45,7 @@ "selfsigned", "subrequests", "textfile", + "tinyexec", "tsbuildinfo", "turborepo", "undici", diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index 4c1ea754090..44e4bc96aa1 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -65,7 +65,6 @@ "dns2": "^2.1.0", "dotenv": "catalog:default", "esbuild": "catalog:default", - "execa": "^7.1.1", "exit-hook": "2.2.1", "get-port": "^7.1.0", "haikunator": "^2.1.2", diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index ecc77719d42..3f9f411f966 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -145,7 +145,6 @@ "dotenv-expand": "^12.0.2", "empathic": "^2.0.0", "esprima": "4.0.1", - "execa": "^6.1.0", "get-port": "^7.0.0", "glob-to-regexp": "^0.4.1", "https-proxy-agent": "7.0.2", @@ -173,6 +172,7 @@ "smol-toml": "catalog:default", "supports-color": "^9.2.2", "timeago.js": "4.0.2", + "tinyexec": "^1.2.4", "tree-kill": "catalog:default", "ts-dedent": "^2.2.0", "ts-json-schema-generator": "^1.5.0", diff --git a/packages/wrangler/src/__tests__/init.test.ts b/packages/wrangler/src/__tests__/init.test.ts index c62ba15dee0..46519367bbc 100644 --- a/packages/wrangler/src/__tests__/init.test.ts +++ b/packages/wrangler/src/__tests__/init.test.ts @@ -1,9 +1,9 @@ import * as fs from "node:fs"; import path from "node:path"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { execa } from "execa"; import { http, HttpResponse } from "msw"; import * as TOML from "smol-toml"; +import { x } from "tinyexec"; import dedent from "ts-dedent"; import { parseConfigFileTextToJson } from "typescript"; import { FormData } from "undici"; @@ -76,19 +76,27 @@ describe("init", () => { } `); - expect(execa).toHaveBeenCalledWith("mockpm", ["create", "cloudflare"], { - stdio: ["inherit", "pipe", "pipe"], + expect(x).toHaveBeenCalledWith("mockpm", ["create", "cloudflare"], { + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, }); }); it("if `-y` is used, delegate to c3 with --wrangler-defaults", async () => { await runWrangler("init -y"); - expect(execa).toHaveBeenCalledWith( + expect(x).toHaveBeenCalledWith( "mockpm", ["create", "cloudflare", "--wrangler-defaults"], { - stdio: ["inherit", "pipe", "pipe"], + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, } ); }); @@ -104,7 +112,7 @@ describe("init", () => { (getPackageManager as Mock).mockResolvedValue(mockPackageManager); // Update the mock to handle "yarn" for these tests - (execa as Mock).mockImplementation((command: string) => { + (x as Mock).mockImplementation((command: string) => { if (command === "yarn" || command === "mockpm") { return Promise.resolve(); } @@ -116,15 +124,19 @@ describe("init", () => { await runWrangler("init"); // No version specifier needed since C3 has auto-update behavior - expect(execa).toHaveBeenCalledWith("yarn", ["create", "cloudflare"], { - stdio: ["inherit", "pipe", "pipe"], + expect(x).toHaveBeenCalledWith("yarn", ["create", "cloudflare"], { + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, }); }); test("uses C3 command without version specifier when using --from-dash with yarn", async () => { await runWrangler("init --from-dash my-worker"); - expect(execa).toHaveBeenCalledWith( + expect(x).toHaveBeenCalledWith( "yarn", [ "create", @@ -134,7 +146,11 @@ describe("init", () => { "my-worker", ], { - stdio: ["inherit", "pipe", "pipe"], + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, } ); }); @@ -171,23 +187,27 @@ describe("init", () => { } `); - expect(execa).toHaveBeenCalledWith( - "mockpm", - ["run", "create-cloudflare"], - { + expect(x).toHaveBeenCalledWith("mockpm", ["run", "create-cloudflare"], { + nodeOptions: { stdio: ["inherit", "pipe", "pipe"], - } - ); + }, + throwOnError: true, + nodePath: false, + }); }); it("if `-y` is used, delegate to c3 with --wrangler-defaults", async () => { await runWrangler("init -y"); - expect(execa).toHaveBeenCalledWith( + expect(x).toHaveBeenCalledWith( "mockpm", ["run", "create-cloudflare", "--wrangler-defaults"], { - stdio: ["inherit", "pipe", "pipe"], + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, } ); }); @@ -202,11 +222,15 @@ describe("init", () => { }); await runWrangler("init"); - expect(execa).toHaveBeenCalledWith("mockpm", ["create", "cloudflare"], { - env: { - CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1", + expect(x).toHaveBeenCalledWith("mockpm", ["create", "cloudflare"], { + nodeOptions: { + env: { + CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1", + }, + stdio: ["inherit", "pipe", "pipe"], }, - stdio: ["inherit", "pipe", "pipe"], + throwOnError: true, + nodePath: false, }); }); }); @@ -848,8 +872,8 @@ describe("init", () => { } `); - expect(execa).toHaveBeenCalledTimes(1); - expect(execa).toHaveBeenCalledWith( + expect(x).toHaveBeenCalledTimes(1); + expect(x).toHaveBeenCalledWith( "mockpm", [ "create", @@ -859,7 +883,11 @@ describe("init", () => { "existing-memory-crystal", ], { - stdio: ["inherit", "pipe", "pipe"], + nodeOptions: { + stdio: ["inherit", "pipe", "pipe"], + }, + throwOnError: true, + nodePath: false, } ); }); diff --git a/packages/wrangler/src/__tests__/pages/deploy.test.ts b/packages/wrangler/src/__tests__/pages/deploy.test.ts index 8dda8741bba..d8ba4736f21 100644 --- a/packages/wrangler/src/__tests__/pages/deploy.test.ts +++ b/packages/wrangler/src/__tests__/pages/deploy.test.ts @@ -6,9 +6,9 @@ import { writeWranglerConfig, } from "@cloudflare/workers-utils/test-helpers"; import ci from "ci-info"; -import { execa } from "execa"; import { http, HttpResponse } from "msw"; import TOML from "smol-toml"; +import { x } from "tinyexec"; import dedent from "ts-dedent"; /* eslint-disable-next-line no-restricted-imports -- * Uses expect in MSW handlers outside test callbacks @@ -1973,7 +1973,7 @@ describe("pages deploy", () => { }) => { vi.mocked(ci).isCI = false; setIsTTY(true); - await execa("git", ["init"]); + await x("git", ["init"], { throwOnError: true }); writeFileSync("logo.png", "foobar"); mockGetUploadTokenRequest( expect, diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index f62a9a4b5dd..3cf29d70e1a 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -283,14 +283,12 @@ vi.mock("prompts", () => { }; }); -vi.mock("execa", async (importOriginal) => { - const realModule = await importOriginal(); +vi.mock("tinyexec", async (importOriginal) => { + const realModule = await importOriginal(); return { ...realModule, - execa: vi.fn((...args: Parameters) => { - return args[0] === "mockpm" - ? Promise.resolve() - : realModule.execa(...args); + x: vi.fn((...args: Parameters) => { + return args[0] === "mockpm" ? Promise.resolve() : realModule.x(...args); }), }; }); diff --git a/packages/wrangler/src/deployment-bundle/run-custom-build.ts b/packages/wrangler/src/deployment-bundle/run-custom-build.ts index a6b9da18b29..1aa069303fd 100644 --- a/packages/wrangler/src/deployment-bundle/run-custom-build.ts +++ b/packages/wrangler/src/deployment-bundle/run-custom-build.ts @@ -3,12 +3,12 @@ import path from "node:path"; import { Writable } from "node:stream"; import { configFileName, UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import { execaCommand } from "execa"; +import { x } from "tinyexec"; import treeKill from "tree-kill"; import dedent from "ts-dedent"; import { logger } from "../logger"; import type { Config } from "@cloudflare/workers-utils"; -import type { ExecaChildProcess } from "execa"; +import type { Result } from "tinyexec"; export type WranglerCommand = "dev" | "deploy" | "versions upload" | "types"; @@ -28,18 +28,21 @@ export async function runCommand( logger.log(chalk.blue(prefix), "Running:", command); let abortHandler: ReturnType | undefined; try { - const res = execaCommand(command, { - shell: true, - cwd, - env: { - ...process.env, - ...(runOptions?.wranglerCommand - ? { WRANGLER_COMMAND: runOptions.wranglerCommand } - : {}), + const res = x(command, [], { + nodeOptions: { + shell: true, + cwd, + env: { + ...(runOptions?.wranglerCommand + ? { WRANGLER_COMMAND: runOptions.wranglerCommand } + : {}), + }, }, + throwOnError: true, + nodePath: false, }); abortHandler = terminateProcessOnAbort(runOptions?.signal, res); - res.stdout?.pipe( + res.process?.stdout?.pipe( new Writable({ write(chunk: Buffer, _, callback) { const lines = chunk.toString().split("\n"); @@ -50,7 +53,7 @@ export async function runCommand( }, }) ); - res.stderr?.pipe( + res.process?.stderr?.pipe( new Writable({ write(chunk: Buffer, _, callback) { const lines = chunk.toString().split("\n"); @@ -61,7 +64,16 @@ export async function runCommand( }, }) ); - await res; + const { exitCode } = await res; + // `throwOnError` only covers non-zero exit codes. A process that was + // terminated by a signal (e.g. because `signal` aborted, or because the + // user pressed Ctrl-C) reports no exit code at all, and must not be + // treated as a successful build. + if (exitCode === undefined) { + throw new Error( + `Command \`${command}\` was terminated by ${res.process?.signalCode ?? "a signal"}` + ); + } if (runOptions?.signal?.aborted) { await abortHandler?.waitForExit(); } @@ -139,12 +151,12 @@ function assertEntryPointExists( * POSIX and Windows. This matters because custom build commands are run through * a shell and typically spawn their own child processes (e.g. `npm run build`). * Killing the whole tree both terminates those children and closes the stdio - * pipes they inherited — without the latter, the `execa` promise would hang + * pipes they inherited — without the latter, the command promise would hang * waiting for the pipes to reach EOF. */ function terminateProcessOnAbort( signal: AbortSignal | undefined, - subprocess: ExecaChildProcess + subprocess: Result ) { let processExitPromise: Promise | undefined; let forceKillTimer: NodeJS.Timeout | undefined; @@ -163,7 +175,7 @@ function terminateProcessOnAbort( }); }); // If the process tree ignores SIGTERM (and keeps stdio pipes open, which - // would otherwise hang the `execa` promise), escalate to SIGKILL after a + // would otherwise hang the command promise), escalate to SIGKILL after a // grace period. The timer is cleared in `cleanup()` once the command has // settled, so SIGKILL is only sent to a process tree that refused to exit. forceKillTimer ??= setTimeout(() => { diff --git a/packages/wrangler/src/init.ts b/packages/wrangler/src/init.ts index 01ccace6279..bfdc7b90f4f 100644 --- a/packages/wrangler/src/init.ts +++ b/packages/wrangler/src/init.ts @@ -6,7 +6,7 @@ import { getC3CommandFromEnv, UserError, } from "@cloudflare/workers-utils"; -import { execa } from "execa"; +import { x } from "tinyexec"; import { fetchResult } from "./cfetch"; import { fetchWorkerDefinitionFromDash } from "./cfetch/internal"; import { createCommand } from "./core/create-command"; @@ -20,8 +20,8 @@ import * as shellquote from "./utils/shell-quote"; import { isWorkerNotFoundError } from "./utils/worker-not-found-error"; import type { PackageManager } from "./package-manager"; import type { ServiceMetadataRes } from "@cloudflare/workers-utils"; -import type { ExecaError } from "execa"; import type { ReadableStream } from "node:stream/web"; +import type { NonZeroExitError } from "tinyexec"; export const init = createCommand({ metadata: { @@ -137,26 +137,36 @@ export const init = createCommand({ // if telemetry is disabled in wrangler, prevent c3 from sending metrics too const metricsConfig = readMetricsConfig(); try { - const childProcess = execa(packageManager.type, c3Arguments, { - // Note: we need to pipe stdout and stderr otherwise execa won't include - // those in the command's result/error, but we want it to so that we - // can include those in the error Sentry receives - stdio: ["inherit", "pipe", "pipe"], - ...(metricsConfig.permission?.enabled === false && { - env: { CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1" }, - }), + const childProcess = x(packageManager.type, c3Arguments, { + nodeOptions: { + // Note: we need to pipe stdout and stderr otherwise tinyexec won't + // include those in the command's error, but we want it to so + // that we can include those in the error Sentry receives + stdio: ["inherit", "pipe", "pipe"], + ...(metricsConfig.permission?.enabled === false && { + env: { CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1" }, + }), + }, + throwOnError: true, + nodePath: false, }); - childProcess.stdout?.pipe(process.stdout); - childProcess.stderr?.pipe(process.stderr); + childProcess.process?.stdout?.pipe(process.stdout); + childProcess.process?.stderr?.pipe(process.stderr); await childProcess; } catch (e: unknown) { - const execaError = e as ExecaError; - throw new Error(execaError.shortMessage, { - // We include the execaError as the cause, in this way this - // will be reflected in Sentry allowing us to better monitor - // C3 errors - cause: execaError, - }); + const procError = e as NonZeroExitError; + const output = [procError.output?.stdout, procError.output?.stderr] + .filter(Boolean) + .join("\n"); + throw new Error( + output ? `${procError.message}\n\n${output}` : procError.message, + { + // We include the process error as the cause, in this way this + // will be reflected in Sentry allowing us to better monitor + // C3 errors + cause: procError, + } + ); } } }, diff --git a/packages/wrangler/src/package-manager.ts b/packages/wrangler/src/package-manager.ts index 1afbb187ef3..eb6add89363 100644 --- a/packages/wrangler/src/package-manager.ts +++ b/packages/wrangler/src/package-manager.ts @@ -1,6 +1,6 @@ import { env } from "node:process"; import { UserError } from "@cloudflare/workers-utils"; -import { execaCommandSync } from "execa"; +import { x } from "tinyexec"; import { logger } from "./logger"; export type { PackageManager } from "@cloudflare/workers-utils"; @@ -86,7 +86,12 @@ export function getPackageManagerName(packageManager: PackageManager): string { async function supports(name: string): Promise { try { - execaCommandSync(`${name} --version`, { stdio: "ignore" }); + await x(name, ["--version"], { + nodeOptions: { stdio: "ignore" }, + throwOnError: true, + // Disable tinyexec's default PATH (includes node_modules/.bin) + nodePath: false, + }); return true; } catch { return false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6541eadae98..1a25ad91913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1963,9 +1963,6 @@ importers: esbuild: specifier: catalog:default version: 0.28.1 - execa: - specifier: ^7.1.1 - version: 7.1.1 exit-hook: specifier: 2.2.1 version: 2.2.1 @@ -4708,9 +4705,6 @@ importers: esprima: specifier: 4.0.1 version: 4.0.1 - execa: - specifier: ^6.1.0 - version: 6.1.0 get-port: specifier: ^7.0.0 version: 7.0.0 @@ -4792,6 +4786,9 @@ importers: timeago.js: specifier: 4.0.2 version: 4.0.2 + tinyexec: + specifier: ^1.2.4 + version: 1.2.4 tree-kill: specifier: catalog:default version: 1.2.2 @@ -12212,14 +12209,6 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - execa@6.1.0: - resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - execa@7.1.1: - resolution: {integrity: sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==} - engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} - exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -12815,14 +12804,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - human-signals@3.0.1: - resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} - engines: {node: '>=12.20.0'} - - human-signals@4.3.1: - resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} - engines: {node: '>=14.18.0'} - humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -13084,10 +13065,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -13808,10 +13785,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -14112,10 +14085,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npm-run-path@5.1.0: - resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -14194,10 +14163,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} @@ -14406,10 +14371,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -15855,10 +15816,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -24868,30 +24825,6 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - execa@6.1.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 3.0.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - - execa@7.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 4.3.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - exit-hook@2.2.1: {} expand-template@2.0.3: @@ -25656,10 +25589,6 @@ snapshots: human-signals@2.1.0: {} - human-signals@3.0.1: {} - - human-signals@4.3.1: {} - humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -25879,8 +25808,6 @@ snapshots: is-stream@2.0.1: {} - is-stream@3.0.0: {} - is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -26520,8 +26447,6 @@ snapshots: mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} - mimic-response@3.1.0: optional: true @@ -26810,10 +26735,6 @@ snapshots: dependencies: path-key: 3.1.1 - npm-run-path@5.1.0: - dependencies: - path-key: 4.0.0 - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -26881,10 +26802,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - oniguruma-parser@0.12.1: {} oniguruma-to-es@4.3.5: @@ -27124,8 +27041,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -28875,8 +28790,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} From c47d183c06a7d743e82bb8a84ac7a779eb5895e3 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 31 Jul 2026 14:18:40 +0100 Subject: [PATCH 2/2] fix(wrangler): preserve execa's failure semantics under tinyexec Two behavioural differences surfaced while migrating: - `throwOnError` only covers non-zero exit codes, so a process killed by a signal resolved successfully where execa rejected. For custom builds this silently turned an aborted build into a successful one. Guard all three call sites on a missing exit code. - `NonZeroExitError.message` does not name the command the way execa's `shortMessage` did. Embedding C3's captured stdout/stderr to compensate printed the output twice (it is already streamed live) and gave every failure a unique message, defeating Sentry grouping. Name the command and exit code instead, and let the output travel on the cause. Also narrows the C3 error with `instanceof` rather than an unchecked cast, trims the custom build command before handing it to the shell (`cmd.exe` is not whitespace tolerant, and `execaCommand` used to normalise this), and adds coverage for the abort, signal and C3 failure paths. --- .changeset/thin-ghosts-shout.md | 2 +- .../src/__tests__/custom-build.test.ts | 12 ++++++ packages/wrangler/src/__tests__/init.test.ts | 38 ++++++++++++++++- .../src/__tests__/pages/deploy.test.ts | 2 +- .../wrangler/src/__tests__/vitest.setup.ts | 15 ++++++- .../src/deployment-bundle/run-custom-build.ts | 18 +++++--- packages/wrangler/src/init.ts | 41 +++++++++++-------- packages/wrangler/src/package-manager.ts | 12 ++++-- 8 files changed, 107 insertions(+), 33 deletions(-) diff --git a/.changeset/thin-ghosts-shout.md b/.changeset/thin-ghosts-shout.md index 64bee82763c..e8e5857e0f1 100644 --- a/.changeset/thin-ghosts-shout.md +++ b/.changeset/thin-ghosts-shout.md @@ -2,4 +2,4 @@ "wrangler": patch --- -Migrate to `tinyexec` for process execution. +Replace `execa` with `tinyexec` for running subprocesses, shrinking the bundled Wrangler output. diff --git a/packages/wrangler/src/__tests__/custom-build.test.ts b/packages/wrangler/src/__tests__/custom-build.test.ts index 721c38d2313..a7ef4407e43 100644 --- a/packages/wrangler/src/__tests__/custom-build.test.ts +++ b/packages/wrangler/src/__tests__/custom-build.test.ts @@ -31,6 +31,18 @@ describe("Custom Builds", () => { } }); + // POSIX-only: `kill` is not a `cmd.exe` command, and Windows has no real + // signals anyway — a terminated process there reports a non-zero exit code, + // which `throwOnError` already covers. + it.skipIf(process.platform === "win32")( + "runCustomBuild throws UserError when a command is terminated by a signal", + async ({ expect }) => { + await expect( + runCustomBuild("/", "/", { command: "kill -TERM $$" }, undefined) + ).rejects.toThrow(UserError); + } + ); + it("runCommand aborts the custom build command", async ({ expect }) => { const aborter = new AbortController(); const commandPromise = runCommand( diff --git a/packages/wrangler/src/__tests__/init.test.ts b/packages/wrangler/src/__tests__/init.test.ts index 46519367bbc..4a6381d0504 100644 --- a/packages/wrangler/src/__tests__/init.test.ts +++ b/packages/wrangler/src/__tests__/init.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import * as TOML from "smol-toml"; -import { x } from "tinyexec"; +import { NonZeroExitError, x } from "tinyexec"; import dedent from "ts-dedent"; import { parseConfigFileTextToJson } from "typescript"; import { FormData } from "undici"; @@ -114,7 +114,7 @@ describe("init", () => { // Update the mock to handle "yarn" for these tests (x as Mock).mockImplementation((command: string) => { if (command === "yarn" || command === "mockpm") { - return Promise.resolve(); + return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }); } return Promise.reject(new Error(`Unexpected command: ${command}`)); }); @@ -233,6 +233,40 @@ describe("init", () => { nodePath: false, }); }); + + describe("when C3 fails", () => { + it("reports a stable message that names the command and carries the output on the cause", async ({ + expect, + }) => { + const output = { stdout: "some stdout", stderr: "some stderr" }; + const cause = new NonZeroExitError( + { pid: 1234, killed: false, exitCode: 2 }, + { ...output, exitCode: 2 } + ); + (x as Mock).mockRejectedValueOnce(cause); + + await expect(runWrangler("init")).rejects.toThrow( + expect.objectContaining({ + // The message must not embed the command output, otherwise every + // C3 failure becomes a distinct Sentry issue. + message: "`mockpm create cloudflare` failed with exit code 2", + cause, + }) + ); + }); + + it("reports a process terminated by a signal as a failure", async ({ + expect, + }) => { + // `throwOnError` does not fire for signal termination: tinyexec resolves + // with no exit code at all. + (x as Mock).mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await expect(runWrangler("init")).rejects.toThrow( + "`mockpm create cloudflare` was terminated by a signal" + ); + }); + }); }); describe("--from-dash --no-delegate-c3", () => { diff --git a/packages/wrangler/src/__tests__/pages/deploy.test.ts b/packages/wrangler/src/__tests__/pages/deploy.test.ts index d8ba4736f21..62d86ea190b 100644 --- a/packages/wrangler/src/__tests__/pages/deploy.test.ts +++ b/packages/wrangler/src/__tests__/pages/deploy.test.ts @@ -1973,7 +1973,7 @@ describe("pages deploy", () => { }) => { vi.mocked(ci).isCI = false; setIsTTY(true); - await x("git", ["init"], { throwOnError: true }); + await x("git", ["init"], { throwOnError: true, nodePath: false }); writeFileSync("logo.png", "foobar"); mockGetUploadTokenRequest( expect, diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index 3cf29d70e1a..b6762a70733 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -285,10 +285,23 @@ vi.mock("prompts", () => { vi.mock("tinyexec", async (importOriginal) => { const realModule = await importOriginal(); + // `x()` returns an `ExecProcess`: a thenable that also exposes the spawned + // process. Mimic enough of that shape that callers which reach for `.process` + // or `.pid` behave the same way they would for a real (already exited) + // command. + const mockResult = () => + Object.assign(Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }), { + process: undefined, + pid: undefined, + exitCode: 0, + killed: false, + aborted: false, + kill: () => false, + }); return { ...realModule, x: vi.fn((...args: Parameters) => { - return args[0] === "mockpm" ? Promise.resolve() : realModule.x(...args); + return args[0] === "mockpm" ? mockResult() : realModule.x(...args); }), }; }); diff --git a/packages/wrangler/src/deployment-bundle/run-custom-build.ts b/packages/wrangler/src/deployment-bundle/run-custom-build.ts index 1aa069303fd..ec3d97b3143 100644 --- a/packages/wrangler/src/deployment-bundle/run-custom-build.ts +++ b/packages/wrangler/src/deployment-bundle/run-custom-build.ts @@ -28,17 +28,23 @@ export async function runCommand( logger.log(chalk.blue(prefix), "Running:", command); let abortHandler: ReturnType | undefined; try { - const res = x(command, [], { + // `command` is handed to the shell verbatim, so trim it: on Windows the + // shell is `cmd.exe /d /s /c ""`, where surrounding whitespace + // from a multi-line config value is not harmless. + const res = x(command.trim(), [], { nodeOptions: { shell: true, cwd, - env: { - ...(runOptions?.wranglerCommand - ? { WRANGLER_COMMAND: runOptions.wranglerCommand } - : {}), - }, + // tinyexec always merges this over `process.env`, so the rest of the + // environment is still inherited. + env: runOptions?.wranglerCommand + ? { WRANGLER_COMMAND: runOptions.wranglerCommand } + : undefined, }, throwOnError: true, + // Disable tinyexec's default PATH manipulation, which prepends every + // ancestor `node_modules/.bin` and the directory holding the running + // Node binary. Custom builds should see the user's own PATH. nodePath: false, }); abortHandler = terminateProcessOnAbort(runOptions?.signal, res); diff --git a/packages/wrangler/src/init.ts b/packages/wrangler/src/init.ts index bfdc7b90f4f..111d97b4000 100644 --- a/packages/wrangler/src/init.ts +++ b/packages/wrangler/src/init.ts @@ -6,7 +6,7 @@ import { getC3CommandFromEnv, UserError, } from "@cloudflare/workers-utils"; -import { x } from "tinyexec"; +import { NonZeroExitError, x } from "tinyexec"; import { fetchResult } from "./cfetch"; import { fetchWorkerDefinitionFromDash } from "./cfetch/internal"; import { createCommand } from "./core/create-command"; @@ -21,7 +21,6 @@ import { isWorkerNotFoundError } from "./utils/worker-not-found-error"; import type { PackageManager } from "./package-manager"; import type { ServiceMetadataRes } from "@cloudflare/workers-utils"; import type { ReadableStream } from "node:stream/web"; -import type { NonZeroExitError } from "tinyexec"; export const init = createCommand({ metadata: { @@ -140,8 +139,8 @@ export const init = createCommand({ const childProcess = x(packageManager.type, c3Arguments, { nodeOptions: { // Note: we need to pipe stdout and stderr otherwise tinyexec won't - // include those in the command's error, but we want it to so - // that we can include those in the error Sentry receives + // capture those on the command's error, but we want it to so + // that they are attached to the error Sentry receives stdio: ["inherit", "pipe", "pipe"], ...(metricsConfig.permission?.enabled === false && { env: { CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1" }, @@ -152,21 +151,27 @@ export const init = createCommand({ }); childProcess.process?.stdout?.pipe(process.stdout); childProcess.process?.stderr?.pipe(process.stderr); - await childProcess; + const { exitCode } = await childProcess; + // `throwOnError` only covers non-zero exit codes, so a process that + // was terminated by a signal would otherwise look like a success. + if (exitCode === undefined) { + throw new Error( + `${replacementC3Command} was terminated by ${childProcess.process?.signalCode ?? "a signal"}` + ); + } } catch (e: unknown) { - const procError = e as NonZeroExitError; - const output = [procError.output?.stdout, procError.output?.stderr] - .filter(Boolean) - .join("\n"); - throw new Error( - output ? `${procError.message}\n\n${output}` : procError.message, - { - // We include the process error as the cause, in this way this - // will be reflected in Sentry allowing us to better monitor - // C3 errors - cause: procError, - } - ); + if (e instanceof NonZeroExitError) { + // C3 has already streamed its output to the terminal, so keep it out + // of the message: a stable message is what lets Sentry group these + // failures together. The captured output rides along on the cause, + // which is what Sentry reports, allowing us to better monitor C3 + // errors. + throw new Error( + `${replacementC3Command} failed with exit code ${e.exitCode ?? "unknown"}`, + { cause: e } + ); + } + throw e; } } }, diff --git a/packages/wrangler/src/package-manager.ts b/packages/wrangler/src/package-manager.ts index eb6add89363..181ab4dfa68 100644 --- a/packages/wrangler/src/package-manager.ts +++ b/packages/wrangler/src/package-manager.ts @@ -86,14 +86,18 @@ export function getPackageManagerName(packageManager: PackageManager): string { async function supports(name: string): Promise { try { - await x(name, ["--version"], { + const { exitCode } = await x(name, ["--version"], { nodeOptions: { stdio: "ignore" }, - throwOnError: true, - // Disable tinyexec's default PATH (includes node_modules/.bin) + // Disable tinyexec's default PATH manipulation, which prepends every + // ancestor `node_modules/.bin` and the directory holding the running Node + // binary, so that we only detect package managers that are actually + // available on the user's own PATH. nodePath: false, }); - return true; + // `exitCode` is `undefined` when the process was terminated by a signal. + return exitCode === 0; } catch { + // The command could not be spawned at all (e.g. it is not installed). return false; } }