Skip to content

Commit 4fda685

Browse files
Revert "Revert "[wrangler] fix: prevent remote binding sessions expiring during long dev sessions"" (#13470)
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
1 parent 0444fdd commit 4fda685

9 files changed

Lines changed: 215 additions & 88 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"wrangler": patch
3+
"miniflare": patch
4+
---
5+
6+
fix: prevent remote binding sessions from expiring during long-running dev sessions
7+
8+
Preview tokens for remote bindings expire after one hour. Previously, the first request after expiry would fail before a refresh was triggered. This change proactively refreshes the token at 50 minutes so no request ever sees an expired session.
9+
10+
The reactive recovery path is also improved: `error code: 1031` responses (returned by bindings such as Workers AI when their session times out) now correctly trigger a refresh, where previously only `Invalid Workers Preview configuration` HTML responses did.
11+
12+
Auth credentials are now resolved lazily when a remote proxy session starts rather than at bundle-complete time. This means that if your OAuth access token has been refreshed since `wrangler dev` started, the new token is used rather than the one captured at startup.

packages/wrangler/e2e/start-worker-auth-opts.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe("startWorker - auth options", { sequential: true }, () => {
8484

8585
await assertValidWorkerAiResponse(expect);
8686

87-
expect(validAuth).toHaveBeenCalledOnce();
87+
expect(validAuth).toHaveBeenCalled();
8888

8989
consoleErrorMock.mockReset();
9090

@@ -105,7 +105,7 @@ describe("startWorker - auth options", { sequential: true }, () => {
105105

106106
await assertInvalidWorkerAiResponse(expect);
107107

108-
expect(incorrectAuth).toHaveBeenCalledOnce();
108+
expect(incorrectAuth).toHaveBeenCalled();
109109
});
110110

111111
test("starting a worker with startWorker with invalid auth information and updating it with valid auth information", async ({
@@ -139,7 +139,7 @@ describe("startWorker - auth options", { sequential: true }, () => {
139139

140140
await assertInvalidWorkerAiResponse(expect);
141141

142-
expect(incorrectAuth).toHaveBeenCalledOnce();
142+
expect(incorrectAuth).toHaveBeenCalled();
143143

144144
consoleErrorMock.mockReset();
145145

@@ -162,7 +162,7 @@ describe("startWorker - auth options", { sequential: true }, () => {
162162

163163
await assertValidWorkerAiResponse(expect);
164164

165-
expect(validAuth).toHaveBeenCalledOnce();
165+
expect(validAuth).toHaveBeenCalled();
166166
});
167167

168168
async function assertValidWorkerAiResponse(expect: ExpectStatic) {

packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { beforeEach, describe, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, it, vi } from "vitest";
22
import { RemoteRuntimeController } from "../../../api/startDevWorker/RemoteRuntimeController";
3-
import { unwrapHook } from "../../../api/startDevWorker/utils";
43
// Import the mocked functions so we can set their behavior
54
import {
65
createPreviewSession,
@@ -38,10 +37,6 @@ vi.mock("../../../user/access", () => ({
3837
domainUsesAccess: vi.fn(),
3938
}));
4039

41-
vi.mock("../../../api/startDevWorker/utils", () => ({
42-
unwrapHook: vi.fn(),
43-
}));
44-
4540
function makeConfig(
4641
overrides: Partial<StartDevWorkerOptions> = {}
4742
): StartDevWorkerOptions {
@@ -103,12 +98,6 @@ describe("RemoteRuntimeController", () => {
10398
}
10499

105100
beforeEach(() => {
106-
// Setup mock implementations
107-
vi.mocked(unwrapHook).mockResolvedValue({
108-
accountId: "test-account-id",
109-
apiToken: { apiToken: "test-token" },
110-
});
111-
112101
vi.mocked(getWorkerAccountAndContext).mockResolvedValue({
113102
workerAccount: {
114103
accountId: "test-account-id",
@@ -159,12 +148,106 @@ describe("RemoteRuntimeController", () => {
159148
vi.mocked(createWorkerPreview).mockResolvedValue({
160149
value: "test-preview-token",
161150
host: "test.workers.dev",
162-
tailUrl: "wss://test.workers.dev/tail",
151+
// No tailUrl — avoids real WebSocket connections in unit tests
163152
});
164153

165154
vi.mocked(getAccessHeaders).mockResolvedValue({});
166155
});
167156

157+
describe("proactive token refresh", () => {
158+
afterEach(() => vi.useRealTimers());
159+
160+
it("should proactively refresh the token before expiry", async ({
161+
expect,
162+
}) => {
163+
vi.useFakeTimers();
164+
165+
const { controller, bus } = setup();
166+
const config = makeConfig();
167+
const bundle = makeBundle();
168+
169+
controller.onBundleStart({ type: "bundleStart", config });
170+
controller.onBundleComplete({ type: "bundleComplete", config, bundle });
171+
await bus.waitFor("reloadComplete");
172+
173+
vi.mocked(createWorkerPreview).mockClear();
174+
vi.mocked(createRemoteWorkerInit).mockClear();
175+
vi.mocked(createWorkerPreview).mockResolvedValue({
176+
value: "proactively-refreshed-token",
177+
host: "test.workers.dev",
178+
});
179+
180+
// Register the waiter before advancing so it's in place when the
181+
// event fires. Use a timeout larger than the advance window so the
182+
// waiter's own faked setTimeout doesn't race the refresh timer.
183+
const reloadPromise = bus.waitFor(
184+
"reloadComplete",
185+
undefined,
186+
60 * 60 * 1000
187+
);
188+
await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1);
189+
const reloadEvent = await reloadPromise;
190+
191+
expect(createWorkerPreview).toHaveBeenCalledTimes(1);
192+
expect(reloadEvent).toMatchObject({
193+
type: "reloadComplete",
194+
proxyData: {
195+
headers: {
196+
"cf-workers-preview-token": "proactively-refreshed-token",
197+
},
198+
},
199+
});
200+
});
201+
202+
it("should cancel the proactive refresh timer on bundle start", async ({
203+
expect,
204+
}) => {
205+
vi.useFakeTimers();
206+
207+
const { controller, bus } = setup();
208+
const config = makeConfig();
209+
const bundle = makeBundle();
210+
211+
controller.onBundleStart({ type: "bundleStart", config });
212+
controller.onBundleComplete({ type: "bundleComplete", config, bundle });
213+
await bus.waitFor("reloadComplete");
214+
215+
vi.mocked(createWorkerPreview).mockClear();
216+
217+
// A new bundleStart cancels the old timer before it fires
218+
controller.onBundleStart({ type: "bundleStart", config });
219+
controller.onBundleComplete({ type: "bundleComplete", config, bundle });
220+
await bus.waitFor("reloadComplete");
221+
222+
vi.mocked(createWorkerPreview).mockClear();
223+
224+
// Advance to just before T2 would fire — no proactive refresh should occur
225+
await vi.advanceTimersByTimeAsync(50 * 60 * 1000 - 1);
226+
expect(createWorkerPreview).not.toHaveBeenCalled();
227+
});
228+
229+
it("should cancel the proactive refresh timer on teardown", async ({
230+
expect,
231+
}) => {
232+
vi.useFakeTimers();
233+
234+
const { controller, bus } = setup();
235+
const config = makeConfig();
236+
const bundle = makeBundle();
237+
238+
controller.onBundleStart({ type: "bundleStart", config });
239+
controller.onBundleComplete({ type: "bundleComplete", config, bundle });
240+
await bus.waitFor("reloadComplete");
241+
242+
vi.mocked(createWorkerPreview).mockClear();
243+
await controller.teardown();
244+
245+
// Advance past where the timer would have fired
246+
await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1);
247+
expect(createWorkerPreview).not.toHaveBeenCalled();
248+
});
249+
});
250+
168251
describe("preview token refresh", () => {
169252
it("should handle missing state gracefully", async ({ expect }) => {
170253
const { controller } = setup();

packages/wrangler/src/__tests__/dev/remote-bindings.test.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/consistent-type-imports */
2+
import assert from "node:assert";
23
import { seed } from "@cloudflare/workers-utils/test-helpers";
34
import { fetch } from "undici";
45
/* eslint-disable no-restricted-imports */
@@ -13,6 +14,7 @@ import {
1314
} from "vitest";
1415
/* eslint-enable no-restricted-imports */
1516
import { Binding, StartRemoteProxySessionOptions } from "../../api";
17+
import { unwrapHook } from "../../api/startDevWorker/utils";
1618
import { mockAccountId, mockApiToken } from "../helpers/mock-account-id";
1719
import { mockConsoleMethods } from "../helpers/mock-console";
1820
import {
@@ -714,16 +716,18 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => {
714716
await vi.waitFor(() => expect(std.out).toMatch(/Ready/), {
715717
timeout: 5_000,
716718
});
717-
expect(sessionOptions).toEqual({
718-
auth: {
719-
accountId: "some-account-id",
720-
apiToken: {
721-
apiToken: "some-api-token",
722-
},
723-
},
719+
expect(sessionOptions).toBeDefined();
720+
assert(sessionOptions);
721+
const { auth, ...rest1 } = sessionOptions;
722+
expect(rest1).toEqual({
724723
complianceRegion: undefined,
725724
workerName: "worker",
726725
});
726+
assert(auth);
727+
expect(await unwrapHook(auth, { account_id: undefined })).toEqual({
728+
accountId: "some-account-id",
729+
apiToken: { apiToken: "some-api-token" },
730+
});
727731
await stopWrangler();
728732
await wranglerStopped;
729733
});
@@ -756,16 +760,18 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => {
756760
timeout: 5_000,
757761
});
758762

759-
expect(sessionOptions).toEqual({
760-
auth: {
761-
accountId: "mock-account-id",
762-
apiToken: {
763-
apiToken: "some-api-token",
764-
},
765-
},
763+
expect(sessionOptions).toBeDefined();
764+
assert(sessionOptions);
765+
const { auth: auth2, ...rest2 } = sessionOptions;
766+
expect(rest2).toEqual({
766767
complianceRegion: undefined,
767768
workerName: "worker",
768769
});
770+
assert(auth2);
771+
expect(await unwrapHook(auth2, { account_id: undefined })).toEqual({
772+
accountId: "mock-account-id",
773+
apiToken: { apiToken: "some-api-token" },
774+
});
769775

770776
await stopWrangler();
771777

packages/wrangler/src/api/remoteBindings/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ export async function maybeStartOrUpdateRemoteProxySession(
8282
preExistingRemoteProxySessionData?: {
8383
session: RemoteProxySession;
8484
remoteBindings: Record<string, Binding>;
85-
auth?: CfAccount | undefined;
85+
auth?: AsyncHook<CfAccount> | undefined;
8686
} | null,
87-
auth?: CfAccount | undefined
87+
auth?: AsyncHook<CfAccount> | undefined
8888
): Promise<{
8989
session: RemoteProxySession;
9090
remoteBindings: Record<string, Binding>;
@@ -188,9 +188,9 @@ export async function maybeStartOrUpdateRemoteProxySession(
188188
* @returns the auth hook to pass to the startRemoteProxy session function if any
189189
*/
190190
function getAuthHook(
191-
auth: CfAccount | undefined,
191+
auth: AsyncHook<CfAccount> | undefined,
192192
config: Pick<Config, "account_id"> | undefined
193-
): AsyncHook<CfAccount, [Pick<Config, "account_id">]> | undefined {
193+
): AsyncHook<CfAccount> | undefined {
194194
if (auth) {
195195
return auth;
196196
}

packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import * as MF from "../../dev/miniflare";
1414
import { logger } from "../../logger";
1515
import { RuntimeController } from "./BaseController";
1616
import { castErrorCause } from "./events";
17-
import { getBinaryFileContents, unwrapHook } from "./utils";
17+
import { getBinaryFileContents } from "./utils";
1818
import type { RemoteProxySession } from "../remoteBindings";
1919
import type {
2020
BundleCompleteEvent,
@@ -209,12 +209,6 @@ export class LocalRuntimeController extends RuntimeController {
209209

210210
const remoteBindings = pickRemoteBindings(configBundle.bindings ?? {});
211211

212-
const auth =
213-
Object.keys(remoteBindings).length === 0
214-
? // If there are no remote bindings (this is a local only session) there's no need to get auth data
215-
undefined
216-
: await unwrapHook(data.config.dev.auth);
217-
218212
this.#remoteProxySessionData =
219213
await maybeStartOrUpdateRemoteProxySession(
220214
{
@@ -223,7 +217,10 @@ export class LocalRuntimeController extends RuntimeController {
223217
bindings: remoteBindings,
224218
},
225219
this.#remoteProxySessionData ?? null,
226-
auth
220+
Object.keys(remoteBindings).length === 0
221+
? // If there are no remote bindings (this is a local only session) there's no need to get auth data
222+
undefined
223+
: data.config.dev.auth
227224
);
228225
}
229226

0 commit comments

Comments
 (0)