knitting 0.1.61 → 0.1.63

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,
@@ -512,6 +516,9 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
512
516
  debug,
513
517
  hostDebug: hostDebug?.log,
514
518
  totalNumberOfThread,
519
+ // Worker count without the inline lane. The inliner runs on the host
520
+ // thread and never spins, so it must not count towards the spin policy.
521
+ workerCount: threads ?? 1,
515
522
  source,
516
523
  workerOptions: resolvedWorker,
517
524
  workerExecArgv: execArgv,
@@ -533,6 +540,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
533
540
  regionLanes: stealBuffers.regionLanes,
534
541
  abortSignalSAB: stealBuffers.abortSignalSAB,
535
542
  abortSignalMax: stealBuffers.abortSignalMax,
543
+ processMemory: stealBuffers.processMemory,
536
544
  },
537
545
  }));
538
546
  const stealChannel = useSteal ? new ChannelHandler() : undefined;
@@ -565,6 +573,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
565
573
  channelHandler: channel,
566
574
  dispatcherOptions: host,
567
575
  notifySignal: wakeOne,
576
+ crossProcess: resolvedWorker?.runtime === "process",
568
577
  });
569
578
  channel.open(check);
570
579
  workers.forEach((context) => {
@@ -681,7 +690,8 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
681
690
  if (closePromise)
682
691
  return closePromise;
683
692
  closing = true;
684
- closePromise = Promise.allSettled(workers.map((context) => context.kills()))
693
+ closePromise = Promise.allSettled(workers.map((context) => context.requestStop?.()))
694
+ .then(() => Promise.allSettled(workers.map((context) => context.kills())))
685
695
  .then(() => {
686
696
  sharedDispatcherChannel?.close();
687
697
  stealChannel?.close();
@@ -843,7 +853,7 @@ const buildTaskDefinitionFromCaller = (input, callerHref, at, imported = false)
843
853
  const importedFrom = new URL(callerHref).href;
844
854
  const out = ({
845
855
  ...input,
846
- id: genTaskID(),
856
+ id: stableTaskID(importedFrom, at),
847
857
  importedFrom,
848
858
  at,
849
859
  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",
@@ -1,6 +1,13 @@
1
1
  export type SignalArguments = ReturnType<typeof createSharedMemoryTransport>;
2
2
  import { type SharedBufferSource } from "../../common/shared-buffer-region.js";
3
3
  export declare const TRANSPORT_SIGNAL_BYTES: number;
4
+ /** States of the worker-stop word. */
5
+ export declare const WORKER_STOP: {
6
+ readonly running: 0;
7
+ readonly requested: 1;
8
+ /** The worker has left its dispatch loop. */
9
+ readonly acknowledged: 2;
10
+ };
4
11
  export type Sab = {
5
12
  size?: number;
6
13
  sharedSab?: SharedBufferSource;
@@ -18,6 +25,7 @@ export declare const createSharedMemoryTransport: ({ sabObject, isMain, startTim
18
25
  opView: Int32Array<import("../../common/shared-buffer-region.js").SharedBuffer>;
19
26
  rxStatus: Int32Array<import("../../common/shared-buffer-region.js").SharedBuffer>;
20
27
  txStatus: Int32Array<import("../../common/shared-buffer-region.js").SharedBuffer>;
28
+ stopView: Int32Array<import("../../common/shared-buffer-region.js").SharedBuffer>;
21
29
  };
22
- export type MainSignal = Pick<SignalArguments, "opView" | "startAt" | "rxStatus" | "txStatus">;
30
+ export type MainSignal = Pick<SignalArguments, "opView" | "startAt" | "rxStatus" | "txStatus" | "stopView">;
23
31
  export {};
@@ -7,8 +7,16 @@ const SIGNAL_OFFSETS = {
7
7
  op: 0,
8
8
  rxStatus: CACHE_LINE_BYTES,
9
9
  txStatus: CACHE_LINE_BYTES * 2,
10
+ stop: CACHE_LINE_BYTES * 3,
11
+ };
12
+ export const TRANSPORT_SIGNAL_BYTES = CACHE_LINE_BYTES * 4;
13
+ /** States of the worker-stop word. */
14
+ export const WORKER_STOP = {
15
+ running: 0,
16
+ requested: 1,
17
+ /** The worker has left its dispatch loop. */
18
+ acknowledged: 2,
10
19
  };
11
- export const TRANSPORT_SIGNAL_BYTES = CACHE_LINE_BYTES * 3;
12
20
  const a_store = Atomics.store;
13
21
  export const createSharedMemoryTransport = ({ sabObject, isMain, startTime }) => {
14
22
  const toGrow = sabObject?.size ?? page;
@@ -24,6 +32,9 @@ export const createSharedMemoryTransport = ({ sabObject, isMain, startTime }) =>
24
32
  a_store(opView, 0, 0);
25
33
  const rxStatus = new Int32Array(sab, baseByteOffset + SIGNAL_OFFSETS.rxStatus, 1);
26
34
  a_store(rxStatus, 0, 1);
35
+ const stopView = new Int32Array(sab, baseByteOffset + SIGNAL_OFFSETS.stop, 1);
36
+ if (isMain)
37
+ a_store(stopView, 0, WORKER_STOP.running);
27
38
  return {
28
39
  sab: signalRegion,
29
40
  op: opView,
@@ -31,5 +42,6 @@ export const createSharedMemoryTransport = ({ sabObject, isMain, startTime }) =>
31
42
  opView,
32
43
  rxStatus,
33
44
  txStatus: new Int32Array(sab, baseByteOffset + SIGNAL_OFFSETS.txStatus, 1),
45
+ stopView,
34
46
  };
35
47
  };
@@ -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;
@@ -228,10 +228,9 @@ export const HEADER_STATIC_PAYLOAD_U32 = TaskIndex.TotalBuff -
228
228
  export const HEADER_TASK_OFFSET_IN_SLOT_U32 = HEADER_STATIC_PAYLOAD_U32;
229
229
  // Work-stealing control words live in the unused tail of each slot's header
230
230
  // cache line. Task words occupy 0..7 (`TASK_LOCAL_FLAGS_INDEX = 7`) of the
231
- // 16-word line, leaving words 8..15 free. Consumer `c` owns slot `c`'s pair, and
232
- // because the slot stride is exactly 9 cache lines those pairs land on distinct
233
- // lines. `ACK[c]` and `WANT[c]` share one line on purpose: same single writer,
234
- // and a peer reading one pulls the other for free.
231
+ // 16-word line, leaving words 8..15 free. Consumer `c` owns slot `c`'s words,
232
+ // and the slot stride keeps those words on separate cache lines.
233
+ // The ACK word was removed; retirement is folded into `workerBits`.
235
234
  export const STEAL_ACK_SLOT_OFFSET_U32 = HEADER_TASK_OFFSET_IN_SLOT_U32 +
236
235
  TaskIndex.Size;
237
236
  export const STEAL_WANT_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 1;
@@ -247,6 +246,8 @@ export const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 2;
247
246
  * clear here. This preserves single-writer ownership of every WANT word.
248
247
  */
249
248
  export const STEAL_LIVE_SLOT_OFFSET_U32 = STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 + 1;
249
+ /** Host-owned arm word for the return-lock completion doorbell. */
250
+ export const DOORBELL_ARMED_SLOT_OFFSET_U32 = STEAL_LIVE_SLOT_OFFSET_U32 + 1;
250
251
  export const HEADER_U32_LENGTH = LockBound.header +
251
252
  (HEADER_SLOT_STRIDE_U32 * LockBound.slots);
252
253
  export const HEADER_BYTE_LENGTH = HEADER_U32_LENGTH *
@@ -297,7 +298,7 @@ const settleTask = (task) => {
297
298
  task[TaskIndex["FlagsToHost"]] = 0;
298
299
  }
299
300
  };
300
- export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }) => {
301
+ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, notifyOnHostPublish, }) => {
301
302
  // Layout within `lockSectorRegion`:
302
303
  // - hostBits starts at byte 0
303
304
  // - workerBits starts at byte 64
@@ -316,6 +317,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
316
317
  const headersRegion = toSharedBufferRegion(headers ?? createWasmSharedArrayBuffer(HEADER_BYTE_LENGTH));
317
318
  const headersBuffer = new Uint32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
318
319
  const headersSlotStride = headerSlotStrideU32 ?? HEADER_SLOT_STRIDE_U32;
320
+ // The first task cache line has four unused control words after the task
321
+ // header. Keep the doorbell arm bit in one of those words so the host and
322
+ // worker lock instances can share it without allocating another SAB.
323
+ const doorbellArmed = new Int32Array(headersRegion.sab, headersRegion.byteOffset +
324
+ (DOORBELL_ARMED_SLOT_OFFSET_U32 * Uint32Array.BYTES_PER_ELEMENT), 1);
319
325
  const resolvedPayloadConfig = resolvePayloadBufferOptions({
320
326
  sab: payload,
321
327
  options: payloadConfig,
@@ -356,12 +362,10 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
356
362
  // Int32 alias over the headers SAB so the control words get signed bit math
357
363
  // consistent with the rest of the protocol.
358
364
  const stealView = new Int32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
359
- const stealAckIndex = new Int32Array(stealConsumers);
360
365
  const stealWantIndex = new Int32Array(stealConsumers);
361
366
  for (let c = 0; c < stealConsumers; c++) {
362
- const slotBase = (c * headersSlotStride) + LockBound.header;
363
- stealAckIndex[c] = slotBase + STEAL_ACK_SLOT_OFFSET_U32;
364
- stealWantIndex[c] = slotBase + STEAL_WANT_SLOT_OFFSET_U32;
367
+ stealWantIndex[c] = (c * headersSlotStride) + LockBound.header +
368
+ STEAL_WANT_SLOT_OFFSET_U32;
365
369
  }
366
370
  const stealLiveIndex = LockBound.header + STEAL_LIVE_SLOT_OFFSET_U32;
367
371
  const stealAllLiveMask = stealConsumers === 32
@@ -371,13 +375,6 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
371
375
  Atomics.store(stealView, stealLiveIndex, stealAllLiveMask);
372
376
  }
373
377
  const stealIsLive = (mask, consumer) => (mask & (1 << consumer)) !== 0;
374
- const stealAckXorAll = () => {
375
- let x = 0 | 0;
376
- for (let c = 0; c < stealConsumers; c++) {
377
- x = (x ^ a_load(stealView, stealAckIndex[c])) | 0;
378
- }
379
- return x;
380
- };
381
378
  let promiseHandler;
382
379
  if (registeredEncodePayload === undefined ||
383
380
  registeredDecodePayload === undefined) {
@@ -428,14 +425,18 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
428
425
  // Atomics aliases (hot path)
429
426
  const a_load = Atomics.load;
430
427
  const a_store = Atomics.store;
428
+ const a_notify = Atomics.notify;
429
+ const shouldNotifyHostPublish = notifyOnHostPublish === true;
430
+ const a_waitAsync = typeof Atomics.waitAsync === "function"
431
+ ? Atomics.waitAsync.bind(Atomics)
432
+ : undefined;
431
433
  // Sender-side cached shadow of the receiver-owned queue word. Under the XSC
432
434
  // false-busy-only sender-side staleness property, this may hide newly freed
433
435
  // lanes but cannot make a genuinely pending lane appear free. Refresh only
434
436
  // when the cached free set is exhausted.
435
437
  let workerShadow = 0 | 0;
436
- const refreshWorkerShadow = stealEnabled
437
- ? () => workerShadow = stealAckXorAll()
438
- : () => workerShadow = a_load(workerBits, 0) | 0;
438
+ // Under stealing, workerBits also records retired lanes.
439
+ const refreshWorkerShadow = () => workerShadow = a_load(workerBits, 0) | 0;
439
440
  refreshWorkerShadow();
440
441
  const ensureSenderStateHasFree = (state) => (~state) !== 0 ? state : (LastLocal ^ refreshWorkerShadow()) | 0;
441
442
  // RingQueue method aliases (hot path)
@@ -560,7 +561,12 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
560
561
  deferredCount = 0 | 0;
561
562
  return toBeSent.isEmpty;
562
563
  };
563
- const storeHost = (bit) => a_store(hostBits, 0, LastLocal = (LastLocal ^ bit) | 0);
564
+ const storeHost = (bit) => {
565
+ a_store(hostBits, 0, LastLocal = (LastLocal ^ bit) | 0);
566
+ if (shouldNotifyHostPublish && a_load(doorbellArmed, 0) !== 0) {
567
+ a_notify(hostBits, 0, 1);
568
+ }
569
+ };
564
570
  const storeWorker = (bit) => a_store(workerBits, 0, LastWorker = (LastWorker ^ bit) | 0);
565
571
  const encode = (task, state = (LastLocal ^ workerShadow) | 0) => {
566
572
  state = ensureSenderStateHasFree(state);
@@ -649,14 +655,6 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
649
655
  const stealLaneMask = (region) => stealRegionLanes === 32
650
656
  ? -1
651
657
  : ((((1 << stealRegionLanes) - 1) << (region * stealRegionLanes)) | 0);
652
- const stealPeerAcks = () => {
653
- let x = 0 | 0;
654
- for (let c = 0; c < stealConsumers; c++) {
655
- if (c !== stealId)
656
- x = (x ^ a_load(stealView, stealAckIndex[c])) | 0;
657
- }
658
- return x;
659
- };
660
658
  const stealJuniorWants = (intent) => {
661
659
  const live = a_load(stealView, stealLiveIndex) | 0;
662
660
  for (let c = stealId + 1; c < stealConsumers; c++) {
@@ -667,9 +665,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
667
665
  }
668
666
  return false;
669
667
  };
670
- let stealCursor = ((stealRegions * stealId) / stealConsumers) | 0;
668
+ const stealHome = ((stealRegions * stealId) / stealConsumers) | 0;
669
+ let stealCursor = stealHome;
671
670
  const decodeSteal = () => {
672
- const pending = (a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) | 0;
671
+ // workerBits includes retired lanes under stealing.
672
+ const pending = (a_load(hostBits, 0) ^ a_load(workerBits, 0)) | 0;
673
673
  if (pending === 0)
674
674
  return false;
675
675
  let pendingRegions = 0 | 0;
@@ -731,23 +731,32 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
731
731
  }
732
732
  // Last control loads before ownership. After this no control word is read
733
733
  // until every slot in `take` has been decoded.
734
- const take = ((a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) &
734
+ const take = ((a_load(hostBits, 0) ^ a_load(workerBits, 0)) &
735
735
  stealLaneMask(region)) | 0;
736
736
  if (take === 0) {
737
737
  a_store(stealView, stealWantIndex[stealId], 0);
738
738
  return false;
739
739
  }
740
+ // Retire decoded lanes and release the region even if decoding throws.
740
741
  let lanes = take;
741
- while (lanes !== 0) {
742
- decodeAt(31 - clz32((lanes & -lanes) >>> 0));
743
- lanes = (lanes & (lanes - 1)) | 0;
744
- }
745
- // Retire the whole claimed mask with one ACK store, then release the region.
746
- // ACK before intent-clear (paper Assumption 2(ii)).
747
- LastWorker = (LastWorker ^ take) | 0;
748
- a_store(stealView, stealAckIndex[stealId], LastWorker);
749
- a_store(stealView, stealWantIndex[stealId], 0);
750
- stealCursor = (region + 1) % stealRegions;
742
+ let done = 0 | 0;
743
+ try {
744
+ while (lanes !== 0) {
745
+ const bit = (lanes & -lanes) | 0;
746
+ decodeAt(31 - clz32(bit >>> 0));
747
+ lanes = (lanes & (lanes - 1)) | 0;
748
+ done = (done ^ bit) | 0;
749
+ }
750
+ }
751
+ finally {
752
+ // ACK before clearing intent.
753
+ LastWorker = (LastWorker ^ done) | 0;
754
+ if (done !== 0)
755
+ Atomics.xor(workerBits, 0, done);
756
+ a_store(stealView, stealWantIndex[stealId], 0);
757
+ // Restart from this consumer's home region.
758
+ stealCursor = stealHome;
759
+ }
751
760
  return true;
752
761
  };
753
762
  /**
@@ -878,6 +887,34 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
878
887
  return modified;
879
888
  };
880
889
  };
890
+ /**
891
+ * HOST SIDE: wait until the producer changes hostBits.
892
+ *
893
+ * LastWorker is the host's acknowledgement shadow. Passing it as the
894
+ * expected value makes the arm race-free: a publication between the drain
895
+ * and this call returns `not-equal` synchronously instead of being lost.
896
+ */
897
+ const waitForHostChange = (timeoutMs) => {
898
+ if (a_waitAsync === undefined) {
899
+ a_store(doorbellArmed, 0, 0);
900
+ return undefined;
901
+ }
902
+ a_store(doorbellArmed, 0, 1);
903
+ try {
904
+ const wait = a_waitAsync(hostBits, 0, LastWorker | 0, timeoutMs);
905
+ if (!wait.async)
906
+ a_store(doorbellArmed, 0, 0);
907
+ return wait;
908
+ }
909
+ catch {
910
+ a_store(doorbellArmed, 0, 0);
911
+ // Some runtimes reject particular SharedArrayBuffer implementations
912
+ // (for example a growable or native-backed buffer). Fall back to the
913
+ // existing dispatcher rather than turning a capability issue into a
914
+ // hung pool.
915
+ return undefined;
916
+ }
917
+ };
881
918
  const decodeAt = (at) => {
882
919
  const off = (at * headersSlotStride) + slotBaseU32;
883
920
  const recycled = recycleShift();
@@ -951,6 +988,10 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
951
988
  workerBits,
952
989
  recyclecList,
953
990
  resolveHost,
991
+ waitForHostChange,
992
+ setHostWaiterArmed: (armed) => {
993
+ a_store(doorbellArmed, 0, armed ? 1 : 0);
994
+ },
954
995
  hasPendingFrames: () => toBeSent.size !== 0,
955
996
  getPendingFrameCount: () => toBeSent.size | 0,
956
997
  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
  }