@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/README.md +118 -10
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/limiter.d.ts +21 -0
- package/lib/limiter.d.ts.map +1 -1
- package/lib/limiter.js +266 -48
- package/lib/limiter.js.map +1 -1
- package/lib/queue.d.ts +4 -0
- package/lib/queue.d.ts.map +1 -1
- package/lib/queue.js +55 -42
- package/lib/queue.js.map +1 -1
- package/lib/scheduler.d.ts +28 -0
- package/lib/scheduler.d.ts.map +1 -1
- package/lib/scheduler.js +568 -28
- package/lib/scheduler.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
319
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
|
426
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|