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 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)