From 5a82c7530dbd91689ed05e9c362d308aef9611a9 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 19 Apr 2026 12:20:54 -0400 Subject: [PATCH 1/4] fix: eliminate Promise.race handler-stacking in batchPackageStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior implementation stored per-generator promises in a Map and called `Promise.race(running.values())` every loop iteration. Each race call re-attached fresh `.then` handlers to every still-pending promise in the pool, so long-surviving generators accumulated O(iterations) dead handler closures before finally settling. See https://github.com/nodejs/node/issues/17469 and https://github.com/cefn/watchable/tree/main/packages/unpromise for the pattern. Switch to a single-waiter queue: each generator's `.then` delivers its step into a buffer, and the main loop awaits a fresh promiseWithResolvers each iteration. Handlers are one-shot — nothing to stack. All 565 tests pass. --- src/socket-sdk-class.ts | 63 +++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/socket-sdk-class.ts b/src/socket-sdk-class.ts index 952bb94b..5248b313 100644 --- a/src/socket-sdk-class.ts +++ b/src/socket-sdk-class.ts @@ -867,10 +867,50 @@ export class SocketSdk { /* c8 ignore stop */ const { components } = componentsObj const { length: componentsCount } = components - const running = new Map< - AsyncGenerator, - Promise - >() + // Tracks in-flight generators only for pool-size accounting. + // Completed steps and errors flow through the single-waiter queue below, + // not through per-generator promises re-raced each iteration — repeated + // Promise.race() over the same pool accumulates unreleased .then + // handlers on each still-pending arm until the pool drains. + // See https://github.com/nodejs/node/issues/17469. + const running = new Set>() + const completed: GeneratorStep[] = [] + let waiter: { + resolve: (step: GeneratorStep) => void + reject: (err: unknown) => void + } | null = null + let pendingError: { err: unknown } | null = null + const deliverStep = (step: GeneratorStep) => { + if (waiter) { + const w = waiter + waiter = null + w.resolve(step) + } else { + completed.push(step) + } + } + const deliverError = (err: unknown) => { + if (waiter) { + const w = waiter + waiter = null + w.reject(err) + } else if (!pendingError) { + pendingError = { err } + } + } + const takeStep = (): Promise => { + if (pendingError) { + const { err } = pendingError + pendingError = null + return Promise.reject(err) + } + if (completed.length) { + return Promise.resolve(completed.shift()!) + } + const { promise, resolve, reject } = promiseWithResolvers() + waiter = { resolve, reject } + return promise + } let index = 0 const enqueueGen = () => { if (index >= componentsCount) { @@ -888,17 +928,12 @@ export class SocketSdk { const continueGen = ( generator: AsyncGenerator, ) => { - const { - promise, - reject: rejectFn, - resolve: resolveFn, - } = promiseWithResolvers() - running.set(generator, promise) + running.add(generator) void generator .next() .then( - iteratorResult => resolveFn({ generator, iteratorResult }), - rejectFn, + iteratorResult => deliverStep({ generator, iteratorResult }), + deliverError, ) } // Start initial batch of generators. @@ -907,9 +942,7 @@ export class SocketSdk { } while (running.size > 0) { // eslint-disable-next-line no-await-in-loop - const { generator, iteratorResult }: GeneratorStep = await Promise.race( - running.values(), - ) + const { generator, iteratorResult }: GeneratorStep = await takeStep() running.delete(generator) // Yield the value if one is given, even when done:true. if (iteratorResult.value) { From f9cd3ef98dfeed873689f78a56cb3aa6c2f2f7a5 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 19 Apr 2026 12:30:02 -0400 Subject: [PATCH 2/4] docs(claude): add Promise.race handler-stacking rule Pairs with the batchPackageStream fix: documents the anti-pattern so future code does not reintroduce it. Concise bullet in the SHARED STANDARDS section alongside the existsSync rule. --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1f748755..6cf1ac09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ - 🚨 **NEVER use `npx`, `pnpm dlx`, or `yarn dlx`** — use `pnpm exec ` for devDep binaries, or `pnpm run