@navaramap/worker 0.2.0 → 0.3.0

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/src/pool.test.ts CHANGED
@@ -3,7 +3,7 @@ import workerpool from "workerpool";
3
3
  import type Pool from "workerpool/types/Pool";
4
4
 
5
5
  import { createConcurrencyManager } from "./manager";
6
- import { initializeWorkerPool, queueTask } from "./pool";
6
+ import { acquireWorkerPool, queueTask } from "./pool";
7
7
 
8
8
  const workPoolExecStates = (() => {
9
9
  const states = {
@@ -39,13 +39,20 @@ vi.spyOn(workerpool, "pool").mockImplementation(
39
39
  );
40
40
  },
41
41
  terminate: () => undefined,
42
+ stats: () => ({
43
+ totalWorkers: 1,
44
+ busyWorkers: 0,
45
+ idleWorkers: 1,
46
+ pendingTasks: 0,
47
+ activeTasks: 0,
48
+ }),
42
49
  }) as unknown as Pool,
43
50
  );
44
51
 
45
52
  // Worker pool is initialized globally.
46
53
  const manager = createConcurrencyManager(1);
47
54
  const incrementFnMock = vi.spyOn(manager, "increment");
48
- initializeWorkerPool("https://example.com", manager);
55
+ acquireWorkerPool("https://example.com", manager);
49
56
 
50
57
  beforeEach(() => {
51
58
  workPoolExecStates.reset();
package/src/pool.ts CHANGED
@@ -16,10 +16,27 @@ export type {
16
16
  WorkerPoolStats,
17
17
  } from "./recyclingPool";
18
18
 
19
+ /** One holder's claim on the shared worker pool; see `acquireWorkerPool`. */
20
+ export type WorkerPoolLease = {
21
+ /** The pool's concurrency manager, which may be another holder's. */
22
+ manager: ConcurrencyManager;
23
+ /** Whether this claim created the pool, so its holder warms it up. */
24
+ created: boolean;
25
+ /** Drops this claim; the last release terminates the pool. Idempotent. */
26
+ release: () => void;
27
+ };
28
+
29
+ const NOT_ACQUIRED =
30
+ "The worker pool is not acquired: call acquireWorkerPool() first, and do not use the pool after its last holder released it.";
31
+
19
32
  const {
20
- initializeWorkerPool,
21
- terminateWorkerPool,
33
+ acquireWorkerPool,
34
+ workerPoolHolders,
22
35
  warmUpWorkerPool,
36
+ canWorkerPoolAcceptTasks,
37
+ workerPoolLoad,
38
+ reserveWorkerCore,
39
+ workerPoolReservedSlots,
23
40
  worker,
24
41
  workerPoolStats,
25
42
  workerPoolHeapStats,
@@ -30,43 +47,105 @@ const {
30
47
  | {
31
48
  pool: RecyclingWorkerPool;
32
49
  manager: ConcurrencyManager;
50
+ maxWorkerHeapBytes: number | undefined;
33
51
  }
34
52
  | undefined;
53
+ /** Cores held by threads outside the pool; outlives the pool itself, so a
54
+ * reservation taken before initialization (or across a re-init) holds. */
55
+ let coreReservations = 0;
56
+ let holders = 0;
35
57
 
36
58
  return {
37
- initializeWorkerPool: (
59
+ /**
60
+ * Claims the page-wide worker pool, creating it on the first claim. Every
61
+ * view shares the one pool so their tasks contend for the same cores;
62
+ * `url`, `manager` and `options` of later claims are ignored while the
63
+ * pool lives (a differing pool size or heap budget logs a warning).
64
+ */
65
+ acquireWorkerPool: (
38
66
  url: string,
39
67
  manager: ConcurrencyManager,
40
68
  options?: RecyclingWorkerPoolOptions,
41
- ) => {
42
- if (worker) {
43
- throw new Error("Worker pool has already been initialized.");
69
+ ): WorkerPoolLease => {
70
+ const created = !worker;
71
+ if (!worker) {
72
+ worker = {
73
+ // Recycles worker threads to bound WASM linear memory growth; see
74
+ // recyclingPool.ts for the policy.
75
+ pool: new RecyclingWorkerPool(url, manager.total, options),
76
+ manager,
77
+ maxWorkerHeapBytes: options?.maxWorkerHeapBytes,
78
+ };
79
+ worker.pool.setReservedSlots(coreReservations);
80
+ } else if (
81
+ manager.total !== worker.manager.total ||
82
+ options?.maxWorkerHeapBytes !== worker.maxWorkerHeapBytes
83
+ ) {
84
+ console.warn(
85
+ `The shared worker pool keeps its size (${worker.manager.total}) and worker heap budget (${worker.maxWorkerHeapBytes ?? "default"}); this claim asked for ${manager.total} and ${options?.maxWorkerHeapBytes ?? "default"}.`,
86
+ );
44
87
  }
88
+ holders++;
45
89
 
46
- worker = {
47
- // Recycles worker threads to bound WASM linear memory growth; see
48
- // recyclingPool.ts for the policy.
49
- pool: new RecyclingWorkerPool(url, manager.total, options),
50
- manager,
90
+ let released = false;
91
+ return {
92
+ manager: worker.manager,
93
+ created,
94
+ release: () => {
95
+ if (released) return;
96
+ released = true;
97
+ holders--;
98
+ if (holders === 0 && worker) {
99
+ worker.pool.terminate();
100
+ worker = undefined;
101
+ }
102
+ },
51
103
  };
52
104
  },
53
- terminateWorkerPool: () => {
54
- if (worker) {
55
- worker.pool.terminate();
56
- worker = undefined;
57
- }
58
- },
105
+ /** Live claims on the pool; see `acquireWorkerPool`. */
106
+ workerPoolHolders: () => holders,
59
107
  /**
60
108
  * Pre-initializes WASM on the workers so that upcoming tasks don't pay
61
109
  * the initialization latency. Warms a single worker and resolves once
62
110
  * it is ready; the rest warm in the background.
63
111
  */
64
112
  warmUpWorkerPool: () => {
65
- invariant(worker, "initializeWorkerPool() must be invoked first.");
113
+ invariant(worker, NOT_ACQUIRED);
66
114
  return worker.pool.warmUp();
67
115
  },
116
+ /**
117
+ * Whether at least one worker is under its WASM heap budget. While false
118
+ * the engine's worker-task intake must pause so the draining workers
119
+ * reach a task boundary and recycle.
120
+ */
121
+ canWorkerPoolAcceptTasks: () => worker?.pool.canAcceptTasks() ?? true,
122
+ /**
123
+ * Tasks the pool's workers have running or queued, including tasks
124
+ * cancelled while running; see RecyclingWorkerPool.load.
125
+ */
126
+ workerPoolLoad: () => worker?.pool.load() ?? 0,
127
+ /**
128
+ * Hold one core for a thread outside the pool (a font worker, a splat
129
+ * decoder) while it works: one pool slot stops taking tasks and its
130
+ * worker idles once its queued tasks finish. Returns the release
131
+ * function; calling it again is a no-op. At least one slot always keeps
132
+ * taking tasks.
133
+ */
134
+ reserveWorkerCore: (): (() => void) => {
135
+ coreReservations++;
136
+ worker?.pool.setReservedSlots(coreReservations);
137
+ let released = false;
138
+ return () => {
139
+ if (released) return;
140
+ released = true;
141
+ coreReservations--;
142
+ worker?.pool.setReservedSlots(coreReservations);
143
+ };
144
+ },
145
+ /** Pool slots kept free for threads outside the pool right now. */
146
+ workerPoolReservedSlots: () => worker?.pool.reservedSlots() ?? 0,
68
147
  worker: () => {
69
- invariant(worker, "initializeWorkerPool() must be invoked first.");
148
+ invariant(worker, NOT_ACQUIRED);
70
149
  return {
71
150
  pool: worker.pool,
72
151
  manager: worker.manager,
@@ -81,21 +160,18 @@ const {
81
160
  })();
82
161
 
83
162
  export {
84
- initializeWorkerPool,
85
- terminateWorkerPool,
163
+ acquireWorkerPool,
164
+ workerPoolHolders,
86
165
  warmUpWorkerPool,
166
+ canWorkerPoolAcceptTasks,
167
+ workerPoolLoad,
168
+ reserveWorkerCore,
169
+ workerPoolReservedSlots,
87
170
  workerPoolStats,
88
171
  workerPoolHeapStats,
89
172
  probeWorkerPoolHeap,
90
173
  };
91
174
 
92
- export const canWorkerProcessImmediately = () => {
93
- const { pool, manager } = worker();
94
- // Also pauses intake while every worker is over its WASM heap budget
95
- // (draining); a recycled worker restores capacity moments later.
96
- return manager.canIncrement() && pool.canAcceptTasks();
97
- };
98
-
99
175
  export type { ExecOptions } from "workerpool/types/types";
100
176
 
101
177
  type GetTaskName<Task> =
@@ -136,7 +212,9 @@ export function queueTask<
136
212
  // `finally` doesn't work, so use `then()` and `catch()` to ensure that `manager.decrement()` is invoked.
137
213
  .then((p) => {
138
214
  manager.decrement();
139
- return p;
215
+ // workerpool types the result as the task's raw return type, but it
216
+ // resolves with the awaited value.
217
+ return p as MethodReturnType<Task, Name>;
140
218
  })
141
219
  .catch((e) => {
142
220
  manager.decrement();
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
2
+
3
+ import { createConcurrencyManager, type ConcurrencyManager } from "./manager";
4
+ import {
5
+ acquireWorkerPool as acquireWorkerPoolRaw,
6
+ warmUpWorkerPool,
7
+ workerPoolHolders,
8
+ type WorkerPoolLease,
9
+ } from "./pool";
10
+ import type { RecyclingWorkerPoolOptions } from "./recyclingPool";
11
+
12
+ const { created, terminated } = vi.hoisted(() => ({
13
+ created: { count: 0 },
14
+ terminated: { count: 0 },
15
+ }));
16
+
17
+ vi.mock("./recyclingPool", () => ({
18
+ RecyclingWorkerPool: class {
19
+ constructor() {
20
+ created.count++;
21
+ }
22
+ setReservedSlots() {}
23
+ warmUp() {
24
+ return Promise.resolve();
25
+ }
26
+ terminate() {
27
+ terminated.count++;
28
+ }
29
+ },
30
+ }));
31
+
32
+ // Every lease a test takes, released after it so a failed assertion cannot
33
+ // leave the module-level pool alive for the next test.
34
+ const leases: WorkerPoolLease[] = [];
35
+ function acquireWorkerPool(
36
+ url: string,
37
+ manager: ConcurrencyManager,
38
+ options?: RecyclingWorkerPoolOptions,
39
+ ) {
40
+ const lease = acquireWorkerPoolRaw(url, manager, options);
41
+ leases.push(lease);
42
+ return lease;
43
+ }
44
+
45
+ beforeEach(() => {
46
+ created.count = 0;
47
+ terminated.count = 0;
48
+ });
49
+
50
+ afterEach(() => {
51
+ for (const lease of leases.splice(0)) lease.release();
52
+ vi.restoreAllMocks();
53
+ });
54
+
55
+ it("shares one pool and its manager between holders", () => {
56
+ const first = createConcurrencyManager(4);
57
+ const a = acquireWorkerPool("https://example.com", first);
58
+ const b = acquireWorkerPool(
59
+ "https://example.com",
60
+ createConcurrencyManager(2),
61
+ );
62
+
63
+ expect(created.count).toBe(1);
64
+ expect(a.manager).toBe(first);
65
+ expect(b.manager).toBe(first);
66
+
67
+ a.release();
68
+ b.release();
69
+ });
70
+
71
+ it("terminates the pool only when the last holder releases", () => {
72
+ const a = acquireWorkerPool(
73
+ "https://example.com",
74
+ createConcurrencyManager(1),
75
+ );
76
+ const b = acquireWorkerPool(
77
+ "https://example.com",
78
+ createConcurrencyManager(1),
79
+ );
80
+
81
+ a.release();
82
+ expect(terminated.count).toBe(0);
83
+ expect(() => warmUpWorkerPool()).not.toThrow();
84
+
85
+ b.release();
86
+ expect(terminated.count).toBe(1);
87
+ expect(() => warmUpWorkerPool()).toThrow();
88
+ });
89
+
90
+ it("ignores a repeated release so it cannot drop another holder's claim", () => {
91
+ const a = acquireWorkerPool(
92
+ "https://example.com",
93
+ createConcurrencyManager(1),
94
+ );
95
+ const b = acquireWorkerPool(
96
+ "https://example.com",
97
+ createConcurrencyManager(1),
98
+ );
99
+
100
+ a.release();
101
+ a.release();
102
+ expect(terminated.count).toBe(0);
103
+
104
+ b.release();
105
+ expect(terminated.count).toBe(1);
106
+ });
107
+
108
+ it("creates a fresh pool after every holder released", () => {
109
+ acquireWorkerPool(
110
+ "https://example.com",
111
+ createConcurrencyManager(1),
112
+ ).release();
113
+ const next = createConcurrencyManager(3);
114
+ const lease = acquireWorkerPool("https://example.com", next);
115
+
116
+ expect(created.count).toBe(2);
117
+ expect(lease.manager).toBe(next);
118
+
119
+ lease.release();
120
+ });
121
+
122
+ it("tells only the creating claim that it created the pool", () => {
123
+ const a = acquireWorkerPool(
124
+ "https://example.com",
125
+ createConcurrencyManager(1),
126
+ );
127
+ const b = acquireWorkerPool(
128
+ "https://example.com",
129
+ createConcurrencyManager(1),
130
+ );
131
+
132
+ expect(a.created).toBe(true);
133
+ expect(b.created).toBe(false);
134
+ expect(workerPoolHolders()).toBe(2);
135
+ });
136
+
137
+ it("warns when a later claim asks for different pool settings", () => {
138
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
139
+ acquireWorkerPool("https://example.com", createConcurrencyManager(4), {
140
+ maxWorkerHeapBytes: 256,
141
+ });
142
+
143
+ acquireWorkerPool("https://example.com", createConcurrencyManager(4), {
144
+ maxWorkerHeapBytes: 256,
145
+ });
146
+ expect(warn).not.toHaveBeenCalled();
147
+
148
+ acquireWorkerPool("https://example.com", createConcurrencyManager(2), {
149
+ maxWorkerHeapBytes: 128,
150
+ });
151
+ expect(warn).toHaveBeenCalledOnce();
152
+ });
@@ -17,6 +17,10 @@ type MockExecution = {
17
17
 
18
18
  type MockPool = {
19
19
  executions: MockExecution[];
20
+ /** Busy / queued counts the worker reports, independent of the slot's
21
+ * own `inflight` (e.g. a cancelled task still running). */
22
+ activeTasks: number;
23
+ pendingTasks: number;
20
24
  exec: ReturnType<typeof vi.fn>;
21
25
  terminate: ReturnType<typeof vi.fn>;
22
26
  stats: () => ReturnType<Pool["stats"]>;
@@ -29,6 +33,8 @@ let pools: MockPool[] = [];
29
33
  vi.spyOn(workerpool, "pool").mockImplementation(() => {
30
34
  const mock: MockPool = {
31
35
  executions: [],
36
+ activeTasks: 0,
37
+ pendingTasks: 0,
32
38
  exec: vi.fn(
33
39
  (method: string) =>
34
40
  new workerpool.Promise(
@@ -57,8 +63,8 @@ vi.spyOn(workerpool, "pool").mockImplementation(() => {
57
63
  totalWorkers: 1,
58
64
  busyWorkers: 0,
59
65
  idleWorkers: 1,
60
- pendingTasks: 0,
61
- activeTasks: 0,
66
+ pendingTasks: mock.pendingTasks,
67
+ activeTasks: mock.activeTasks,
62
68
  }),
63
69
  };
64
70
  pools.push(mock);
@@ -115,6 +121,91 @@ it("dispatches tasks to the least-loaded slot", () => {
115
121
  expect(pools[1].exec).toHaveBeenCalledTimes(1);
116
122
  });
117
123
 
124
+ it("does not dispatch to a worker still running a cancelled task", async () => {
125
+ const pool = new RecyclingWorkerPool("https://example.com", 2);
126
+
127
+ const cancelled = pool.exec("test");
128
+ cancelled.cancel();
129
+ await cancelled.catch(() => undefined);
130
+ respondToProbe(pools[0], 0);
131
+ // The slot's own count is back to 0, but its worker is still busy.
132
+ pools[0].activeTasks = 1;
133
+
134
+ pool.exec("test");
135
+ const tests = (p: MockPool) =>
136
+ p.executions.filter((e) => e.method === "test").length;
137
+ expect(tests(pools[0])).toBe(1);
138
+ expect(tests(pools[1])).toBe(1);
139
+ });
140
+
141
+ it("sums the slots' load, counting a cancelled task still running", async () => {
142
+ const pool = new RecyclingWorkerPool("https://example.com", 2);
143
+ expect(pool.load()).toBe(0);
144
+
145
+ const cancelled = pool.exec("test");
146
+ pool.exec("test");
147
+ expect(pool.load()).toBe(2);
148
+
149
+ cancelled.cancel();
150
+ await cancelled.catch(() => undefined);
151
+ respondToProbe(pools[0], 0);
152
+ expect(pool.load()).toBe(1);
153
+ // Its worker is still running it.
154
+ pools[0].activeTasks = 1;
155
+ expect(pool.load()).toBe(2);
156
+ });
157
+
158
+ it("leaves the pool's own probes out of its load", async () => {
159
+ const pool = new RecyclingWorkerPool("https://example.com", 1);
160
+
161
+ pool.exec("test");
162
+ settleTask(pools[0]);
163
+ await vi.advanceTimersByTimeAsync(0);
164
+ expect(nextPending(pools[0], PROBE)).toBeDefined();
165
+ expect(pool.load()).toBe(0);
166
+ });
167
+
168
+ it("keeps reserved slots free of new tasks, leaving one slot open", () => {
169
+ const pool = new RecyclingWorkerPool("https://example.com", 3);
170
+ const tests = (p: MockPool) =>
171
+ p.executions.filter((e) => e.method === "test").length;
172
+
173
+ // Slot 0 is busy, so the idle slots are the ones reserved.
174
+ pool.exec("test");
175
+ pool.setReservedSlots(2);
176
+ expect(pool.reservedSlots()).toBe(2);
177
+ pool.exec("test");
178
+ pool.exec("test");
179
+ expect(tests(pools[0])).toBe(3);
180
+ expect(tests(pools[1])).toBe(0);
181
+ expect(tests(pools[2])).toBe(0);
182
+
183
+ pool.setReservedSlots(5);
184
+ expect(pool.reservedSlots()).toBe(2);
185
+
186
+ pool.setReservedSlots(0);
187
+ expect(pool.reservedSlots()).toBe(0);
188
+ pool.exec("test");
189
+ expect(tests(pools[1]) + tests(pools[2])).toBe(1);
190
+ });
191
+
192
+ it("reports no capacity while every unreserved slot is draining", async () => {
193
+ const pool = new RecyclingWorkerPool("https://example.com", 2, {
194
+ maxWorkerHeapBytes: 100,
195
+ });
196
+ pool.exec("test");
197
+ pool.setReservedSlots(1);
198
+ expect(pool.canAcceptTasks()).toBe(true);
199
+
200
+ // The open slot goes over budget while its next task still runs.
201
+ pool.exec("test");
202
+ settleTask(pools[0]);
203
+ await vi.advanceTimersByTimeAsync(0);
204
+ respondToProbe(pools[0], 200);
205
+ await vi.advanceTimersByTimeAsync(0);
206
+ expect(pool.canAcceptTasks()).toBe(false);
207
+ });
208
+
118
209
  it("probes heap usage after a settled task and recycles once over budget", async () => {
119
210
  const pool = new RecyclingWorkerPool("https://example.com", 1, {
120
211
  maxWorkerHeapBytes: 100,
@@ -262,6 +353,17 @@ it("warmUp() warms a single worker first, then the rest in the background", asyn
262
353
  expect(nextPending(pools[1], "warmUp")).toBeDefined();
263
354
  });
264
355
 
356
+ it("warmUp() starts no background warm-up once terminated", async () => {
357
+ const pool = new RecyclingWorkerPool("https://example.com", 2);
358
+
359
+ const warmed = pool.warmUp();
360
+ pool.terminate();
361
+ nextPending(pools[0], "warmUp")?.resolve();
362
+ await warmed;
363
+
364
+ expect(pools[1].executions).toHaveLength(0);
365
+ });
366
+
265
367
  it("warmUp() falls back to the remaining workers when the first fails", async () => {
266
368
  const pool = new RecyclingWorkerPool("https://example.com", 2);
267
369
 
@@ -18,8 +18,9 @@ import type { ExecOptions } from "workerpool/types/types";
18
18
  // bounded even under sustained load (no pool-wide idle moment is required)
19
19
  // and never discards more than one warm WASM instance at a time. The
20
20
  // trade-off versus a single shared queue is possible head-of-line blocking
21
- // within a slot, but upstream backpressure (ConcurrencyManager) keeps
22
- // per-slot queues shallow.
21
+ // within a slot, but upstream backpressure keeps per-slot queues shallow:
22
+ // the engine admits only about two tasks per worker, and its intake pauses
23
+ // while every slot is draining (see canAcceptTasks).
23
24
  //
24
25
  // A burst that stays under the heap budget would otherwise keep its heaps
25
26
  // forever, so an idle sweep additionally recycles every used slot once the
@@ -62,6 +63,12 @@ type Slot = {
62
63
  tasksSinceSpawn: number;
63
64
  /** Dispatched but not yet settled tasks, including warm-ups and probes. */
64
65
  inflight: number;
66
+ /** Warm-ups and probes in flight (the pool's own traffic, part of
67
+ * `inflight`). */
68
+ internal: number;
69
+ /** Kept free for a thread outside the pool: receives no new task, so its
70
+ * worker idles once the tasks it has finish (see setReservedSlots). */
71
+ reserved: boolean;
65
72
  /** Set once the slot exceeds its heap budget; drained slots recycle. */
66
73
  draining: boolean;
67
74
  /** Whether a heap probe is already in flight (probes are coalesced). */
@@ -84,6 +91,19 @@ export type WorkerPoolHeapStats = {
84
91
  maxWorkerHeapBytes: number;
85
92
  };
86
93
 
94
+ /**
95
+ * Tasks a slot's worker still has ahead of a new one. `inflight` drops as
96
+ * soon as a task is cancelled, but a task cancelled while running keeps the
97
+ * worker busy until it finishes (the worker-side abort listener lets it run
98
+ * to completion), so the worker's own busy/queued counts are consulted too.
99
+ * Cancelled tasks still queued are skipped instantly once the worker frees;
100
+ * counting them only steers new work away from a busy worker.
101
+ */
102
+ const slotLoad = (slot: Slot): number => {
103
+ const { activeTasks, pendingTasks } = slot.pool.stats();
104
+ return Math.max(slot.inflight, activeTasks + pendingTasks);
105
+ };
106
+
87
107
  const createPool = (url: string) =>
88
108
  workerpool.pool(url, {
89
109
  // One worker per pool so each worker can be recycled independently.
@@ -116,6 +136,8 @@ export class RecyclingWorkerPool {
116
136
  pool: createPool(url),
117
137
  tasksSinceSpawn: 0,
118
138
  inflight: 0,
139
+ internal: 0,
140
+ reserved: false,
119
141
  draining: false,
120
142
  probing: false,
121
143
  lastHeapBytes: undefined,
@@ -189,7 +211,62 @@ export class RecyclingWorkerPool {
189
211
  * least-loaded draining slot).
190
212
  */
191
213
  canAcceptTasks(): boolean {
192
- return this.slots.some((slot) => !slot.draining);
214
+ return this.slots.some((slot) => !slot.draining && !slot.reserved);
215
+ }
216
+
217
+ /**
218
+ * Keep `count` slots free for threads outside the pool (clamped so one
219
+ * slot always takes tasks): a reserved slot receives no new task, so its
220
+ * worker idles and leaves its core to them once its queued tasks finish.
221
+ * Newly reserved slots are the least loaded ones, which free soonest.
222
+ */
223
+ setReservedSlots(count: number): void {
224
+ const target = Math.max(0, Math.min(count, this.slots.length - 1));
225
+ let reserved = this.reservedSlots();
226
+ while (reserved < target) {
227
+ let least: Slot | undefined;
228
+ let leastLoad = Infinity;
229
+ for (const slot of this.slots) {
230
+ if (slot.reserved) continue;
231
+ const load = slotLoad(slot);
232
+ if (load < leastLoad) {
233
+ least = slot;
234
+ leastLoad = load;
235
+ }
236
+ }
237
+ if (!least) break;
238
+ least.reserved = true;
239
+ reserved++;
240
+ }
241
+ for (const slot of this.slots) {
242
+ if (reserved <= target) break;
243
+ if (slot.reserved) {
244
+ slot.reserved = false;
245
+ reserved--;
246
+ }
247
+ }
248
+ }
249
+
250
+ /** Slots currently kept free (see setReservedSlots). */
251
+ reservedSlots(): number {
252
+ let reserved = 0;
253
+ for (const slot of this.slots) {
254
+ if (slot.reserved) reserved++;
255
+ }
256
+ return reserved;
257
+ }
258
+
259
+ /**
260
+ * Tasks handed to the pool that the workers still have, summed over the
261
+ * slots: running and queued, including tasks cancelled while running (see
262
+ * slotLoad). The pool's own warm-ups and probes are left out.
263
+ */
264
+ load(): number {
265
+ let load = 0;
266
+ for (const slot of this.slots) {
267
+ load += Math.max(0, slotLoad(slot) - slot.internal);
268
+ }
269
+ return load;
193
270
  }
194
271
 
195
272
  stats(): WorkerPoolStats {
@@ -253,14 +330,23 @@ export class RecyclingWorkerPool {
253
330
  }
254
331
 
255
332
  private pickSlot(): Slot {
333
+ // Reserved slots never take a task (one slot always stays unreserved).
256
334
  // Prefer slots still under their heap budget; fall back to a draining
257
335
  // slot only when every slot is draining at once (it recycles once it
258
336
  // finally drains, so this cannot starve the recycling).
259
- const candidates = this.slots.filter((slot) => !slot.draining);
260
- const eligible = candidates.length > 0 ? candidates : this.slots;
261
- return eligible.reduce((least, slot) =>
262
- slot.inflight < least.inflight ? slot : least,
263
- );
337
+ const open = this.slots.filter((slot) => !slot.reserved);
338
+ const candidates = open.filter((slot) => !slot.draining);
339
+ const eligible = candidates.length > 0 ? candidates : open;
340
+ let least = eligible[0];
341
+ let leastLoad = slotLoad(least);
342
+ for (const slot of eligible) {
343
+ const load = slotLoad(slot);
344
+ if (load < leastLoad) {
345
+ least = slot;
346
+ leastLoad = load;
347
+ }
348
+ }
349
+ return least;
264
350
  }
265
351
 
266
352
  private onSettled(slot: Slot): void {
@@ -297,9 +383,14 @@ export class RecyclingWorkerPool {
297
383
  // perpetuate itself through the work it triggers.
298
384
 
299
385
  private warmSlot(slot: Slot): Promise<void> {
386
+ // A terminated workerpool still spawns a worker on exec, and the
387
+ // background warm-ups can start after terminate().
388
+ if (this.terminated) return Promise.resolve();
300
389
  slot.inflight++;
390
+ slot.internal++;
301
391
  const settle = () => {
302
392
  slot.inflight--;
393
+ slot.internal--;
303
394
  // Same as the probe: if the slot drained while this warm-up was in
304
395
  // flight, its settlement is the last task boundary, so recycle here.
305
396
  if (!this.terminated && slot.draining && slot.inflight === 0) {
@@ -336,9 +427,11 @@ export class RecyclingWorkerPool {
336
427
  if (slot.probing) return;
337
428
  slot.probing = true;
338
429
  slot.inflight++;
430
+ slot.internal++;
339
431
  const settle = (overBudget: boolean) => {
340
432
  slot.probing = false;
341
433
  slot.inflight--;
434
+ slot.internal--;
342
435
  if (this.terminated) return;
343
436
  if (overBudget) {
344
437
  slot.draining = true;