Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-miniflare-hang-on-workerd-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
---

Detect early workerd exit instead of hanging indefinitely

When `workerd` exits during startup before writing all expected listen events to the control file descriptor (e.g. due to an IPv6 bind failure, permission error, or missing library), Miniflare's `waitForPorts()` would block forever. This caused `wrangler dev` to stall at "Starting local server..." with no error and no timeout.

The fix races `waitForPorts()` against the child process exit event so that any unexpected `workerd` termination is detected immediately. When `workerd` exits early, Miniflare now throws `ERR_RUNTIME_FAILURE` with the runtime's stderr output included in the error message, making the root cause diagnosable without external tools.
7 changes: 7 additions & 0 deletions .changeset/update-secret-bulk-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Update `wrangler secret bulk` command description to reflect create/update/delete capabilities

The help text for `wrangler secret bulk` now accurately describes that the command can create, update, or delete multiple secrets in a single request, with up to 100 secrets per command. The file argument description also clarifies that setting a key to `null` in JSON will delete it, and that deletion is not supported with `.env` files.
8 changes: 8 additions & 0 deletions .changeset/wrangler-auth-config-file-mode-0600.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"wrangler": patch
"@cloudflare/workers-auth": patch
---

Tighten on-disk permissions of the OAuth credentials file to `0600`

The user auth config file written by `wrangler login` (typically `~/.config/.wrangler/config/default.toml` on Linux/macOS, or `<environment>.toml` for non-production Cloudflare API environments) is now written with mode `0600` and re-`chmod`-ed on every save. This prevents other local users on shared hosts from reading the stored OAuth tokens. Existing files with looser permissions written by older Wrangler versions are tightened the next time Wrangler refreshes the token or the user logs in again. The change is a no-op on Windows, which does not honour POSIX mode bits.
17 changes: 17 additions & 0 deletions .changeset/wrangler-login-oauth-callback-error-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"wrangler": patch
"@cloudflare/workers-auth": patch
---

Show the actual OAuth error instead of hanging when `wrangler login` is rejected by the OAuth provider (for example with `invalid_scope`).

Previously, if the OAuth callback returned with an `error` other than `access_denied`, Wrangler would never respond to the browser. Because `server.close()`'s callback only fires once all open connections have ended, the login command would hang until the 120 second OAuth timeout — at which point it would print a generic timeout message rather than the actual OAuth failure. The same gap existed for the case where the OAuth provider redirected back without an authorisation code, and for failures during the auth-code-to-access-token exchange.

The OAuth provider's `error_description` (RFC 6749 §4.1.2.1) is now also surfaced, so the message includes the specific reason for the failure rather than just the bare `error` code. For example, a misconfigured staging scope now surfaces as:

```
OAuth error: invalid_scope
The OAuth 2.0 Client is not allowed to request scope 'browser:write'.
```

instead of hanging silently.
25 changes: 22 additions & 3 deletions packages/miniflare/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ class StartupLogBuffer {
`Address already in use (${match[1]}:${match[2]}). Please check that you are not already running a server on this address or specify a different port with --port.`
);
}

// If stderr contains any output, surface it so the user can diagnose
// the failure (e.g. bind errors on IPv6 addresses, permission denied,
// missing libraries, etc.)
const stderr = this.stderrBuffer.join("").trim();
if (stderr.length > 0) {
throw new MiniflareCoreError(
"ERR_RUNTIME_FAILURE",
`The Workers runtime failed to start. There was likely a problem with the workerd binary or your configuration.\nRuntime stderr:\n${stderr}`
);
}
}
}

Expand Down Expand Up @@ -246,7 +257,8 @@ export class Runtime {
});
const startupLogBuffer = new StartupLogBuffer();
this.#process = runtimeProcess;
this.#processExitPromise = waitForExit(runtimeProcess);
const processExitPromise = waitForExit(runtimeProcess);
this.#processExitPromise = processExitPromise;

const handleRuntimeStdio =
options.handleRuntimeStdio ??
Expand Down Expand Up @@ -279,8 +291,15 @@ export class Runtime {
runtimeProcess.stdin.end();
await once(runtimeProcess.stdin, "finish");

// 4. Wait for sockets to start listening
const ports = await waitForPorts(controlPipe, options);
// 4. Wait for sockets to start listening, racing against the process
// exiting early. If workerd exits before all required sockets report
// their ports (e.g. due to a bind failure), `waitForPorts()` would
// hang indefinitely. Racing against the exit promise ensures we
// detect this and return `undefined` promptly.
const ports = await Promise.race([
waitForPorts(controlPipe, options),
processExitPromise.then(() => undefined),
]);
if (ports?.has(kInspectorSocket) && process.env.VSCODE_INSPECTOR_OPTIONS) {
// We have an inspector socket and we're in a VSCode Debug Terminal.
// Let's startup a watchdog service to register ourselves as a debuggable target
Expand Down
14 changes: 14 additions & 0 deletions packages/miniflare/test/fixtures/crashing-workerd.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node
// A fake workerd that exits immediately without writing any control messages.
// Used to test that Miniflare detects early workerd exits instead of hanging.

import { arrayBuffer } from "stream/consumers";

// Consume stdin (config passed via stdin) to avoid EPIPE
await arrayBuffer(process.stdin);

// Write an error to stderr to simulate a startup failure
process.stderr.write("error: bind(::1, 0): Address not available\n");

// Exit with non-zero code without writing any listen events to FD3
process.exit(1);
26 changes: 26 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2752,6 +2752,32 @@ unixSerialTest(
}
);

// When workerd exits before sending all listen events on FD3, Miniflare should
// detect this and throw ERR_RUNTIME_FAILURE instead of hanging indefinitely.
// See https://github.com/cloudflare/workers-sdk/issues/14077
unixSerialTest(
"Miniflare: throws ERR_RUNTIME_FAILURE when workerd exits before all sockets are ready",
async ({ expect, onTestFinished }) => {
const workerdPath = path.join(FIXTURES_PATH, "crashing-workerd.mjs");

const original = process.env.MINIFLARE_WORKERD_PATH;
process.env.MINIFLARE_WORKERD_PATH = workerdPath;
onTestFinished(() => {
if (original === undefined) {
delete process.env.MINIFLARE_WORKERD_PATH;
} else {
process.env.MINIFLARE_WORKERD_PATH = original;
}
});

const mf = new Miniflare({ script: "" });
onTestFinished(() => mf.dispose().catch(() => {}));

await expect(mf.ready).rejects.toThrow(MiniflareCoreError);
await expect(mf.ready).rejects.toThrow(/Workers runtime failed to start/);
}
);

const TIMEZONE_WORKER = `
export default {
fetch() {
Expand Down
10 changes: 9 additions & 1 deletion packages/workers-auth/src/auth-config-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
getCloudflareApiEnvironmentFromEnv,
Expand Down Expand Up @@ -52,9 +52,17 @@ export function writeAuthConfigFile(config: UserAuthConfig): void {
mkdirSync(path.dirname(configPath), {
recursive: true,
});
// Write with mode 0o600 on creation and re-`chmod` on every save so
// other local users on shared hosts can't read the OAuth tokens.
// `writeFileSync`'s `mode` option only applies when the file is
// being created — the explicit `chmodSync` ensures that pre-existing
// files (e.g. written by an older Wrangler version with the process
// umask) get tightened on the next save too.
writeFileSync(configPath, TOML.stringify(config), {
encoding: "utf-8",
mode: 0o600,
});
chmodSync(configPath, 0o600);
}

/**
Expand Down
134 changes: 104 additions & 30 deletions packages/workers-auth/src/callback-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import assert from "node:assert";
import http from "node:http";
import url from "node:url";
import { UserError } from "@cloudflare/workers-utils";
import { ErrorAccessDenied, ErrorNoAuthCode } from "./errors";
import { ErrorAccessDenied, ErrorNoAuthCode, ErrorOAuth2 } from "./errors";
import {
exchangeAuthCodeForAccessToken,
getAuthURL,
Expand Down Expand Up @@ -85,6 +85,15 @@ export async function getOauthToken(
function finish(token: AccessContext): void;
function finish(token: AccessContext | null, error?: Error) {
clearTimeout(loginTimeoutHandle);
// Defensive: every code path that calls `finish()` should already
// have written a response, but if not, end the connection so that
// `server.close()` can complete (its callback only fires once all
// open connections have ended). Without this, a future code path
// that forgets to send a response could cause `wrangler login` to
// hang until the OAuth timeout.
if (!res.writableEnded) {
res.end();
}
server.close((closeErr?: Error) => {
if (error || closeErr) {
reject(error || closeErr);
Expand All @@ -95,6 +104,52 @@ export async function getOauthToken(
});
}

function renderErrorPage(detail: {
code?: string;
description?: string;
}): void {
const escape = (s: string) =>
s.replace(
/[&<>"']/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c] as string
);
const codeRow = detail.code
? `<p>Code: <code>${escape(detail.code)}</code></p>`
: "";
const descriptionRow = detail.description
? `<p class="detail">${escape(detail.description)}</p>`
: "";
const body = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Wrangler login failed</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 720px; margin: 4rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.5; }
h1 { color: #c12d3f; }
code { background: #f5f7fa; padding: 0.15em 0.3em; border-radius: 3px; }
p.detail { background: #f5f7fa; padding: 1rem; border-radius: 4px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Wrangler login failed</h1>
<p>The Cloudflare OAuth provider returned an error.</p>
${codeRow}
${descriptionRow}
<p>You can close this tab and return to your terminal for more details.</p>
</body>
</html>`;
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(body);
}

assert(req.url, "This request doesn't have a URL"); // This should never happen
const { pathname, query } = url.parse(req.url, true);
if (req.method !== "GET") {
Expand All @@ -119,46 +174,65 @@ export async function getOauthToken(
);
});

return;
} else {
finish(null, err as Error);
return;
}
const oauthErr = err as ErrorOAuth2;
renderErrorPage({
code: oauthErr.code,
description: oauthErr.description ?? oauthErr.message,
});
finish(null, oauthErr);
return;
}
if (!hasAuthCode) {
// render an error page here
const noCodeMessage =
"The Cloudflare OAuth provider did not return an authorisation code.";
renderErrorPage({ description: noCodeMessage });
finish(
null,
new ErrorNoAuthCode("", {
new ErrorNoAuthCode(noCodeMessage, {
telemetryMessage: "user oauth missing auth code",
})
);
return;
} else {
// `exchangeAuthCodeForAccessToken` can reject (network error,
// invalid JSON, OAuth error response, etc.). Without this
// `try/catch` the rejection would become an unhandled promise
// rejection inside an `http.createServer` callback, which is
// not promise-aware — Node.js >= 15 terminates the process on
// unhandled rejection by default. Route the error through
// `finish` so the caller's promise rejects cleanly.
try {
const exchange = await exchangeAuthCodeForAccessToken(
state,
ctx.logger,
ctx.isNonInteractiveOrCI
);
res.writeHead(307, {
Location: options.granted.url,
});
res.end(() => {
finish(exchange);
});
} catch (err) {
finish(null, err as Error);
}
return;
}
// `exchangeAuthCodeForAccessToken` can reject (network error,
// invalid JSON, OAuth error response, etc.). Without this
// `try/catch` the rejection would become an unhandled promise
// rejection inside an `http.createServer` callback, which is
// not promise-aware — Node.js >= 15 terminates the process on
// unhandled rejection by default. Route the error through
// `finish` so the caller's promise rejects cleanly.
try {
const exchange = await exchangeAuthCodeForAccessToken(
state,
ctx.logger,
ctx.isNonInteractiveOrCI
);
res.writeHead(307, {
Location: options.granted.url,
});
res.end(() => {
finish(exchange);
});
} catch (err: unknown) {
// `exchangeAuthCodeForAccessToken` can throw an `ErrorOAuth2`
// (for provider-side errors), or a plain `Error` (for JSON
// parse failures in `getJSONFromResponse`, network errors
// from `fetchAuthToken`, etc.). Only read the structured
// OAuth fields when we know we have them.
const exchangeErr = err as Error;
const isOAuthError = exchangeErr instanceof ErrorOAuth2;
renderErrorPage({
code: isOAuthError ? exchangeErr.code : undefined,
description:
(isOAuthError ? exchangeErr.description : undefined) ??
exchangeErr.message ??
"Failed to exchange the authorisation code for an access token.",
});
finish(null, exchangeErr);
}
return;
}
}
});
Expand Down
Loading
Loading