@nxtedition/scheduler 4.1.11 → 4.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scheduler.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { types } from 'node:util';
1
2
  import { FastQueue, parsePriority, Queue } from "./queue.js";
2
3
  // Layout of the 128-byte (32 × Int32) shared state buffer:
3
4
  //
@@ -12,11 +13,23 @@ import { FastQueue, parsePriority, Queue } from "./queue.js";
12
13
  // loads WAITERS to decide whether to skip Atomics.notify does NOT invalidate
13
14
  // the cache line carrying RUNNING — under N-way contention that would
14
15
  // penalize every concurrent acquirer on every release.
16
+ //
17
+ // (An earlier revision sharded RUNNING/WAITERS across cache lines + a separate
18
+ // futex word. Benchmarking on the x64 target showed it was a net regression:
19
+ // RUNNING is read on every acquire, so aggregating shards costs more coherency
20
+ // traffic than the single-line write contention it avoids. Reverted to one
21
+ // counter; the futex is RUNNING itself, which makes waitAsync self-correcting.)
15
22
  const RUNNING_INDEX = 0;
16
23
  const MAGIC_INDEX = 8;
17
24
  const CONCURRENCY_INDEX = 16;
18
25
  const WAITERS_INDEX = 24;
19
26
  const SCHEDULER_MAGIC = 0x5ca4edde; // sanity-check sentinel written by makeSharedState
27
+ // CONCURRENCY is stored in an Int32 slot. A finite value above this would silently
28
+ // wrap on store (2**31 → -2147483648 → "unlimited", 2**32 → 0 → permanent hang), so
29
+ // values that don't fit are either rejected (makeSharedState) or reported as
30
+ // Infinity by the `concurrency` getter (non-shared mode, where the real limit is
31
+ // still enforced via #limits/#max as ordinary numbers).
32
+ const MAX_INT32 = 0x7fffffff;
20
33
  // Per-priority limit keys, ordered to match `Queue.queues` (lowest=0, normal=3, highest=6).
21
34
  const PRIORITY_KEYS = ['lowest', 'lower', 'low', 'normal', 'high', 'higher', 'highest'];
22
35
  // Reject anything that isn't a literal config object — Map/Date/Array/class instances all
@@ -123,6 +136,28 @@ export class Scheduler extends Queue {
123
136
  // bypasses per-priority limits to prevent starvation — without this hard cap, the
124
137
  // bypass could push running above max during drain.
125
138
  #max;
139
+ // ---- child() hierarchy ----
140
+ // A child Scheduler enforces its OWN (local, per-priority) concurrency cap on top of its
141
+ // parent's overall `max`: a task dispatched through a child must be admitted by both.
142
+ // Children delegate all global-capacity accounting (and, when the root is shared, all
143
+ // cross-thread waiting) up the parent chain via #tryAcquireGlobal / #releaseGlobal, so
144
+ // only the root ever touches the SharedArrayBuffer. See child().
145
+ #parent = null;
146
+ #children = null;
147
+ // For a node that is itself a child: `running` counts ALL slots this node holds globally
148
+ // — its own direct tasks PLUS every descendant's (a descendant's #tryAcquireGlobal
149
+ // increments it up the chain). That total is what this node's hard `#max` and
150
+ // #tryAcquireGlobal measure. But the node's PER-PRIORITY caps apply only to its OWN
151
+ // directly-submitted tasks, so those are measured against #directRunning (incremented
152
+ // solely by this node's own dispatch). For a leaf child the two are equal; they diverge
153
+ // only for an intermediate node used both as a pool and directly. Unused on the root.
154
+ #directRunning = 0;
155
+ // Round-robin start offset across #children, advanced once per dispatch pass so no
156
+ // sibling can monopolise freed global capacity when the pool is over-subscribed.
157
+ #driveCursor = 0;
158
+ // Re-entrancy guard for the hierarchy dispatch loop (#driveLoop), distinct from
159
+ // `releasing` which guards the non-hierarchy #schedule.
160
+ #driving = false;
126
161
  running = 0;
127
162
  pending = 0;
128
163
  releasing = false;
@@ -141,7 +176,13 @@ export class Scheduler extends Queue {
141
176
  try {
142
177
  const result = fn(opaque);
143
178
  if (result != null && typeof result.then === 'function') {
144
- void result.then(resolve, reject).then(this.release);
179
+ // Promise.resolve assimilates the thenable: identity for native promises (no
180
+ // hot-path allocation), and for user thenables it restores the Promises/A+
181
+ // guarantees raw .then lacks — a then() that returns undefined no longer
182
+ // TypeErrors the chain, a thenable that calls its callback twice can no
183
+ // longer double-release the slot, and release is chained off a real promise
184
+ // instead of whatever object the thenable's then() happened to return.
185
+ void Promise.resolve(result).then(resolve, reject).then(this.release);
145
186
  }
146
187
  else {
147
188
  resolve(result);
@@ -157,7 +198,10 @@ export class Scheduler extends Queue {
157
198
  if (concurrency === Infinity) {
158
199
  concurrency = -1;
159
200
  }
160
- else if (concurrency == null || !Number.isInteger(concurrency) || concurrency < 0) {
201
+ else if (concurrency == null ||
202
+ !Number.isInteger(concurrency) ||
203
+ concurrency < 0 ||
204
+ concurrency > MAX_INT32) {
161
205
  throw new Error('Invalid concurrency');
162
206
  }
163
207
  const stateBuffer = new SharedArrayBuffer(128); // 2 cache lines, one per field
@@ -188,7 +232,10 @@ export class Scheduler extends Queue {
188
232
  if (options != null && !isPlainObject(options)) {
189
233
  throw new Error('Invalid options');
190
234
  }
191
- if (optsOrArrayBuffer instanceof SharedArrayBuffer) {
235
+ // util.types.isSharedArrayBuffer instead of instanceof: a SAB from another realm
236
+ // (vm context, jest sandbox) fails instanceof and would fall into the options
237
+ // branch with a misleading 'Invalid options' error.
238
+ if (types.isSharedArrayBuffer(optsOrArrayBuffer)) {
192
239
  const state = optsOrArrayBuffer;
193
240
  if (state.byteLength !== 128) {
194
241
  throw new Error('Invalid SharedArrayBuffer size');
@@ -216,7 +263,9 @@ export class Scheduler extends Queue {
216
263
  const { limits, max } = buildLimits(opts?.concurrency);
217
264
  this.stateView = new Int32Array(64);
218
265
  // Persist `max` so the public `concurrency` getter still reflects the overall ceiling.
219
- Atomics.store(this.stateView, CONCURRENCY_INDEX, max === Number.MAX_SAFE_INTEGER ? -1 : max);
266
+ // Values that don't fit Int32 are effectively unlimited — report them as Infinity
267
+ // (-1 sentinel) instead of letting the store wrap to garbage.
268
+ Atomics.store(this.stateView, CONCURRENCY_INDEX, max > MAX_INT32 ? -1 : max);
220
269
  this.shared = false;
221
270
  this.#limits = limits;
222
271
  this.#max = max;
@@ -253,17 +302,72 @@ export class Scheduler extends Queue {
253
302
  // Atomics.sub(WAITERS, 1) in its .then handler would normally pair the
254
303
  // increment from #maybeWait — but the .then handler sees dead===true
255
304
  // and skips. Drain WAITERS here to keep the cross-thread counter honest.
256
- if (this.#waitPromise != null) {
305
+ if (this.shared && this.#waitPromise != null) {
257
306
  Atomics.sub(this.stateView, WAITERS_INDEX, 1);
258
307
  }
259
- if (released > 0) {
308
+ if (released > 0 && this.shared) {
260
309
  Atomics.sub(this.stateView, RUNNING_INDEX, released);
261
310
  if (this.stateView[WAITERS_INDEX] > 0) {
262
311
  Atomics.notify(this.stateView, RUNNING_INDEX, released);
263
312
  }
264
313
  }
265
314
  }
315
+ /**
316
+ * Create a child Scheduler that enforces an ADDITIONAL concurrency limit on top of this
317
+ * (the parent). A task dispatched through the child runs only when it is admitted by BOTH
318
+ * the child's own per-priority cap (counting the child's running tasks) AND the parent's
319
+ * overall `max` (counting all tasks in the parent's pool, across workers when the parent
320
+ * is shared).
321
+ *
322
+ * Canonical use: a shared parent caps global concurrency (e.g. 16 reads across the whole
323
+ * process) while each worker uses a child to cap its local share (e.g. 4):
324
+ *
325
+ * const parent = new Scheduler(Scheduler.makeSharedState(16)) // global pool
326
+ * const reads = parent.child({ concurrency: 4 }) // this worker's cap
327
+ * await reads.run(() => fs.readFile(path), 'high')
328
+ *
329
+ * `opts` mirrors the constructor's options (so per-priority caps work too:
330
+ * `parent.child({ concurrency: { max: 4, low: 1 } })`); omit it for an unlimited local
331
+ * cap (only the parent constrains).
332
+ *
333
+ * Priorities: the child has its own independent per-priority caps and starvation-
334
+ * prevention lottery; the parent's overall `max` is the global gate (a SOFT limit when
335
+ * the parent/root is shared — the same bounded read-then-increment overshoot as a plain
336
+ * shared Scheduler). The parent's own per-priority caps constrain only tasks submitted
337
+ * DIRECTLY to the parent — child tasks see the parent solely as an overall `max`.
338
+ * Children may be nested arbitrarily.
339
+ */
340
+ child(opts) {
341
+ if (this.dead) {
342
+ throw new Error('Scheduler is disposed');
343
+ }
344
+ // A child is always a local (non-shared) Scheduler: it owns no SharedArrayBuffer and
345
+ // delegates every global-capacity decision to the parent chain. No opts → unlimited
346
+ // local cap (`{}`), so only the parent constrains. (Pass an explicit `{}` rather than
347
+ // relying on an optional constructor param, keeping the public constructor signature
348
+ // unchanged.)
349
+ const c = new Scheduler(opts ?? {});
350
+ c.#parent = this;
351
+ (this.#children ??= []).push(c);
352
+ return c;
353
+ }
354
+ // The root of this hierarchy (itself when not a child). Depth is tiny in practice.
355
+ #root() {
356
+ let n = this.#parent;
357
+ if (n === null) {
358
+ return this;
359
+ }
360
+ while (n.#parent !== null) {
361
+ n = n.#parent;
362
+ }
363
+ return n;
364
+ }
266
365
  run(fn, priority = Queue.NORMAL, opaque) {
366
+ if (typeof fn !== 'function') {
367
+ // Validate up front: a queued non-function would otherwise detonate later
368
+ // inside the dispatch loop, mis-attributed to whoever triggered the drain.
369
+ throw new Error('Invalid fn');
370
+ }
267
371
  return new Promise((resolve, reject) => {
268
372
  // After dispose/exit the scheduler is inert: #tryAcquire returns false and
269
373
  // acquire() refuses to queue, so without this guard the returned promise would
@@ -273,10 +377,27 @@ export class Scheduler extends Queue {
273
377
  reject(new Error('Scheduler is disposed'));
274
378
  return;
275
379
  }
380
+ // A hard cap of 0 anywhere in the chain can never dispatch anything (even the
381
+ // lottery bypass is gated on running < max), so the promise would hang forever.
382
+ // Fail fast, mirroring the disposed-scheduler rejection above.
383
+ if (this.#max === 0) {
384
+ reject(new Error('Scheduler concurrency is 0'));
385
+ return;
386
+ }
387
+ for (let n = this.#parent; n !== null; n = n.#parent) {
388
+ if (n.#max === 0) {
389
+ reject(new Error('Scheduler concurrency is 0'));
390
+ return;
391
+ }
392
+ }
276
393
  const ctx = { resolve, reject, fn, opaque };
277
394
  const p = parsePriority(priority);
278
- if (this.#tryAcquire(p)) {
279
- // Fast path: slot available immediately call #runTask directly.
395
+ if (this.queues[p + 3].cnt === 0 && this.#tryAcquire(p)) {
396
+ // Fast path: nothing queued ahead of us at this priority and a slot is
397
+ // available immediately — call #runTask directly. The same-priority gate
398
+ // preserves FIFO within a priority: without it a fresh task could overtake
399
+ // work queued at the same priority during the (shared-mode) microtask window
400
+ // between a remote release and the parked waiter's wakeup.
280
401
  this.#runTask(ctx);
281
402
  }
282
403
  else {
@@ -287,9 +408,30 @@ export class Scheduler extends Queue {
287
408
  });
288
409
  }
289
410
  acquire(fn, priority = Queue.NORMAL, opaque) {
411
+ if (typeof fn !== 'function') {
412
+ // Validate up front: a queued non-function would otherwise detonate later
413
+ // inside the dispatch loop, mis-attributed to whoever triggered the drain.
414
+ throw new Error('Invalid fn');
415
+ }
290
416
  if (this.dead) {
291
417
  return false;
292
418
  }
419
+ // In a child() hierarchy, a hard cap of 0 anywhere in the chain can never dispatch this
420
+ // work: queuing it would strand the item AND, under a shared root, busy-spin #driveLoop's
421
+ // lost-race retry (a structurally-dead subtree the loop mistakes for a transient race).
422
+ // Refuse up front and enqueue nothing. (A plain, non-hierarchy max-0 scheduler keeps its
423
+ // existing contract of queuing the task — it simply never dispatches; see run(), which
424
+ // rejects max-0 either way.)
425
+ if (this.#parent !== null || this.#children !== null) {
426
+ if (this.#max === 0) {
427
+ return false;
428
+ }
429
+ for (let n = this.#parent; n !== null; n = n.#parent) {
430
+ if (n.#max === 0) {
431
+ return false;
432
+ }
433
+ }
434
+ }
293
435
  const p = priority == null ? Queue.NORMAL : parsePriority(priority);
294
436
  // Snapshot RUNNING *before* the capacity check. If a remote release lands
295
437
  // between a failed #tryAcquire and the Atomics.waitAsync in #maybeWait, its
@@ -300,7 +442,8 @@ export class Scheduler extends Queue {
300
442
  // A fresh read taken after the failed check would match the post-release
301
443
  // value and park forever on a queue nobody will ever notify.
302
444
  const expected = this.shared ? this.stateView[RUNNING_INDEX] : 0;
303
- if (this.#tryAcquire(p)) {
445
+ // Same-priority FIFO gate: see run().
446
+ if (this.queues[p + 3].cnt === 0 && this.#tryAcquire(p)) {
304
447
  try {
305
448
  fn(opaque);
306
449
  }
@@ -314,19 +457,40 @@ export class Scheduler extends Queue {
314
457
  const queue = this.queues[p + 3];
315
458
  queue.arr.push(fn, opaque);
316
459
  queue.cnt += 1;
460
+ if (queue.cnt === 1) {
461
+ this.nonEmptyMask |= 1 << queue.i;
462
+ }
317
463
  this.pending += 1;
318
- if (this.shared) {
319
- this.#maybeWait(expected);
464
+ if (this.#parent !== null || this.#children !== null) {
465
+ // Part of a child() hierarchy — dispatch (and, for a shared root, parking) is
466
+ // coordinated from the root across the whole tree.
467
+ this.#root().#driveLoop();
468
+ }
469
+ else if (this.shared) {
470
+ const fresh = this.#maybeWait(expected);
471
+ if (fresh !== -1) {
472
+ this.#schedule(fresh);
473
+ }
320
474
  }
321
475
  return false;
322
476
  }
477
+ // Park a waiter on RUNNING when this instance has queued work that only a remote
478
+ // release can unblock. Returns -1 when parked (or when waiting is not applicable:
479
+ // non-shared, nothing pending, already parked, cap 0); otherwise returns a fresh
480
+ // RUNNING value the caller must re-dispatch with (#schedule) instead of parking.
481
+ //
482
+ // We park even while local tasks are running: a local release does drive
483
+ // #schedule directly, but capacity freed by a REMOTE release is only ever
484
+ // observed through this waiter — refusing to park while this.running > 0 left
485
+ // the backlog serialized behind the slowest local task (and hung forever behind
486
+ // an unbounded one) while free shared slots sat idle.
323
487
  #maybeWait(expected) {
324
- if (!this.shared || this.running > 0 || this.pending === 0 || this.#waitPromise) {
325
- return;
488
+ if (!this.shared || this.pending === 0 || this.#waitPromise) {
489
+ return -1;
326
490
  }
327
491
  // No point waiting when global cap is 0 — value can never drop to enable dispatch.
328
492
  if (this.#max === 0) {
329
- return;
493
+ return -1;
330
494
  }
331
495
  // Bump WAITERS BEFORE waitAsync so any concurrent release that already
332
496
  // decremented RUNNING will observe us — a release that misses our increment
@@ -335,6 +499,18 @@ export class Scheduler extends Queue {
335
499
  // pair the decrement in the .then; the cost is one wasted atomic pair on
336
500
  // the rare not-equal path.
337
501
  Atomics.add(this.stateView, WAITERS_INDEX, 1);
502
+ // ABA guard: `expected` predates the caller's failed capacity check. A remote
503
+ // release in between skipped notify (WAITERS was 0) — and if a remote acquire
504
+ // then restored RUNNING to exactly `expected`, waitAsync would park on a value
505
+ // that already changed, with the missed release's capacity unconsumed. Re-read
506
+ // AFTER the WAITERS increment: from this point any release will notify us, so
507
+ // an unchanged read is safe to park on, and a changed read means capacity may
508
+ // be free — hand it back to the caller for a dispatch pass instead of parking.
509
+ const current = this.stateView[RUNNING_INDEX];
510
+ if (current !== expected) {
511
+ Atomics.sub(this.stateView, WAITERS_INDEX, 1);
512
+ return current;
513
+ }
338
514
  // Atomics.waitAsync does not keep the Node event loop alive on its own. Callers
339
515
  // with idle workers (running===0 and tasks queued) need an external keep-alive
340
516
  // (their own setTimeout, setInterval, HTTP server, etc.) until notify wakes us.
@@ -342,24 +518,63 @@ export class Scheduler extends Queue {
342
518
  this.#waitPromise = (async ? value : Promise.resolve(value)).then(() => {
343
519
  // If [kOnExit] already fired (Node still drains the microtask queue
344
520
  // after process.on('exit')), it has already drained WAITERS for us and
345
- // any further state mutation would corrupt shared counters.
521
+ // any further state mutation would corrupt shared counters. But we must
522
+ // forward the wakeup: Atomics.waitAsync cannot be cancelled, so a
523
+ // disposed instance's waiter stays in the (FIFO) wait set and a
524
+ // notify(1) aimed at a live waiter parked behind it would otherwise be
525
+ // swallowed here, stranding that waiter's queue forever. Re-notifying
526
+ // re-targets the wakeup to the next waiter; when none is parked it is a
527
+ // harmless no-op.
346
528
  if (this.dead) {
529
+ Atomics.notify(this.stateView, RUNNING_INDEX, 1);
347
530
  return;
348
531
  }
349
532
  Atomics.sub(this.stateView, WAITERS_INDEX, 1);
350
533
  this.#waitPromise = null;
351
- this.#schedule(this.stateView[RUNNING_INDEX]);
534
+ // Re-dispatch via the path matching this instance's CURRENT shape, not the one it had
535
+ // when it parked: child() may have been called on this (formerly plain) shared
536
+ // scheduler while it was parked here, turning it into a hierarchy node. Routing
537
+ // through #schedule would only drain this node's own direct queue and strand the new
538
+ // subtree work; #driveLoop drives the whole tree. (acquire()/release() pick the
539
+ // dispatcher the same way.)
540
+ if (this.#parent !== null || this.#children !== null) {
541
+ this.#root().#driveLoop();
542
+ }
543
+ else {
544
+ this.#schedule(this.stateView[RUNNING_INDEX]);
545
+ }
352
546
  });
547
+ return -1;
353
548
  }
354
549
  tryAcquire(priority) {
355
550
  const p = priority == null ? Queue.NORMAL : parsePriority(priority);
356
- return this.#tryAcquire(p);
551
+ // Same-priority FIFO gate (see run()): refuse a slot that work queued at this
552
+ // priority is already waiting for, mirroring Limiter.tryConsume's pending check.
553
+ return this.queues[p + 3].cnt === 0 && this.#tryAcquire(p);
357
554
  }
358
555
  #tryAcquire(priority) {
359
556
  if (this.dead) {
360
557
  return false;
361
558
  }
362
559
  const limit = this.#limits[priority + 3];
560
+ if (this.#parent !== null) {
561
+ // Child: enforce the local per-priority cap (against this node's own DIRECT running
562
+ // count, not descendant slots), AND this node's overall #max against its whole subtree
563
+ // (this.running = direct + descendants). For a leaf the two coincide, but for an
564
+ // intermediate node whose descendants already hold slots the subtree check is what
565
+ // keeps a direct task from pushing the node past its own max (#drainSelf gates the
566
+ // same way). Then take a global slot from the parent chain — the parent's
567
+ // #tryAcquireGlobal performs the shared Atomics, so the child never does.
568
+ if (this.#directRunning >= limit || this.running >= this.#max) {
569
+ return false;
570
+ }
571
+ if (!this.#parent.#tryAcquireGlobal()) {
572
+ return false;
573
+ }
574
+ this.#directRunning += 1;
575
+ this.running += 1;
576
+ return true;
577
+ }
363
578
  if (this.shared) {
364
579
  // We use non-atomic access here as an optimization and treat the concurrency limit as a soft limit.
365
580
  if (this.stateView[RUNNING_INDEX] < limit) {
@@ -376,6 +591,55 @@ export class Scheduler extends Queue {
376
591
  }
377
592
  return false;
378
593
  }
594
+ // Take one slot against this node's overall `max` AND every ancestor's, used by a child
595
+ // to acquire global capacity. Self is checked first (cheap, no side effect) then the
596
+ // parent chain; self is only incremented once the whole chain grants, so a denial higher
597
+ // up never needs a rollback. Returns false if any level is at its cap.
598
+ #tryAcquireGlobal() {
599
+ if (this.dead) {
600
+ return false;
601
+ }
602
+ if (this.shared) {
603
+ if (this.stateView[RUNNING_INDEX] >= this.#max) {
604
+ return false;
605
+ }
606
+ }
607
+ else if (this.running >= this.#max) {
608
+ return false;
609
+ }
610
+ if (this.#parent !== null && !this.#parent.#tryAcquireGlobal()) {
611
+ return false;
612
+ }
613
+ if (this.shared) {
614
+ Atomics.add(this.stateView, RUNNING_INDEX, 1);
615
+ }
616
+ this.running += 1;
617
+ return true;
618
+ }
619
+ // Mirror of #tryAcquireGlobal's increments: release one slot at this node and every
620
+ // ancestor, waking parked waiters on each shared level.
621
+ #releaseGlobal() {
622
+ // Stop at a dead ancestor (mirrors #tryAcquireGlobal's dead guard and release()'s). At
623
+ // process exit only the shared ROOT runs [kOnExit] — a local child is not in the global
624
+ // `schedulers` set, so its dead flag is never set and a late post-exit microtask from an
625
+ // in-flight child task can still reach here. kOnExit already subtracted this node's held
626
+ // slots from the SAB; decrementing again would double-count and drive the cross-thread
627
+ // RUNNING counter negative. (Ancestors above a dead node were balanced by the same
628
+ // kOnExit, so stopping the whole walk here is correct.)
629
+ if (this.dead) {
630
+ return;
631
+ }
632
+ this.running -= 1;
633
+ if (this.shared) {
634
+ Atomics.sub(this.stateView, RUNNING_INDEX, 1);
635
+ if (this.stateView[WAITERS_INDEX] > 0) {
636
+ Atomics.notify(this.stateView, RUNNING_INDEX, 1);
637
+ }
638
+ }
639
+ if (this.#parent !== null) {
640
+ this.#parent.#releaseGlobal();
641
+ }
642
+ }
379
643
  #schedule(running) {
380
644
  const shared = this.shared;
381
645
  if (this.dead || this.pending === 0 || this.releasing) {
@@ -389,13 +653,21 @@ export class Scheduler extends Queue {
389
653
  // Hard cap on global concurrency. getNextQueue's lottery bypasses per-priority
390
654
  // limits to prevent starvation and would otherwise push running above max.
391
655
  if (running >= max) {
392
- this.#maybeWait(running);
393
- break;
656
+ // #maybeWait returns a fresh RUNNING value (instead of parking) when the
657
+ // counter changed under us — loop back and retry the dispatch with it.
658
+ running = this.#maybeWait(running);
659
+ if (running === -1) {
660
+ break;
661
+ }
662
+ continue;
394
663
  }
395
664
  const queue = this.getNextQueue(limits, running);
396
665
  if (queue == null) {
397
- this.#maybeWait(running);
398
- break;
666
+ running = this.#maybeWait(running);
667
+ if (running === -1) {
668
+ break;
669
+ }
670
+ continue;
399
671
  }
400
672
  const fn = queue.arr[queue.idx];
401
673
  queue.arr[queue.idx++] = null;
@@ -403,6 +675,7 @@ export class Scheduler extends Queue {
403
675
  queue.arr[queue.idx++] = null;
404
676
  queue.cnt -= 1;
405
677
  if (queue.cnt === 0) {
678
+ this.nonEmptyMask &= ~(1 << queue.i);
406
679
  queue.idx = 0;
407
680
  // `queue.arr = []` is faster than `arr.length = 0` in V8: truncation walks
408
681
  // the elements area to clear refs, while reassigning drops the old backing
@@ -422,10 +695,16 @@ export class Scheduler extends Queue {
422
695
  fn(opaque);
423
696
  }
424
697
  catch (err) {
425
- // Balance the counter we just incremented before propagating. this.releasing=true
426
- // so the nested release() only decrements — it won't re-enter this dispatch loop.
698
+ // Balance the counter we just incremented (this.releasing=true keeps the
699
+ // nested release() from re-entering this dispatch loop), then isolate the
700
+ // failure exactly like Limiter's drain: surface it asynchronously and keep
701
+ // dispatching. Propagating from here would abort the loop — and when the
702
+ // thrower didn't release a slot first, no future release is coming, so the
703
+ // remaining backlog (and any queued run() promises) would strand forever.
427
704
  this.release();
428
- throw err;
705
+ queueMicrotask(() => {
706
+ throw err;
707
+ });
429
708
  }
430
709
  // Re-read running after fn() in case it synchronously called release().
431
710
  // Plain Int32Array load: same soft-limit relaxation as #tryAcquire — a
@@ -440,6 +719,220 @@ export class Scheduler extends Queue {
440
719
  this.releasing = false;
441
720
  }
442
721
  }
722
+ // ---- hierarchy dispatch (child() schedulers) ----
723
+ // True if this node or any descendant has queued work.
724
+ #subtreeHasPending() {
725
+ if (this.pending > 0) {
726
+ return true;
727
+ }
728
+ const children = this.#children;
729
+ if (children !== null) {
730
+ for (let i = 0; i < children.length; i++) {
731
+ if (children[i].#subtreeHasPending()) {
732
+ return true;
733
+ }
734
+ }
735
+ }
736
+ return false;
737
+ }
738
+ // Dispatch this node's OWN queued tasks, each gated by its local per-priority cap (with
739
+ // the starvation lottery) AND a global slot from the parent chain. Returns true if at
740
+ // least one task was dispatched.
741
+ #drainSelf() {
742
+ if (this.dead || this.pending === 0) {
743
+ return false;
744
+ }
745
+ const isRoot = this.#parent === null;
746
+ let progressed = false;
747
+ while (this.pending > 0) {
748
+ // Hard cap on THIS node's whole-subtree concurrency, checked BEFORE getNextQueue
749
+ // exactly like #schedule: the GLOBAL running total for the root, this node's
750
+ // `running` (own + descendants) for a child. getNextQueue's starvation lottery
751
+ // deliberately BYPASSES the per-priority limits, so without this gate the lottery
752
+ // could push a node past its own `max`.
753
+ const maxRunning = isRoot && this.shared ? this.stateView[RUNNING_INDEX] : this.running;
754
+ if (maxRunning >= this.#max) {
755
+ break;
756
+ }
757
+ // Per-priority selection is measured against the GLOBAL total for the root (its caps
758
+ // are global reservations) but against this child's OWN direct count (#directRunning)
759
+ // for a child — so a child's per-priority caps are not polluted by descendant slots.
760
+ const capRunning = isRoot ? maxRunning : this.#directRunning;
761
+ const queue = this.getNextQueue(this.#limits, capRunning);
762
+ if (queue === null) {
763
+ break;
764
+ }
765
+ // Acquire a global slot. For the root this is its own counter; for a child it is the
766
+ // whole parent chain (#tryAcquireGlobal), which can still deny on a parent's cap.
767
+ if (isRoot) {
768
+ if (this.shared) {
769
+ Atomics.add(this.stateView, RUNNING_INDEX, 1);
770
+ }
771
+ this.running += 1;
772
+ }
773
+ else {
774
+ if (!this.#parent.#tryAcquireGlobal()) {
775
+ break;
776
+ }
777
+ this.#directRunning += 1;
778
+ this.running += 1;
779
+ }
780
+ const fn = queue.arr[queue.idx];
781
+ queue.arr[queue.idx++] = null;
782
+ const opaque = queue.arr[queue.idx];
783
+ queue.arr[queue.idx++] = null;
784
+ queue.cnt -= 1;
785
+ if (queue.cnt === 0) {
786
+ this.nonEmptyMask &= ~(1 << queue.i);
787
+ queue.idx = 0;
788
+ queue.arr = [];
789
+ }
790
+ else if (queue.idx > 1024) {
791
+ queue.arr.splice(0, queue.idx);
792
+ queue.idx = 0;
793
+ }
794
+ this.pending -= 1;
795
+ progressed = true;
796
+ try {
797
+ fn(opaque);
798
+ }
799
+ catch (err) {
800
+ // Balance the slot we just took (release() routes through the hierarchy path;
801
+ // #driving keeps the nested drive from re-entering), then isolate the failure.
802
+ this.release();
803
+ queueMicrotask(() => {
804
+ throw err;
805
+ });
806
+ }
807
+ }
808
+ return progressed;
809
+ }
810
+ // Dispatch this node and, round-robin, every descendant. Returns true if any task ran in
811
+ // the subtree. The cursor rotation gives siblings a fair share of freed global capacity
812
+ // when the pool is over-subscribed (no sibling drains fully first every pass).
813
+ #dispatchSubtree() {
814
+ if (this.dead) {
815
+ return false;
816
+ }
817
+ let progressed = this.#drainSelf();
818
+ const children = this.#children;
819
+ if (children !== null && children.length > 0) {
820
+ // A dispatched fn may synchronously dispose a sibling, which splices #children
821
+ // mid-iteration. Bound the loop by the count captured here and null-check each slot
822
+ // (the index can fall past a shrunk array) so a concurrent removal can't crash the
823
+ // drive or read undefined. A disposed child is dead, so its #dispatchSubtree no-ops.
824
+ const start = this.#driveCursor;
825
+ const n = children.length;
826
+ for (let k = 0; k < n; k++) {
827
+ const len = children.length;
828
+ if (len === 0) {
829
+ break;
830
+ }
831
+ const node = children[(start + k) % len];
832
+ if (node !== undefined && node.#dispatchSubtree()) {
833
+ progressed = true;
834
+ }
835
+ }
836
+ const len = children.length;
837
+ this.#driveCursor = len > 0 ? (start + 1) % len : 0;
838
+ }
839
+ return progressed;
840
+ }
841
+ // Root-coordinated dispatch for child() hierarchies. Replaces #schedule/#maybeWait for
842
+ // any scheduler that is part of a tree. Re-entrancy is guarded by #driving (a synchronous
843
+ // release inside a dispatched fn just decrements counters; the outer loop re-reads them
844
+ // and picks up the freed capacity).
845
+ #driveLoop() {
846
+ if (this.dead || this.#driving) {
847
+ return;
848
+ }
849
+ this.#driving = true;
850
+ try {
851
+ for (;;) {
852
+ const expected = this.shared ? this.stateView[RUNNING_INDEX] : 0;
853
+ if (this.#dispatchSubtree()) {
854
+ continue; // dispatched something — re-attempt (freed/created capacity may allow more)
855
+ }
856
+ // Nothing dispatched this pass.
857
+ if (!this.shared || this.#waitPromise !== null || this.#max === 0) {
858
+ // Non-shared: local releases redrive #driveLoop directly, no futex to park on.
859
+ break;
860
+ }
861
+ if (!this.#subtreeHasPending()) {
862
+ break;
863
+ }
864
+ const running = this.stateView[RUNNING_INDEX];
865
+ if (running !== expected) {
866
+ continue; // a concurrent release changed capacity — re-attempt before parking
867
+ }
868
+ if (running < this.#max) {
869
+ // GLOBAL headroom exists yet we dispatched nothing. Two cases:
870
+ if (this.running > 0) {
871
+ // This worker holds running tasks → it is blocked on a LOCAL child cap, which
872
+ // only a local release can free (and that redrives #driveLoop directly). The
873
+ // headroom is shared, though: a peer whose child is below its cap could use it,
874
+ // and notify(1) on the releasing side may have woken THIS worker (which can't
875
+ // use the slot). Forward the wakeup so the slot is not stranded while a peer's
876
+ // backlog hangs; the chain stops when capacity is consumed or no waiter remains.
877
+ if (this.stateView[WAITERS_INDEX] > 0) {
878
+ Atomics.notify(this.stateView, RUNNING_INDEX, 1);
879
+ }
880
+ break;
881
+ }
882
+ // We hold NOTHING yet headroom exists: a peer won the acquire race during this
883
+ // pass (RUNNING transiently hit max while we read it in #tryAcquireGlobal). There
884
+ // is no local task to ever redrive us, so we must NOT break — retry; the next
885
+ // #tryAcquireGlobal grabs the slot once the transient spike clears. (Parking here
886
+ // would strand us with idle capacity, since the freeing release already fired.)
887
+ continue;
888
+ }
889
+ // Globally saturated with pending work: park on RUNNING. Bump WAITERS, then re-read
890
+ // RUNNING (a release in the window skipped notify while WAITERS was 0); waitAsync's
891
+ // atomic compare against `expected` covers the remaining window up to the park.
892
+ Atomics.add(this.stateView, WAITERS_INDEX, 1);
893
+ if (this.stateView[RUNNING_INDEX] !== expected) {
894
+ Atomics.sub(this.stateView, WAITERS_INDEX, 1);
895
+ continue;
896
+ }
897
+ const { value, async } = Atomics.waitAsync(this.stateView, RUNNING_INDEX, expected);
898
+ this.#waitPromise = (async ? value : Promise.resolve(value)).then(() => {
899
+ if (this.dead) {
900
+ Atomics.notify(this.stateView, RUNNING_INDEX, 1);
901
+ return;
902
+ }
903
+ Atomics.sub(this.stateView, WAITERS_INDEX, 1);
904
+ this.#waitPromise = null;
905
+ this.#driveLoop();
906
+ });
907
+ break;
908
+ }
909
+ }
910
+ finally {
911
+ this.#driving = false;
912
+ }
913
+ }
914
+ // Release path for any scheduler in a child() hierarchy: free one slot up the chain, then
915
+ // re-drive the whole tree from the root.
916
+ #releaseHierarchy() {
917
+ if (this.running > 0) {
918
+ this.running -= 1;
919
+ if (this.#parent !== null) {
920
+ // This release is for one of THIS node's own direct tasks (release() is always
921
+ // called on the node the task ran through), so pair the #directRunning increment.
922
+ if (this.#directRunning > 0) {
923
+ this.#directRunning -= 1;
924
+ }
925
+ this.#parent.#releaseGlobal();
926
+ }
927
+ else if (this.shared) {
928
+ Atomics.sub(this.stateView, RUNNING_INDEX, 1);
929
+ if (this.stateView[WAITERS_INDEX] > 0) {
930
+ Atomics.notify(this.stateView, RUNNING_INDEX, 1);
931
+ }
932
+ }
933
+ }
934
+ this.#root().#driveLoop();
935
+ }
443
936
  release = () => {
444
937
  // After [kOnExit], all bookkeeping for this scheduler has been finalised
445
938
  // and any leftover microtasks must NOT touch shared state. See the dead
@@ -447,6 +940,10 @@ export class Scheduler extends Queue {
447
940
  if (this.dead) {
448
941
  return;
449
942
  }
943
+ if (this.#parent !== null || this.#children !== null) {
944
+ this.#releaseHierarchy();
945
+ return;
946
+ }
450
947
  let running;
451
948
  if (this.running > 0) {
452
949
  this.running -= 1;
@@ -473,15 +970,47 @@ export class Scheduler extends Queue {
473
970
  else {
474
971
  running = this.shared ? this.stateView[RUNNING_INDEX] : this.running;
475
972
  }
476
- this.#schedule(running);
973
+ // Skip the (non-inlined) #schedule call entirely when nothing is queued —
974
+ // measurably cheaper on the no-backlog acquire/release hot path, and
975
+ // semantically identical (#schedule with pending === 0 is a no-op).
976
+ if (this.pending !== 0) {
977
+ this.#schedule(running);
978
+ }
477
979
  };
478
980
  [Symbol.dispose]() {
479
981
  if (this.dead) {
480
982
  return;
481
983
  }
482
- // Reuse the exit-cleanup path: release any global slots this instance still holds,
483
- // pair the WAITERS increment of a parked waitAsync, and mark the instance dead so any
484
- // late notify .then microtask becomes a no-op (see the `dead` field docstring).
984
+ // Dispose descendants first: reject their queued run() promises and release the global
985
+ // slots they hold back up to us (so our own kOnExit below accounts only for OUR direct
986
+ // slots, not theirs avoiding a double release of the same SAB slot).
987
+ const children = this.#children;
988
+ if (children !== null) {
989
+ this.#children = null;
990
+ for (const c of children.slice()) {
991
+ c[Symbol.dispose]();
992
+ }
993
+ }
994
+ // If we are a child, hand the slots we still hold back up the chain and unregister from
995
+ // the parent so the freed capacity can be reused and we can be GC'd.
996
+ const parent = this.#parent;
997
+ if (parent !== null) {
998
+ const held = this.running;
999
+ this.running = 0;
1000
+ this.#directRunning = 0;
1001
+ for (let i = 0; i < held; i++) {
1002
+ parent.#releaseGlobal();
1003
+ }
1004
+ if (parent.#children !== null) {
1005
+ const idx = parent.#children.indexOf(this);
1006
+ if (idx >= 0) {
1007
+ parent.#children.splice(idx, 1);
1008
+ }
1009
+ }
1010
+ }
1011
+ // Reuse the exit-cleanup path: release any global slots THIS instance still holds (root
1012
+ // shared mode), pair the WAITERS increment of a parked waitAsync, and mark the instance
1013
+ // dead so any late notify .then microtask becomes a no-op (see the `dead` docstring).
485
1014
  this[kOnExit]();
486
1015
  // Reject queued run() promises so callers awaiting them fail fast instead of
487
1016
  // hanging forever — the dead-guard in run() only covers calls made AFTER dispose,
@@ -498,12 +1027,23 @@ export class Scheduler extends Queue {
498
1027
  queue.idx = 0;
499
1028
  queue.cnt = 0;
500
1029
  }
1030
+ this.nonEmptyMask = 0;
501
1031
  // Drop our reference to the parked waitAsync chain and remove the strong ref that the
502
1032
  // exit registry holds, so the instance — and the .then closure capturing it — can be
503
1033
  // garbage-collected without waiting for process exit. Cleanup already ran above, so
504
1034
  // there is nothing left for process.on('exit') to do for this instance.
505
1035
  this.#waitPromise = null;
506
1036
  schedulers.delete(this);
1037
+ // A freed child slot may let the parent or our siblings dispatch now — BUT only when
1038
+ // this is an isolated child dispose, not when the parent is disposing the whole tree.
1039
+ // A parent's dispose nulls its #children before disposing each descendant; re-driving it
1040
+ // then would dispatch the parent's OWN still-queued direct tasks mid-disposal (running
1041
+ // user callbacks that the parent is about to reject), violating dispose's contract. A
1042
+ // node that has a child always has a non-null #children EXCEPT during its own dispose,
1043
+ // so `parent.#children !== null` is exactly "parent still an active hierarchy node".
1044
+ if (parent !== null && parent.#children !== null) {
1045
+ parent.#root().#driveLoop();
1046
+ }
507
1047
  }
508
1048
  }
509
1049
  //# sourceMappingURL=scheduler.js.map