knitting 0.1.61 → 0.1.62

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/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getCallerFilePath, getCallerHref, setModuleUrl, } from "./common/task-source.js";
2
2
  import { DEBUG_ENABLED, resolveDebugNamespaces } from "./debug/gate.js";
3
- import { genTaskID } from "./common/task-source.js";
3
+ import { genTaskID, stableTaskID } from "./common/task-source.js";
4
4
  import { toModuleUrl } from "./common/module-url.js";
5
5
  import { endpointSymbol } from "./common/task-symbol.js";
6
6
  import { createStealPoolBuffers, MAX_STEAL_CONSUMERS, spawnWorkerContext, } from "./runtime/pool.js";
@@ -9,7 +9,8 @@ import { createSharedArrayBuffer, RUNTIME } from "./common/runtime.js";
9
9
  import { RUNTIME_IS_MAIN_THREAD, RUNTIME_POOL_DEPTH, RUNTIME_WORKER_DATA, } from "./common/worker-runtime.js";
10
10
  import { classifyProcessPermissionCompatibility, enforceProcessPermissionCompatibility, resolvePermissionProtocol, toRuntimePermissionFlags, } from "./permission/index.js";
11
11
  import { getNodeProcess } from "./common/node-compat.js";
12
- import { readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
12
+ import { readProcessSharedMemorySettings, readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
13
+ import { TRANSPORT_SIGNAL_BYTES } from "./ipc/transport/shared-memory.js";
13
14
  import { inspectCompiledWorkerArtifact } from "./runtime/compiled-artifact.js";
14
15
  import { managerMethod } from "./runtime/balancer.js";
15
16
  import { createInlineExecutor } from "./runtime/inline-executor.js";
@@ -370,17 +371,14 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
370
371
  : stealEnvRaw === "0" || stealEnvRaw === "false"
371
372
  ? false
372
373
  : undefined;
373
- // Shared-submit stealing is the default for compatible multi-worker thread
374
- // pools. An explicit option wins over the environment; process/compiled/
375
- // inline transports retain their existing topology unless the caller
376
- // explicitly requests an unsupported combination.
374
+ // Shared-submit stealing is the default for compatible multi-worker pools.
375
+ // An explicit option wins over the environment; compiled and inline
376
+ // transports retain their existing topology.
377
377
  const dispatcherExplicitlySelected = host?.dispatcher !== undefined ||
378
378
  dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread";
379
379
  const stealDefaultCompatible = balancer === undefined &&
380
380
  !dispatcherExplicitlySelected && (threads ?? 1) <= MAX_STEAL_CONSUMERS;
381
381
  const stealRequested = host?.steal ?? stealEnv ?? stealDefaultCompatible;
382
- const stealExplicitlyEnabled = host?.steal === true ||
383
- (host?.steal === undefined && stealEnv === true);
384
382
  const usingCompiledWorker = resolvedWorker?.runtime === "compiled";
385
383
  if (resolvedWorker?.compiled !== undefined && !usingCompiledWorker) {
386
384
  throw new Error("worker.compiled requires worker.runtime to be compiled");
@@ -435,9 +433,6 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
435
433
  if (usingInliner && resolvedWorker?.bootstrap !== undefined) {
436
434
  throw new Error("worker.bootstrap cannot be used with the inliner");
437
435
  }
438
- if (stealExplicitlyEnabled && resolvedWorker?.runtime === "process") {
439
- throw new Error("host.steal does not support process workers; use worker threads or disable stealing");
440
- }
441
436
  if (resolvedWorker?.runtime === "process") {
442
437
  const processRuntime = readProcessWorkerRuntime(resolvedWorker);
443
438
  enforceProcessPermissionCompatibility(classifyProcessPermissionCompatibility({
@@ -479,12 +474,11 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
479
474
  })();
480
475
  const dispatcher = explicitDispatcher ?? autoDispatcher;
481
476
  // Work stealing: one shared submit region, private return lanes, one
482
- // pool-global pending registry. It is the compatible multi-thread default;
477
+ // pool-global pending registry. It is the compatible multi-worker default;
483
478
  // an explicit balancer/dispatcher keeps the private-lane topology unless
484
479
  // stealing itself was explicitly requested.
485
480
  const useSteal = stealRequested &&
486
481
  !usingCompiledWorker &&
487
- resolvedWorker?.runtime !== "process" &&
488
482
  (threads ?? 1) > 1 &&
489
483
  !usingInliner;
490
484
  const stealBuffers = useSteal
@@ -494,12 +488,22 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
494
488
  regionLanes: host?.stealRegionLanes,
495
489
  abortSignalCapacity,
496
490
  usesAbortSignal,
491
+ processWorker: resolvedWorker?.runtime === "process"
492
+ ? {
493
+ signalBytes: TRANSPORT_SIGNAL_BYTES,
494
+ sharedMemory: readProcessSharedMemorySettings(resolvedWorker),
495
+ }
496
+ : undefined,
497
497
  })
498
498
  : undefined;
499
499
  const serialChannel = !usingCompiledWorker && !useSteal &&
500
500
  dispatcher === "serial-channel";
501
+ // One serial-channel hop drives every lane check in turn, and it depends on
502
+ // the channel's delivery to do that: swapping in the cheaper `setImmediate`
503
+ // pump cost 25% throughput and tripled p99 here, where every other topology
504
+ // gained. See ChannelHandler's note.
501
505
  const serialDispatcherChannel = serialChannel
502
- ? new ChannelHandler()
506
+ ? new ChannelHandler("channel")
503
507
  : undefined;
504
508
  let workers = Array.from({
505
509
  length: threads ?? 1,
@@ -533,6 +537,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
533
537
  regionLanes: stealBuffers.regionLanes,
534
538
  abortSignalSAB: stealBuffers.abortSignalSAB,
535
539
  abortSignalMax: stealBuffers.abortSignalMax,
540
+ processMemory: stealBuffers.processMemory,
536
541
  },
537
542
  }));
538
543
  const stealChannel = useSteal ? new ChannelHandler() : undefined;
@@ -565,6 +570,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
565
570
  channelHandler: channel,
566
571
  dispatcherOptions: host,
567
572
  notifySignal: wakeOne,
573
+ crossProcess: resolvedWorker?.runtime === "process",
568
574
  });
569
575
  channel.open(check);
570
576
  workers.forEach((context) => {
@@ -843,7 +849,7 @@ const buildTaskDefinitionFromCaller = (input, callerHref, at, imported = false)
843
849
  const importedFrom = new URL(callerHref).href;
844
850
  const out = ({
845
851
  ...input,
846
- id: genTaskID(),
852
+ id: stableTaskID(importedFrom, at),
847
853
  importedFrom,
848
854
  at,
849
855
  imported,
@@ -1,4 +1,13 @@
1
1
  export declare const genTaskID: () => number;
2
+ /**
3
+ * Identity of a task, stable across processes.
4
+ *
5
+ * `genTaskID` numbers tasks by the order their module happened to be evaluated,
6
+ * which host and worker do not share: the host imports in its own source order
7
+ * and the worker imports in task-name order. `(href, at)` is the same pair in
8
+ * both, because `at` counts `task()` calls within one module.
9
+ */
10
+ export declare const stableTaskID: (href: string, at: number) => number;
2
11
  export declare const setModuleUrl: (url: string | undefined) => void;
3
12
  export declare const getModuleUrlOverride: () => string | undefined;
4
13
  export declare const getCallerHref: (offset?: number) => string;
@@ -1,5 +1,20 @@
1
1
  import { toModuleUrl } from "./module-url.js";
2
2
  export const genTaskID = ((counter) => () => counter++)(0);
3
+ /**
4
+ * Identity of a task, stable across processes.
5
+ *
6
+ * `genTaskID` numbers tasks by the order their module happened to be evaluated,
7
+ * which host and worker do not share: the host imports in its own source order
8
+ * and the worker imports in task-name order. `(href, at)` is the same pair in
9
+ * both, because `at` counts `task()` calls within one module.
10
+ */
11
+ export const stableTaskID = (href, at) => {
12
+ let hash = 0x811c9dc5;
13
+ for (let index = 0; index < href.length; index++) {
14
+ hash = Math.imul(hash ^ href.charCodeAt(index), 0x01000193);
15
+ }
16
+ return (Math.imul(hash ^ at, 0x01000193) >>> 0);
17
+ };
3
18
  const INTERNAL_CALLER_HINTS = [
4
19
  "/src/common/task-source.ts",
5
20
  "/src/common/task-source.js",
@@ -173,6 +173,8 @@ export declare const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32: number;
173
173
  * clear here. This preserves single-writer ownership of every WANT word.
174
174
  */
175
175
  export declare const STEAL_LIVE_SLOT_OFFSET_U32: number;
176
+ /** Host-owned arm word for the return-lock completion doorbell. */
177
+ export declare const DOORBELL_ARMED_SLOT_OFFSET_U32: number;
176
178
  export declare const HEADER_U32_LENGTH: number;
177
179
  export declare const HEADER_BYTE_LENGTH: number;
178
180
  export declare const makeTask: () => Task;
@@ -191,7 +193,12 @@ type ResolveHostOptions = {
191
193
  * - encode/decode are not re-entrant; payload codec uses a shared scratch buffer.
192
194
  */
193
195
  export type Lock2 = ReturnType<typeof lock2>;
194
- export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }: {
196
+ export type WaitAsyncState = "not-equal" | "ok" | "timed-out";
197
+ export type WaitAsyncResult = {
198
+ async: boolean;
199
+ value: WaitAsyncState | PromiseLike<WaitAsyncState>;
200
+ };
201
+ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, notifyOnHostPublish, }: {
195
202
  headers?: SharedBufferSource;
196
203
  headerSlotStrideU32?: number;
197
204
  LockBoundSector?: SharedBufferSource;
@@ -213,6 +220,8 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
213
220
  consumerId?: number;
214
221
  /** Lanes claimed per Dekker handshake. Paper rule: `slots / regionLanes >= consumers + 1`. */
215
222
  regionLanes?: number;
223
+ /** Notify a host-side wait after this endpoint publishes a frame. */
224
+ notifyOnHostPublish?: boolean;
216
225
  }) => {
217
226
  enlist: (task: Task) => true;
218
227
  encode: (task: Task, state?: number) => boolean;
@@ -227,6 +236,8 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
227
236
  workerBits: Int32Array<import("../common/shared-buffer-region.js").SharedBuffer>;
228
237
  recyclecList: RingQueue<Task>;
229
238
  resolveHost: ({ queue, onResolved, shouldSettle, activeRejectPlaceholder, }: ResolveHostOptions) => () => number;
239
+ waitForHostChange: (timeoutMs?: number) => WaitAsyncResult | undefined;
240
+ setHostWaiterArmed: (armed: boolean) => void;
230
241
  hasPendingFrames: () => boolean;
231
242
  getPendingFrameCount: () => number;
232
243
  getPendingPromiseCount: () => number;
@@ -247,6 +247,8 @@ export const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 2;
247
247
  * clear here. This preserves single-writer ownership of every WANT word.
248
248
  */
249
249
  export const STEAL_LIVE_SLOT_OFFSET_U32 = STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 + 1;
250
+ /** Host-owned arm word for the return-lock completion doorbell. */
251
+ export const DOORBELL_ARMED_SLOT_OFFSET_U32 = STEAL_LIVE_SLOT_OFFSET_U32 + 1;
250
252
  export const HEADER_U32_LENGTH = LockBound.header +
251
253
  (HEADER_SLOT_STRIDE_U32 * LockBound.slots);
252
254
  export const HEADER_BYTE_LENGTH = HEADER_U32_LENGTH *
@@ -297,7 +299,7 @@ const settleTask = (task) => {
297
299
  task[TaskIndex["FlagsToHost"]] = 0;
298
300
  }
299
301
  };
300
- export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }) => {
302
+ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, notifyOnHostPublish, }) => {
301
303
  // Layout within `lockSectorRegion`:
302
304
  // - hostBits starts at byte 0
303
305
  // - workerBits starts at byte 64
@@ -316,6 +318,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
316
318
  const headersRegion = toSharedBufferRegion(headers ?? createWasmSharedArrayBuffer(HEADER_BYTE_LENGTH));
317
319
  const headersBuffer = new Uint32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
318
320
  const headersSlotStride = headerSlotStrideU32 ?? HEADER_SLOT_STRIDE_U32;
321
+ // The first task cache line has four unused control words after the task
322
+ // header. Keep the doorbell arm bit in one of those words so the host and
323
+ // worker lock instances can share it without allocating another SAB.
324
+ const doorbellArmed = new Int32Array(headersRegion.sab, headersRegion.byteOffset +
325
+ (DOORBELL_ARMED_SLOT_OFFSET_U32 * Uint32Array.BYTES_PER_ELEMENT), 1);
319
326
  const resolvedPayloadConfig = resolvePayloadBufferOptions({
320
327
  sab: payload,
321
328
  options: payloadConfig,
@@ -428,6 +435,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
428
435
  // Atomics aliases (hot path)
429
436
  const a_load = Atomics.load;
430
437
  const a_store = Atomics.store;
438
+ const a_notify = Atomics.notify;
439
+ const shouldNotifyHostPublish = notifyOnHostPublish === true;
440
+ const a_waitAsync = typeof Atomics.waitAsync === "function"
441
+ ? Atomics.waitAsync.bind(Atomics)
442
+ : undefined;
431
443
  // Sender-side cached shadow of the receiver-owned queue word. Under the XSC
432
444
  // false-busy-only sender-side staleness property, this may hide newly freed
433
445
  // lanes but cannot make a genuinely pending lane appear free. Refresh only
@@ -560,7 +572,12 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
560
572
  deferredCount = 0 | 0;
561
573
  return toBeSent.isEmpty;
562
574
  };
563
- const storeHost = (bit) => a_store(hostBits, 0, LastLocal = (LastLocal ^ bit) | 0);
575
+ const storeHost = (bit) => {
576
+ a_store(hostBits, 0, LastLocal = (LastLocal ^ bit) | 0);
577
+ if (shouldNotifyHostPublish && a_load(doorbellArmed, 0) !== 0) {
578
+ a_notify(hostBits, 0, 1);
579
+ }
580
+ };
564
581
  const storeWorker = (bit) => a_store(workerBits, 0, LastWorker = (LastWorker ^ bit) | 0);
565
582
  const encode = (task, state = (LastLocal ^ workerShadow) | 0) => {
566
583
  state = ensureSenderStateHasFree(state);
@@ -878,6 +895,34 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
878
895
  return modified;
879
896
  };
880
897
  };
898
+ /**
899
+ * HOST SIDE: wait until the producer changes hostBits.
900
+ *
901
+ * LastWorker is the host's acknowledgement shadow. Passing it as the
902
+ * expected value makes the arm race-free: a publication between the drain
903
+ * and this call returns `not-equal` synchronously instead of being lost.
904
+ */
905
+ const waitForHostChange = (timeoutMs) => {
906
+ if (a_waitAsync === undefined) {
907
+ a_store(doorbellArmed, 0, 0);
908
+ return undefined;
909
+ }
910
+ a_store(doorbellArmed, 0, 1);
911
+ try {
912
+ const wait = a_waitAsync(hostBits, 0, LastWorker | 0, timeoutMs);
913
+ if (!wait.async)
914
+ a_store(doorbellArmed, 0, 0);
915
+ return wait;
916
+ }
917
+ catch {
918
+ a_store(doorbellArmed, 0, 0);
919
+ // Some runtimes reject particular SharedArrayBuffer implementations
920
+ // (for example a growable or native-backed buffer). Fall back to the
921
+ // existing dispatcher rather than turning a capability issue into a
922
+ // hung pool.
923
+ return undefined;
924
+ }
925
+ };
881
926
  const decodeAt = (at) => {
882
927
  const off = (at * headersSlotStride) + slotBaseU32;
883
928
  const recycled = recycleShift();
@@ -951,6 +996,10 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
951
996
  workerBits,
952
997
  recyclecList,
953
998
  resolveHost,
999
+ waitForHostChange,
1000
+ setHostWaiterArmed: (armed) => {
1001
+ a_store(doorbellArmed, 0, armed ? 1 : 0);
1002
+ },
954
1003
  hasPendingFrames: () => toBeSent.size !== 0,
955
1004
  getPendingFrameCount: () => toBeSent.size | 0,
956
1005
  getPendingPromiseCount: () => pendingPromiseCount | 0,
@@ -2,12 +2,14 @@ import { type MultiQueue } from "./tx-queue.js";
2
2
  import { type MainSignal } from "../ipc/transport/shared-memory.js";
3
3
  import { type RuntimeMessageChannelLike, type RuntimeMessagePortLike } from "../common/worker-runtime.js";
4
4
  import type { DispatcherSettings } from "../types.js";
5
- export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, }, channelHandler, dispatcherOptions, notifySignal, }: {
5
+ export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }: {
6
6
  queue: MultiQueue;
7
7
  signalBox: MainSignal;
8
8
  channelHandler: ChannelHandler;
9
9
  dispatcherOptions?: DispatcherSettings;
10
10
  notifySignal?: () => void;
11
+ /** Workers live in other processes; disables the doorbell. */
12
+ crossProcess?: boolean;
11
13
  }) => {
12
14
  check: {
13
15
  (): void;
@@ -20,20 +22,21 @@ type CheckWithState = (() => void) & {
20
22
  rerun: boolean;
21
23
  };
22
24
  export type DispatcherCheck = CheckWithState;
25
+ export type ChannelHandlerPump = "auto" | "channel";
23
26
  export declare class ChannelHandler {
24
27
  #private;
25
- channel: RuntimeMessageChannelLike;
26
- port1: RuntimeMessagePortLike;
27
- port2: RuntimeMessagePortLike;
28
- constructor();
28
+ channel: RuntimeMessageChannelLike | undefined;
29
+ port1: RuntimeMessagePortLike | undefined;
30
+ port2: RuntimeMessagePortLike | undefined;
31
+ constructor(pump?: ChannelHandlerPump);
29
32
  notify(): void;
30
33
  /**
31
- * Opens the channel (if not already open) and sets the onmessage handler.
32
- * This is the setup so `notify` can send a message to the port 1.
34
+ * Registers the handler the pump calls back into. On the channel pump this is
35
+ * also where the ports are opened, so `notify` can reach port 1.
33
36
  */
34
37
  open(f: () => void): void;
35
38
  /**
36
- * Closes the channel if it is open.
39
+ * Detaches the handler, and closes the channel if this pump has one.
37
40
  */
38
41
  close(): void;
39
42
  }
@@ -1,5 +1,34 @@
1
1
  import { createRuntimeMessageChannel, } from "../common/worker-runtime.js";
2
- export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, }, channelHandler, dispatcherOptions, notifySignal, }) => {
2
+ import { RUNTIME, SET_IMMEDIATE } from "../common/runtime.js";
3
+ /**
4
+ * Macrotask primitive for the host pump, picked per runtime: a round trip costs
5
+ * 757ns on Bun via MessageChannel but 4662ns on Deno, where `setImmediate` is
6
+ * 1110ns. Bun keeps the channel; browser and Andromeda have no choice.
7
+ *
8
+ * `serial-channel` opts back into the channel explicitly (see `src/api.ts`): one
9
+ * hop drives every lane's check in turn and relies on the channel's delivery to
10
+ * do it, so a merely cheaper pump starves it -- it cost 25% throughput there.
11
+ */
12
+ const IMMEDIATE_PUMP = RUNTIME === "deno" || RUNTIME === "node"
13
+ ? SET_IMMEDIATE
14
+ : undefined;
15
+ /**
16
+ * Free drain hops before `scheduleNotify` stops re-arming the pump for free.
17
+ *
18
+ * The window has to match what the dispatcher escalates *to*, so these are
19
+ * chosen together and `canUseDoorbell` picks between them. Polling escalates to
20
+ * the `setTimeout` ladder, whose finest rung is ~1.1ms on every runtime, so it
21
+ * wants a wide window; that sleep is also load-bearing, since it batches
22
+ * completions. A doorbell escalates to `Atomics.waitAsync` at about the price of
23
+ * one hop, so it wants a narrow one.
24
+ *
25
+ * Mixing them is the trap: a doorbell behind the wide window keeps every poll
26
+ * hop *and* adds the arm, and loses to plain polling at every thread count.
27
+ * Measurements behind both values: `docs/host-doorbell-proposal.md`.
28
+ */
29
+ const POLL_STALL_FREE_LOOPS = 128;
30
+ const DOORBELL_STALL_FREE_LOOPS = 1;
31
+ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }) => {
3
32
  const a_load = Atomics.load;
4
33
  const a_store = Atomics.store;
5
34
  const a_notify = Atomics.notify;
@@ -10,13 +39,81 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
10
39
  a_notify(opView, 0, 1);
11
40
  });
12
41
  const notify = () => channelHandler.notify();
42
+ // `crossProcess` is a capability, not a preference, so it overrides an
43
+ // explicit `doorbell: true`: V8's Atomics waiter list is per isolate, so a
44
+ // worker in another process can never ring the host's waiter, and an armed
45
+ // doorbell would just sleep to the watchdog.
46
+ const canUseDoorbell = crossProcess !== true &&
47
+ (dispatcherOptions?.doorbell ?? true) &&
48
+ (RUNTIME === "bun" || RUNTIME === "node") &&
49
+ typeof Atomics.waitAsync === "function";
50
+ let doorbellEnabled = canUseDoorbell;
51
+ let doorbellArmed = false;
52
+ let doorbellEpoch = 0 | 0;
53
+ const DOORBELL_WATCHDOG_MS = 1000;
13
54
  let stallCount = 0 | 0;
14
- const STALL_FREE_LOOPS = Math.max(0, (dispatcherOptions?.stallFreeLoops ?? 128) | 0);
55
+ const requestedStallFreeLoops = dispatcherOptions?.stallFreeLoops;
56
+ let stallFreeLoops = requestedStallFreeLoops !== undefined
57
+ ? Math.max(0, requestedStallFreeLoops | 0)
58
+ : canUseDoorbell
59
+ ? DOORBELL_STALL_FREE_LOOPS
60
+ : POLL_STALL_FREE_LOOPS;
15
61
  const MAX_BACKOFF_MS = Math.max(0, (dispatcherOptions?.maxBackoffMs ?? 10) | 0);
16
62
  let backoffTimer;
17
63
  // inFlight prevents re-entrancy when pool.ts fires check() concurrently
18
64
  // from both send() and the channel callback. Cheaper than try/finally.
19
65
  let inFlight = false;
66
+ const cancelDoorbell = () => {
67
+ if (!doorbellArmed)
68
+ return;
69
+ doorbellEpoch = (doorbellEpoch + 1) | 0;
70
+ doorbellArmed = false;
71
+ setCompletionWaiterArmed(false);
72
+ };
73
+ const armDoorbell = () => {
74
+ if (!doorbellEnabled || doorbellArmed === true) {
75
+ if (!doorbellEnabled)
76
+ notify();
77
+ return;
78
+ }
79
+ const token = (doorbellEpoch + 1) | 0;
80
+ doorbellEpoch = token;
81
+ doorbellArmed = true;
82
+ let woke = false;
83
+ const wake = () => {
84
+ if (!doorbellArmed || doorbellEpoch !== token || woke)
85
+ return;
86
+ woke = true;
87
+ doorbellArmed = false;
88
+ doorbellEpoch = (doorbellEpoch + 1) | 0;
89
+ setCompletionWaiterArmed(false);
90
+ // Keep the existing macrotask boundary. Calling check directly from a
91
+ // resolved waitAsync promise can chain microtasks under a hot workload
92
+ // and starve host I/O.
93
+ notify();
94
+ };
95
+ let supported = false;
96
+ try {
97
+ supported = waitForCompletion(wake, DOORBELL_WATCHDOG_MS);
98
+ }
99
+ catch {
100
+ supported = false;
101
+ }
102
+ // Some runtimes reject waitAsync on their SharedArrayBuffer variants; fall
103
+ // back to polling rather than hanging the pool.
104
+ if (!supported) {
105
+ doorbellEnabled = false;
106
+ // The window was narrowed for a doorbell that no longer exists; widen it
107
+ // back or the ladder fires after a single fruitless drain.
108
+ if (requestedStallFreeLoops === undefined) {
109
+ stallFreeLoops = POLL_STALL_FREE_LOOPS;
110
+ }
111
+ doorbellArmed = false;
112
+ doorbellEpoch = (doorbellEpoch + 1) | 0;
113
+ setCompletionWaiterArmed(false);
114
+ notify();
115
+ }
116
+ };
20
117
  const check = () => {
21
118
  if (inFlight) {
22
119
  // Another check() is already mid-drain; mark that a re-run is needed
@@ -24,11 +121,12 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
24
121
  check.rerun = true;
25
122
  return;
26
123
  }
27
- // Nothing enqueued and nothing outstanding at the worker: skip the drain.
28
- // Without this an idle lane still pays a txStatus write, an rxStatus load
29
- // and — worst of all — an Atomics.notify that wakes a parked worker for no
30
- // reason. Under the serial-channel dispatcher every lane is checked on
31
- // every tick, so that waste is what makes latency grow with thread count.
124
+ // A waitAsync cannot be cancelled, so bumping its epoch makes the eventual
125
+ // callback inert instead.
126
+ cancelDoorbell();
127
+ // Idle lane: skip the drain. Otherwise it pays a txStatus write, an rxStatus
128
+ // load, and an Atomics.notify that wakes a parked worker for nothing — the
129
+ // waste that makes serial-channel latency grow with thread count.
32
130
  if (txIdle()) {
33
131
  check.isRunning = false;
34
132
  return;
@@ -46,25 +144,27 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
46
144
  a_store(opView, 0, 1);
47
145
  wakeSignal();
48
146
  }
49
- // Drain loop: local vars so V8 keeps them as unboxed int32.
50
- let anyProgressed = false;
147
+ // Local vars so V8 keeps them as unboxed int32. Only a reaped completion
148
+ // counts as progress for `stallCount`: the pump exists to notice work a
149
+ // worker cannot announce, and letting a flush reset the counter would
150
+ // leave the escalation unreachable for a pool that never runs dry.
151
+ let completed = false;
51
152
  let progressed = true;
52
153
  while (progressed) {
53
154
  progressed = false;
54
155
  if (completeFrame() > 0) {
55
156
  progressed = true;
56
- anyProgressed = true;
157
+ completed = true;
57
158
  }
58
159
  while (hasPendingFrames()) {
59
160
  if (!flushToWorker())
60
161
  break;
61
162
  progressed = true;
62
- anyProgressed = true;
63
163
  }
64
164
  }
65
165
  txStatus[0] = 0;
66
166
  if (!txIdle()) {
67
- if (anyProgressed || hasPendingFrames()) {
167
+ if (completed || hasPendingFrames()) {
68
168
  stallCount = 0 | 0;
69
169
  }
70
170
  else {
@@ -83,14 +183,22 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
83
183
  check.isRunning = false;
84
184
  check.rerun = false;
85
185
  const scheduleNotify = () => {
86
- if (stallCount <= STALL_FREE_LOOPS) {
186
+ if (stallCount <= stallFreeLoops) {
87
187
  notify();
88
188
  return;
89
189
  }
190
+ if (doorbellEnabled) {
191
+ // Release isRunning during the wait so a fresh send() can restart the
192
+ // dispatcher immediately. The epoch in cancelDoorbell() invalidates the
193
+ // old waiter in that case.
194
+ check.isRunning = false;
195
+ armDoorbell();
196
+ return;
197
+ }
90
198
  // One delayed wakeup at a time; fresh send() calls preempt via check directly.
91
199
  if (backoffTimer !== undefined)
92
200
  return;
93
- let delay = (stallCount - STALL_FREE_LOOPS - 1) | 0;
201
+ let delay = (stallCount - stallFreeLoops - 1) | 0;
94
202
  if (delay < 0)
95
203
  delay = 0;
96
204
  else if (delay > MAX_BACKOFF_MS)
@@ -108,25 +216,47 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
108
216
  return { check };
109
217
  };
110
218
  export class ChannelHandler {
219
+ // Set only when the pump is a MessageChannel. The `setImmediate` pump has no
220
+ // ports, and nothing outside this class reads them.
111
221
  channel;
112
222
  port1;
113
223
  port2;
114
- #post2;
115
- constructor() {
116
- this.channel = createRuntimeMessageChannel();
117
- this.port1 = this.channel.port1;
118
- this.port2 = this.channel.port2;
119
- this.#post2 = (message) => this.port2.postMessage(message);
224
+ #handler;
225
+ #notify;
226
+ constructor(pump = "auto") {
227
+ if (pump === "auto" && IMMEDIATE_PUMP !== undefined) {
228
+ const immediate = IMMEDIATE_PUMP;
229
+ // Allocated once; the pump fires hundreds of thousands of times a second.
230
+ // Routing via `#handler` also makes a callback outliving `close()` inert.
231
+ const run = () => {
232
+ this.#handler?.();
233
+ };
234
+ this.#notify = () => {
235
+ immediate(run);
236
+ };
237
+ return;
238
+ }
239
+ const channel = createRuntimeMessageChannel();
240
+ const port2 = channel.port2;
241
+ this.channel = channel;
242
+ this.port1 = channel.port1;
243
+ this.port2 = port2;
244
+ this.#notify = () => {
245
+ port2.postMessage(null);
246
+ };
120
247
  }
121
248
  notify() {
122
- this.#post2(null);
249
+ this.#notify();
123
250
  }
124
251
  /**
125
- * Opens the channel (if not already open) and sets the onmessage handler.
126
- * This is the setup so `notify` can send a message to the port 1.
252
+ * Registers the handler the pump calls back into. On the channel pump this is
253
+ * also where the ports are opened, so `notify` can reach port 1.
127
254
  */
128
255
  open(f) {
256
+ this.#handler = f;
129
257
  const port1 = this.port1;
258
+ if (port1 === undefined)
259
+ return;
130
260
  if (typeof port1.on === "function") {
131
261
  port1.on("message", f);
132
262
  }
@@ -134,13 +264,16 @@ export class ChannelHandler {
134
264
  // @ts-ignore
135
265
  port1.onmessage = f;
136
266
  }
137
- this.port1.start?.();
138
- this.port2.start?.();
267
+ this.port1?.start?.();
268
+ this.port2?.start?.();
139
269
  }
140
270
  /**
141
- * Closes the channel if it is open.
271
+ * Detaches the handler, and closes the channel if this pump has one.
142
272
  */
143
273
  close() {
274
+ this.#handler = undefined;
275
+ if (this.port1 === undefined || this.port2 === undefined)
276
+ return;
144
277
  //@ts-ignore
145
278
  this.port1.onmessage = null;
146
279
  //@ts-ignore
@@ -81,9 +81,14 @@ const composeInlineCallable = (fn, timeout, useAbortToolkit = false) => {
81
81
  };
82
82
  };
83
83
  export const createInlineExecutor = ({ tasks, genTaskID, batchSize, }) => {
84
+ // `createPool` passes an already name-sorted array, which is the order
85
+ // `fnNumber` indexes into. The object form has to reach the same order on its
86
+ // own: task ids identify a task, they do not rank it, so sorting by id would
87
+ // hand callers a different lane than the one they addressed.
84
88
  const entries = Array.isArray(tasks)
85
89
  ? tasks
86
- : Object.values(tasks).sort((a, b) => a.id - b.id);
90
+ : Object.values(tasks)
91
+ .sort((a, b) => a.name.localeCompare(b.name));
87
92
  const runners = entries.map((entry) => {
88
93
  // Imported tasks must never execute on the host inline lane: their module
89
94
  // import is meant to stay inside the worker so worker permission policies