Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 6e0926d

Browse files
authored
[Miniflare 3] Re-enable concurrent dispatchFetch()s and batch proxy heap frees (#713)
* Re-enable concurrent `dispatchFetch()`s and batch proxy heap frees In order to fix tests when adding the magic proxy, we restricted `dispatchFetch()` to one concurrent TCP connection. Unfortunately, this prevented long-lived `dispatchFetch()` requests. When proxies are garbage collected, we send a network request to `workerd` to free the corresponding entry in the `ProxyServer` heap. Garbage collection often happens in phases though, freeing lots of objects at once. This caused many concurrent requests to `workerd` (~60 in some tests), leading to many TCP connections being created. This change re-enables using multiple TCP connections, and attempts to address the root cause of the issues we were seeing, by batching frees before sending the network request. * Re-enable parallel tests
1 parent ff17f0c commit 6e0926d

File tree

4 files changed

+40
-19
lines changed

4 files changed

+40
-19
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"lint:fix": "npm run lint -- --fix",
2828
"prepublishOnly": "npm run lint && npm run clean && npm run build && npm run types:bundle && npm run test",
2929
"release": "./scripts/release.sh",
30-
"test": "npm run build && ava --serial && rimraf ./.tmp",
30+
"test": "npm run build && ava && rimraf ./.tmp",
3131
"types:build": "tsc && tsc -p packages/miniflare/src/workers/tsconfig.json",
3232
"types:bundle": "npm run types:build && node scripts/types.mjs"
3333
},

packages/miniflare/src/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type {
2222
import exitHook from "exit-hook";
2323
import { $ as colors$ } from "kleur/colors";
2424
import stoppable from "stoppable";
25-
import { Client } from "undici";
25+
import { Dispatcher, Pool } from "undici";
2626
import SCRIPT_MINIFLARE_SHARED from "worker:shared/index";
2727
import SCRIPT_MINIFLARE_ZOD from "worker:shared/zod";
2828
import { WebSocketServer } from "ws";
@@ -557,7 +557,7 @@ export class Miniflare {
557557
readonly #removeRuntimeExitHook?: () => void;
558558
#runtimeEntryURL?: URL;
559559
#socketPorts?: SocketPorts;
560-
#runtimeClient?: Client;
560+
#runtimeDispatcher?: Dispatcher;
561561
#proxyClient?: ProxyClient;
562562

563563
// Path to temporary directory for use as scratch space/"in-memory" Durable
@@ -1137,10 +1137,10 @@ export class Miniflare {
11371137
`${secure ? "https" : "http"}://${accessibleHost}:${entryPort}`
11381138
);
11391139
if (previousEntryURL?.toString() !== this.#runtimeEntryURL.toString()) {
1140-
this.#runtimeClient = new Client(this.#runtimeEntryURL, {
1140+
this.#runtimeDispatcher = new Pool(this.#runtimeEntryURL, {
11411141
connect: { rejectUnauthorized: false },
11421142
});
1143-
registerAllowUnauthorizedDispatcher(this.#runtimeClient);
1143+
registerAllowUnauthorizedDispatcher(this.#runtimeDispatcher);
11441144
}
11451145
if (this.#proxyClient === undefined) {
11461146
this.#proxyClient = new ProxyClient(
@@ -1291,7 +1291,7 @@ export class Miniflare {
12911291
await this.ready;
12921292

12931293
assert(this.#runtimeEntryURL !== undefined);
1294-
assert(this.#runtimeClient !== undefined);
1294+
assert(this.#runtimeDispatcher !== undefined);
12951295

12961296
const forward = new Request(input, init);
12971297
const url = new URL(forward.url);
@@ -1313,7 +1313,7 @@ export class Miniflare {
13131313
}
13141314

13151315
const forwardInit = forward as RequestInit;
1316-
forwardInit.dispatcher = this.#runtimeClient;
1316+
forwardInit.dispatcher = this.#runtimeDispatcher;
13171317
const response = await fetch(url, forwardInit);
13181318

13191319
// If the Worker threw an uncaught exception, propagate it to the caller

packages/miniflare/src/plugins/core/proxy/client.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ class ProxyClientBridge {
119119
// as the references will be invalid, and a new object with the same address
120120
// may be added to the "heap".
121121
readonly #finalizationRegistry: FinalizationRegistry<NativeTargetHeldValue>;
122+
// Garbage collection passes will free lots of objects at once. Rather than
123+
// sending a `DELETE` request for each address, we batch finalisations within
124+
// 100ms of each other into one request. This ensures we don't create *lots*
125+
// of TCP connections to `workerd` in `dispatchFetch()` for all the concurrent
126+
// requests.
127+
readonly #finalizeBatch: NativeTargetHeldValue[] = [];
128+
#finalizeBatchTimeout?: NodeJS.Timeout;
129+
122130
readonly sync = new SynchronousFetcher();
123131

124132
constructor(public url: URL, readonly dispatchFetch: DispatchFetch) {
@@ -129,26 +137,37 @@ class ProxyClientBridge {
129137
return this.#version;
130138
}
131139

132-
#finalizeProxy = async (held: NativeTargetHeldValue) => {
133-
// Sanity check: make sure the proxy hasn't been poisoned. We should
134-
// unregister all proxies from the finalisation registry when poisoning,
135-
// but it doesn't hurt to be careful.
136-
if (held.version !== this.#version) return;
137-
140+
#finalizeProxy = (held: NativeTargetHeldValue) => {
138141
// Called when the `Proxy` with address `targetAddress` gets garbage
139142
// collected. This removes the target from the `ProxyServer` "heap".
143+
this.#finalizeBatch.push(held);
144+
clearTimeout(this.#finalizeBatchTimeout);
145+
this.#finalizeBatchTimeout = setTimeout(this.#finalizeProxyBatch, 100);
146+
};
147+
148+
#finalizeProxyBatch = async () => {
149+
const addresses: number[] = [];
150+
for (const held of this.#finalizeBatch.splice(0)) {
151+
// Sanity check: make sure the proxy hasn't been poisoned. We should
152+
// unregister all proxies from the finalisation registry when poisoning,
153+
// but it doesn't hurt to be careful.
154+
if (held.version === this.#version) addresses.push(held.address);
155+
}
156+
// If there are no addresses to free, we don't need to send a request
157+
if (addresses.length === 0) return;
140158
try {
141159
await this.dispatchFetch(this.url, {
142160
method: "DELETE",
143161
headers: {
144162
[CoreHeaders.OP]: ProxyOps.FREE,
145-
[CoreHeaders.OP_TARGET]: held.address.toString(),
163+
[CoreHeaders.OP_TARGET]: addresses.join(","),
146164
},
147165
});
148166
} catch {
149167
// Ignore network errors when freeing. If this `dispatchFetch()` throws,
150168
// it's likely the runtime has shutdown, so the entire "heap" has been
151-
// destroyed anyway.
169+
// destroyed anyway. There's a small chance of a memory leak here if this
170+
// threw for another reason.
152171
}
153172
};
154173

packages/miniflare/src/workers/core/proxy.worker.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,13 @@ export class ProxyServer implements DurableObject {
140140
// Get target to perform operations on
141141
if (targetHeader === null) return new Response(null, { status: 400 });
142142

143-
// If this is a FREE operation, remove the target from the heap
143+
// If this is a FREE operation, remove the target(s) from the heap
144144
if (opHeader === ProxyOps.FREE) {
145-
const targetAddress = parseInt(targetHeader);
146-
assert(!Number.isNaN(targetAddress));
147-
this.heap.delete(targetAddress);
145+
for (const targetValue of targetHeader.split(",")) {
146+
const targetAddress = parseInt(targetValue);
147+
assert(!Number.isNaN(targetAddress));
148+
this.heap.delete(targetAddress);
149+
}
148150
return new Response(null, { status: 204 });
149151
}
150152

0 commit comments

Comments
 (0)