From f05a199b628730d2d42f5b7e3928c56388654887 Mon Sep 17 00:00:00 2001 From: deimantas Jakovlevas Date: Mon, 27 Apr 2026 21:12:49 +0700 Subject: [PATCH 1/2] Defer endpoint release while requests are in flight --- karma.conf.js | 20 ++++++++++++----- package.json | 1 + src/comlink.ts | 20 ++++++++++++++++- tests/same_window.comlink.test.js | 36 +++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index c7840f40..eabdfa5d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -44,22 +44,32 @@ module.exports = function (config) { if (process.env.INSIDE_DOCKER) { return ["DockerChrome"]; } else if (process.env.CHROME_ONLY) { - return ["ChromeHeadless"]; + return ["ChromeHeadlessGC"]; } else { // Filtering SafariTechPreview because I am having // local issues and I have no idea how to fix them. // I know that’s not a good reason to disable tests, // but Safari TP is relatively unimportant. - return availableBrowsers.filter( - (browser) => browser !== "SafariTechPreview" - ); + return availableBrowsers + .filter((browser) => browser !== "SafariTechPreview") + .map((browser) => + browser === "ChromeHeadless" ? "ChromeHeadlessGC" : browser + ); } }, }, customLaunchers: { DockerChrome: { base: "ChromeHeadless", - flags: ["--no-sandbox"], + flags: ["--no-sandbox", "--js-flags=--expose-gc"], + }, + ChromeHeadlessGC: { + base: "ChromeHeadless", + flags: ["--js-flags=--expose-gc"], + }, + ChromeHeadlessGC: { + base: "ChromeHeadless", + flags: ["--js-flags=--expose-gc"], }, }, }; diff --git a/package.json b/package.json index 8c76c654..efc20637 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "rollup -c", "test:unit": "karma start", + "test:unit:gc": "karma start --browsers ChromeHeadlessGC", "test:node": "mocha ./tests/node/main.mjs", "test:types": "tsc -p ./tests/tsconfig.json", "test:types:watch": "npm run test:types -- --watch", diff --git a/src/comlink.ts b/src/comlink.ts index 27c13694..60e891a0 100644 --- a/src/comlink.ts +++ b/src/comlink.ts @@ -234,6 +234,7 @@ type PendingListenersMap = Map< type EndpointWithPendingListeners = { endpoint: Endpoint; pendingListeners: PendingListenersMap; + releaseDeferred?: boolean; }; /** @@ -401,6 +402,10 @@ function closeEndPoint(endpoint: Endpoint) { export function wrap(ep: Endpoint, target?: any): Remote { const pendingListeners : PendingListenersMap = new Map(); + const epWithPendingListeners: EndpointWithPendingListeners = { + endpoint: ep, + pendingListeners, + }; ep.addEventListener("message", function handleMessage(ev: Event) { const { data } = ev as MessageEvent; @@ -416,10 +421,19 @@ export function wrap(ep: Endpoint, target?: any): Remote { resolver(data); } finally { pendingListeners.delete(data.id); + if ( + epWithPendingListeners.releaseDeferred && + pendingListeners.size === 0 + ) { + epWithPendingListeners.releaseDeferred = false; + releaseEndpoint(epWithPendingListeners).finally(() => { + pendingListeners.clear(); + }); + } } }); - return createProxy({ endpoint: ep, pendingListeners }, [], target) as any; + return createProxy(epWithPendingListeners, [], target) as any; } function throwIfProxyReleased(isReleased: boolean) { @@ -455,6 +469,10 @@ const proxyFinalizers = const newCount = (proxyCounter.get(epWithPendingListeners) || 0) - 1; proxyCounter.set(epWithPendingListeners, newCount); if (newCount === 0) { + if (epWithPendingListeners.pendingListeners.size > 0) { + epWithPendingListeners.releaseDeferred = true; + return; + } releaseEndpoint(epWithPendingListeners).finally(() => { epWithPendingListeners.pendingListeners.clear(); }); diff --git a/tests/same_window.comlink.test.js b/tests/same_window.comlink.test.js index 9fea1292..312c5afc 100644 --- a/tests/same_window.comlink.test.js +++ b/tests/same_window.comlink.test.js @@ -598,6 +598,42 @@ describe("Comlink in the same realm", function () { expect(finalized).to.be.true; }); + it("in-flight requests should not be dropped when proxy is GC'd mid-await", async function () { + if (typeof globalThis.gc !== "function") this.skip(); + this.timeout(15000); + + const hammer = setInterval(() => globalThis.gc(), 50); + const garbage = []; + const pressure = setInterval(() => { + garbage.push(new Uint8Array(2_000_000)); + if (garbage.length > 20) garbage.length = 0; + }, 50); + + try { + for (let i = 0; i < 20; i++) { + const { port1, port2 } = new MessageChannel(); + port1.start(); + port2.start(); + Comlink.expose( + { + slow: () => new Promise((r) => setTimeout(() => r("ok"), 100)), + }, + port2 + ); + const result = await Promise.race([ + Comlink.wrap(port1).slow(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`call ${i} hung — port closed mid-await`)), 2000) + ), + ]); + expect(result).to.equal("ok"); + } + } finally { + clearInterval(hammer); + clearInterval(pressure); + } + }); + // commented out this test as it could be unreliable in various browsers as // it has to wait for GC to kick in which could happen at any timing // this does seem to work when testing locally From b5b435fe1ee6b268c4f08b059191ee149e6a0394 Mon Sep 17 00:00:00 2001 From: deimantas Jakovlevas Date: Mon, 27 Apr 2026 21:28:17 +0700 Subject: [PATCH 2/2] remove unused testing code --- karma.conf.js | 18 ++++-------------- package.json | 1 - 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index eabdfa5d..7cfaea9f 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -44,17 +44,15 @@ module.exports = function (config) { if (process.env.INSIDE_DOCKER) { return ["DockerChrome"]; } else if (process.env.CHROME_ONLY) { - return ["ChromeHeadlessGC"]; + return ["ChromeHeadless"]; } else { // Filtering SafariTechPreview because I am having // local issues and I have no idea how to fix them. // I know that’s not a good reason to disable tests, // but Safari TP is relatively unimportant. - return availableBrowsers - .filter((browser) => browser !== "SafariTechPreview") - .map((browser) => - browser === "ChromeHeadless" ? "ChromeHeadlessGC" : browser - ); + return availableBrowsers.filter( + (browser) => browser !== "SafariTechPreview" + ); } }, }, @@ -63,14 +61,6 @@ module.exports = function (config) { base: "ChromeHeadless", flags: ["--no-sandbox", "--js-flags=--expose-gc"], }, - ChromeHeadlessGC: { - base: "ChromeHeadless", - flags: ["--js-flags=--expose-gc"], - }, - ChromeHeadlessGC: { - base: "ChromeHeadless", - flags: ["--js-flags=--expose-gc"], - }, }, }; diff --git a/package.json b/package.json index efc20637..8c76c654 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "scripts": { "build": "rollup -c", "test:unit": "karma start", - "test:unit:gc": "karma start --browsers ChromeHeadlessGC", "test:node": "mocha ./tests/node/main.mjs", "test:types": "tsc -p ./tests/tsconfig.json", "test:types:watch": "npm run test:types -- --watch",