From e3858d59f0e3c18a63e4aeb44ddeb8a1bc63881b Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 12:21:39 -0400 Subject: [PATCH 1/2] fix: eliminate Promise.race handler-stacking in claude.mts worker pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel task runner called `Promise.race(executing)` every loop iteration against the whole in-flight pool. Each race re-attached fresh `.then` handlers to every still-pending promise, so long-lived tasks accumulated O(iterations-they-survived) dead handler closures before finally settling. See https://github.com/nodejs/node/issues/17469 and https://github.com/cefn/watchable/tree/main/packages/unpromise. Switch to a single-waiter "slot available" signal: each task's .then releases one slot and resolves the current waiter (if any). The main loop awaits a one-shot waitForSlot() promise that's garbage-collected after it fires — no persistent pool, nothing to stack. --- scripts/claude.mts | 48 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/claude.mts b/scripts/claude.mts index 78944436a..1d669f084 100644 --- a/scripts/claude.mts +++ b/scripts/claude.mts @@ -1906,23 +1906,47 @@ async function executeParallel(tasks, workers = 3) { return results } - // Parallel execution with worker limit + // Parallel execution with worker limit. + // A single-waiter "slot available" signal wakes this loop whenever any + // in-flight task settles, instead of re-racing the whole executing[] array + // on every iteration (that stacks .then handlers on still-pending promises + // — see https://github.com/nodejs/node/issues/17469). log.substep(`🚀 Executing ${tasks.length} tasks with ${workers} workers`) const results = [] - const executing = [] - - for (const task of tasks) { - const promise = task().then(result => { - executing.splice(executing.indexOf(promise), 1) - return result + let inFlight = 0 + let slotWaiter = null + + const releaseSlot = () => { + inFlight-- + if (slotWaiter) { + const resolve = slotWaiter + slotWaiter = null + resolve() + } + } + const waitForSlot = () => { + if (inFlight < workers) { + return Promise.resolve() + } + return new Promise(resolve => { + slotWaiter = resolve }) + } + for (const task of tasks) { + await waitForSlot() + inFlight++ + const promise = task().then( + result => { + releaseSlot() + return result + }, + err => { + releaseSlot() + throw err + }, + ) results.push(promise) - executing.push(promise) - - if (executing.length >= workers) { - await Promise.race(executing) - } } return Promise.all(results) From dd312283842e47ce1988132102e215375f036367 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 12:30:43 -0400 Subject: [PATCH 2/2] docs(claude): add Promise.race handler-stacking rule Pairs with the claude.mts worker-pool fix: documents the anti-pattern so future code does not reintroduce it. --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 54c6d0441..527303e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ - Safe Deletion: Use `safeDelete()` from `@socketsecurity/lib/fs` (NEVER `fs.rm/rmSync` or `rm -rf`) - HTTP Requests: NEVER use `fetch()` — use `httpJson`/`httpText`/`httpRequest` from `@socketsecurity/lib/http-request` - File existence: ALWAYS `existsSync` from `node:fs`. NEVER `fs.access`, `fs.stat`-for-existence, or an async `fileExists` wrapper. Import form: `import { existsSync, promises as fs } from 'node:fs'`. +- `Promise.race` / `Promise.any`: NEVER pass a long-lived promise (interrupt signal, pool member) into a race inside a loop. Each call re-attaches `.then` handlers to every arm; handlers accumulate on surviving promises until they settle. For concurrency limiters, use a single-waiter "slot available" signal (resolved by each task's `.then`) instead of re-racing `executing[]`. See nodejs/node#17469 and `@watchable/unpromise`. Race with two fresh arms (e.g. one-shot `withTimeout`) is safe. --- @@ -149,6 +150,7 @@ Core infrastructure library for Socket.dev security tools. - **Null-prototype objects**: `{ __proto__: null, ...props }` - **Exports**: Named only. `export default` FORBIDDEN (breaks dual CJS/ESM). Enforced by oxlint `no-default-export` + build + CI validation. - **Function order**: Files with 3+ exports require alphabetical ordering — private first (alphabetical), then exported (alphabetical). Constants/types before functions. +- **Promise.race in loops**: NEVER re-race the same pool across iterations. Each race attaches fresh `.then` handlers to every arm — a promise that survives N iterations accumulates N handler sets ([nodejs/node#17469](https://github.com/nodejs/node/issues/17469)). Fresh-both-arms races (e.g. `Promise.race([work(), timeoutPromise()])` where both are per-call) are fine. For concurrency limiters, use a single-waiter signal (`promiseWithResolvers` swapped per iteration), never `Promise.race(pool)` over a persistent pool. ### Package Exports