diff --git a/karma.conf.js b/karma.conf.js index c7840f40..7cfaea9f 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -59,7 +59,7 @@ module.exports = function (config) { customLaunchers: { DockerChrome: { base: "ChromeHeadless", - flags: ["--no-sandbox"], + flags: ["--no-sandbox", "--js-flags=--expose-gc"], }, }, }; 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