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
7 changes: 7 additions & 0 deletions .changeset/skip-stale-bundles-during-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Skip stale bundles during dev server reload to avoid redundant restarts

When rapidly saving a wrangler config file with remote bindings, each save would trigger a full reload cycle (remote connection setup, miniflare restart), causing many sequential "Reloading local server... / Establishing remote connection..." messages (while blocking the user). The runtime controllers now check whether a newer bundle has been queued at each expensive async boundary and bail out early if the current bundle is stale. This ensures that only the latest config change triggers a reload, making `wrangler dev` much more responsive during repeated config edits.
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,69 @@ describe("LocalRuntimeController", () => {
res = await fetch(urlFromParts(event.proxyData.userWorkerUrl));
expect(await res.json()).toEqual({ binding: 5, bundle: 5 });
});
it("should skip stale bundles and only reload once for rapid updates", async ({
expect,
}) => {
const bus = new FakeBus();
const controller = new LocalRuntimeController(bus);
teardown(() => controller.teardown());

function update(version: number) {
const config = {
name: "worker",
entrypoint: "NOT_REAL",
bindings: {
VERSION: { type: "json", value: version },
},
} satisfies Partial<StartDevWorkerOptions>;
const bundle = makeEsbuildBundle(dedent /*javascript*/ `
export default {
fetch(request, env, ctx) {
return Response.json({ binding: env.VERSION, bundle: ${version} });
}
}
`);
controller.onBundleStart({
type: "bundleStart",
config: configDefaults(config),
});
controller.onBundleComplete({
type: "bundleComplete",
config: configDefaults(config),
bundle,
});
}

// Start worker with initial version
update(1);
await bus.waitFor("reloadComplete");

// Record events before rapid updates
const eventsBefore = bus.events.length;

// Fire many rapid updates — simulates repeated config file saves
update(2);
update(3);
update(4);
update(5);
update(6);

// Wait for the final reloadComplete
const event = await bus.waitFor("reloadComplete");
const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl));
expect(await res.json()).toEqual({ binding: 6, bundle: 6 });

// Give any stale bundles time to flush through the mutex
await new Promise((resolve) => setTimeout(resolve, 500));

// Count how many reloadComplete events were emitted after our rapid
// updates. Stale bundles should bail out early, so we expect exactly
// one reloadComplete for the final (winning) bundle.
const reloadCompleteEvents = bus.events
.slice(eventsBefore)
.filter((e) => e.type === "reloadComplete");
expect(reloadCompleteEvents).toHaveLength(1);
});
it("should start Miniflare with configured compatibility settings", async ({
expect,
}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
import dedent from "ts-dedent";
import { fetch } from "undici";
import { describe, it } from "vitest";
import { MultiworkerRuntimeController } from "../../../api/startDevWorker/MultiworkerRuntimeController";
import { urlFromParts } from "../../../api/startDevWorker/utils";
import { FakeBus } from "../../helpers/fake-bus";
import { mockConsoleMethods } from "../../helpers/mock-console";
import { useTeardown } from "../../helpers/teardown";
import { unusable } from "../../helpers/unusable";
import type { Bundle, StartDevWorkerOptions } from "../../../api";

function makeEsbuildBundle(testBundle: string): Bundle {
return {
type: "esm",
modules: [],
id: 0,
path: "/virtual/index.mjs",
entrypointSource: testBundle,
entry: {
file: "index.mjs",
projectRoot: "/virtual/",
configPath: undefined,
format: "modules",
moduleRoot: "/virtual",
name: undefined,
exports: [],
},
dependencies: {},
sourceMapPath: undefined,
sourceMapMetadata: undefined,
};
}

function configDefaults(
config: Partial<StartDevWorkerOptions>
): StartDevWorkerOptions {
return {
name: "test-worker",
compatibilityDate: "2025-10-10",
complianceRegion: undefined,
entrypoint: "NOT_REAL",
projectRoot: "NOT_REAL",
build: unusable<StartDevWorkerOptions["build"]>(),
legacy: {},
dev: { persist: "./persist", remote: false },
...config,
};
}

describe("MultiworkerRuntimeController", () => {
mockConsoleMethods();
runInTempDir();
const teardown = useTeardown();

describe("stale bundle bail-out", () => {
it("should not bail out when different workers submit bundles", async ({
expect,
}) => {
const bus = new FakeBus();
const controller = new MultiworkerRuntimeController(bus, 2);
teardown(() => controller.teardown());

function makeWorkerConfig(name: string, primary: boolean) {
return configDefaults({
name,
entrypoint: "NOT_REAL",
dev: {
persist: "./persist",
remote: false,
multiworkerPrimary: primary,
},
});
}

function makeWorkerBundle(name: string) {
return makeEsbuildBundle(dedent /*javascript*/ `
export default {
fetch(request, env, ctx) {
return new Response("hello from ${name}");
}
}
`);
}

// Submit bundles for both workers — the key scenario that was
// broken: worker B's onBundleComplete would invalidate worker A's
// in-flight processing because they shared a single counter.
const configA = makeWorkerConfig("worker-a", true);
const configB = makeWorkerConfig("worker-b", false);

controller.onBundleStart({ type: "bundleStart", config: configA });
controller.onBundleComplete({
type: "bundleComplete",
config: configA,
bundle: makeWorkerBundle("worker-a"),
});

controller.onBundleStart({ type: "bundleStart", config: configB });
controller.onBundleComplete({
type: "bundleComplete",
config: configB,
bundle: makeWorkerBundle("worker-b"),
});

// Both workers should have their options stored and Miniflare
// should start — resulting in a reloadComplete event.
const event = await bus.waitFor("reloadComplete");
const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl));
expect(await res.text()).toContain("hello from");
});

it("should skip stale bundles for the same worker during rapid updates", async ({
expect,
}) => {
const bus = new FakeBus();
const controller = new MultiworkerRuntimeController(bus, 2);
teardown(() => controller.teardown());

function makeWorkerConfig(
name: string,
primary: boolean,
version?: number
) {
return configDefaults({
name,
entrypoint: "NOT_REAL",
bindings: version
? { VERSION: { type: "json", value: version } }
: undefined,
dev: {
persist: "./persist",
remote: false,
multiworkerPrimary: primary,
},
});
}

function makeWorkerBundle(name: string, version?: number) {
const body = version
? `Response.json({ name: "${name}", version: ${version} })`
: `new Response("hello from ${name}")`;
return makeEsbuildBundle(dedent /*javascript*/ `
export default {
fetch(request, env, ctx) {
return ${body};
}
}
`);
}

// Initial setup: both workers
const configA = makeWorkerConfig("worker-a", true, 1);
const configB = makeWorkerConfig("worker-b", false);

controller.onBundleStart({ type: "bundleStart", config: configA });
controller.onBundleComplete({
type: "bundleComplete",
config: configA,
bundle: makeWorkerBundle("worker-a", 1),
});
controller.onBundleStart({ type: "bundleStart", config: configB });
controller.onBundleComplete({
type: "bundleComplete",
config: configB,
bundle: makeWorkerBundle("worker-b"),
});

await bus.waitFor("reloadComplete");

// Record events before rapid updates
const eventsBefore = bus.events.length;

// Fire rapid updates for worker-a only — simulates repeated
// config saves for a single worker in a multiworker setup.
for (let v = 2; v <= 6; v++) {
const config = makeWorkerConfig("worker-a", true, v);
controller.onBundleStart({ type: "bundleStart", config });
controller.onBundleComplete({
type: "bundleComplete",
config,
bundle: makeWorkerBundle("worker-a", v),
});
}

// Wait for the final reloadComplete
const event = await bus.waitFor("reloadComplete");
const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl));
const json = (await res.json()) as { name: string; version: number };
expect(json).toEqual({ name: "worker-a", version: 6 });

// Give stale bundles time to flush through the mutex
await new Promise((resolve) => setTimeout(resolve, 500));

// Stale bundles should bail out early — only one reloadComplete
const reloadCompleteEvents = bus.events
.slice(eventsBefore)
.filter((e) => e.type === "reloadComplete");
expect(reloadCompleteEvents).toHaveLength(1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,50 @@ describe("RemoteRuntimeController", () => {
vi.mocked(getAccessHeaders).mockResolvedValue({});
});

describe("stale bundle bail-out", () => {
it("should skip stale bundles and only reload once for rapid updates", async ({
expect,
}) => {
const { controller, bus } = setup();
const config = makeConfig();
const bundle = makeBundle();

// Initial bundle
controller.onBundleStart({ type: "bundleStart", config });
controller.onBundleComplete({ type: "bundleComplete", config, bundle });
await bus.waitFor("reloadComplete");

// Record events before rapid updates
const eventsBefore = bus.events.length;
vi.mocked(createWorkerPreview).mockClear();

// Fire many rapid updates
for (let i = 0; i < 5; i++) {
controller.onBundleStart({ type: "bundleStart", config });
controller.onBundleComplete({
type: "bundleComplete",
config,
bundle,
});
}

// Wait for the final reloadComplete
await bus.waitFor("reloadComplete");

// Give stale bundles time to flush through the mutex
await new Promise((resolve) => setTimeout(resolve, 500));

// Stale bundles should bail out early — only one reloadComplete
const reloadCompleteEvents = bus.events
.slice(eventsBefore)
.filter((e) => e.type === "reloadComplete");
expect(reloadCompleteEvents).toHaveLength(1);

// The API should only be called once (for the winning bundle)
expect(createWorkerPreview).toHaveBeenCalledTimes(1);
});
});

describe("proactive token refresh", () => {
afterEach(() => vi.useRealTimers());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ export class LocalRuntimeController extends RuntimeController {

async #onBundleComplete(data: BundleCompleteEvent, id: number) {
try {
// A newer bundle has already been queued — skip this stale one
// before doing any expensive work.
if (id !== this.#currentBundleId) {
return;
}

const configBundle = await convertToConfigBundle(data);

if (data.config.dev?.remote !== false) {
Expand All @@ -293,6 +299,12 @@ export class LocalRuntimeController extends RuntimeController {
);
}

// Bail out if a newer bundle arrived while we were setting up
// the remote proxy session.
if (id !== this.#currentBundleId) {
return;
}

// Assemble container options and build if necessary

if (
Expand Down Expand Up @@ -348,6 +360,12 @@ export class LocalRuntimeController extends RuntimeController {
logger.log(chalk.dim("⎔ Container image(s) ready"));
}

// Bail out if a newer bundle arrived while we were building
// container images.
if (id !== this.#currentBundleId) {
return;
}

const options = await MF.buildMiniflareOptions(
this.#log,
configBundle,
Expand All @@ -362,6 +380,13 @@ export class LocalRuntimeController extends RuntimeController {
}
);
options.liveReload = false; // TODO: set in buildMiniflareOptions once old code path is removed

// Bail out if a newer bundle arrived while we were building
// miniflare options — avoid a redundant local server reload.
if (id !== this.#currentBundleId) {
return;
}

if (this.#mf === undefined) {
logger.log(chalk.dim("⎔ Starting local server..."));
this.#mf = new Miniflare(options);
Expand Down
Loading
Loading