knitting 0.1.60 → 0.1.61

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.
Files changed (42) hide show
  1. package/BROWSER.md +134 -0
  2. package/README.md +93 -6
  3. package/knitting.browser.d.ts +6 -0
  4. package/knitting.browser.js +1 -0
  5. package/package.json +11 -2
  6. package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
  7. package/prebuilds/darwin-arm64-node-127/knitting_shared_memory.node +0 -0
  8. package/prebuilds/darwin-arm64-node-127/knitting_shm.node +0 -0
  9. package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
  10. package/prebuilds/darwin-arm64-node-137/knitting_shared_memory.node +0 -0
  11. package/prebuilds/darwin-arm64-node-137/knitting_shm.node +0 -0
  12. package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
  13. package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
  14. package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
  15. package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
  16. package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
  17. package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
  18. package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
  19. package/src/api.js +127 -22
  20. package/src/common/runtime.d.ts +2 -1
  21. package/src/common/runtime.js +8 -1
  22. package/src/memory/lock.d.ts +26 -1
  23. package/src/memory/lock.js +212 -4
  24. package/src/memory/payloadCodec.js +2 -6
  25. package/src/memory/regionRegistry.d.ts +1 -3
  26. package/src/memory/regionRegistry.js +16 -14
  27. package/src/permission/protocol.js +5 -1
  28. package/src/runtime/dispatcher.js +9 -0
  29. package/src/runtime/pool.d.ts +114 -3
  30. package/src/runtime/pool.js +209 -63
  31. package/src/runtime/process-worker.d.ts +2 -12
  32. package/src/runtime/process-worker.js +69 -113
  33. package/src/runtime/tx-queue.d.ts +15 -1
  34. package/src/runtime/tx-queue.js +52 -15
  35. package/src/runtime/worker-common.d.ts +13 -0
  36. package/src/runtime/worker-common.js +102 -0
  37. package/src/types.d.ts +41 -3
  38. package/src/worker/loop.js +27 -5
  39. package/src/worker/rx-queue.d.ts +11 -1
  40. package/src/worker/rx-queue.js +4 -1
  41. package/src/worker/timers.d.ts +8 -1
  42. package/src/worker/timers.js +20 -17
package/src/api.js CHANGED
@@ -3,13 +3,13 @@ import { DEBUG_ENABLED, resolveDebugNamespaces } from "./debug/gate.js";
3
3
  import { genTaskID } from "./common/task-source.js";
4
4
  import { toModuleUrl } from "./common/module-url.js";
5
5
  import { endpointSymbol } from "./common/task-symbol.js";
6
- import { spawnWorkerContext } from "./runtime/pool.js";
7
- import { ChannelHandler } from "./runtime/dispatcher.js";
8
- import { RUNTIME } from "./common/runtime.js";
6
+ import { createStealPoolBuffers, MAX_STEAL_CONSUMERS, spawnWorkerContext, } from "./runtime/pool.js";
7
+ import { ChannelHandler, hostDispatcherLoop } from "./runtime/dispatcher.js";
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 { readProcessWorkerCommandPrefix, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
12
+ import { readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
13
13
  import { inspectCompiledWorkerArtifact } from "./runtime/compiled-artifact.js";
14
14
  import { managerMethod } from "./runtime/balancer.js";
15
15
  import { createInlineExecutor } from "./runtime/inline-executor.js";
@@ -46,18 +46,6 @@ const readHostCwd = () => {
46
46
  }
47
47
  return undefined;
48
48
  };
49
- const readKnownProcessWorkerNodeMajor = (worker) => {
50
- if (RUNTIME !== "node")
51
- return undefined;
52
- if (readProcessWorkerCommandPrefix(worker) !== undefined)
53
- return undefined;
54
- const nodeProcess = getNodeProcess();
55
- if (nodeProcess?.env?.NODE_BINARY !== undefined)
56
- return undefined;
57
- const raw = nodeProcess?.versions?.node;
58
- const major = Number.parseInt(String(raw).split(".", 1)[0] ?? "", 10);
59
- return Number.isInteger(major) && major > 0 ? major : undefined;
60
- };
61
49
  const formatDebugList = (values, empty = "(none)") => values && values.length > 0 ? values.join(",") : empty;
62
50
  const MAX_FUNCTION_ID = 0xFFFF;
63
51
  const MAX_FUNCTION_COUNT = MAX_FUNCTION_ID + 1;
@@ -375,6 +363,24 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
375
363
  hostDebug?.log(`permission=${permissionProtocol?.mode ?? "off"} execArgv=${formatDebugList(execArgv)}`);
376
364
  const usesAbortSignal = listOfFunctions.some((fn) => fn.abortSignal !== undefined);
377
365
  const resolvedWorker = resolveWorkerSettings(worker, callerHref);
366
+ const dispatcherEnv = nodeProcess?.env?.KNITTING_DISPATCHER;
367
+ const stealEnvRaw = nodeProcess?.env?.KNITTING_STEAL?.trim().toLowerCase();
368
+ const stealEnv = stealEnvRaw === "1" || stealEnvRaw === "true"
369
+ ? true
370
+ : stealEnvRaw === "0" || stealEnvRaw === "false"
371
+ ? false
372
+ : 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.
377
+ const dispatcherExplicitlySelected = host?.dispatcher !== undefined ||
378
+ dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread";
379
+ const stealDefaultCompatible = balancer === undefined &&
380
+ !dispatcherExplicitlySelected && (threads ?? 1) <= MAX_STEAL_CONSUMERS;
381
+ const stealRequested = host?.steal ?? stealEnv ?? stealDefaultCompatible;
382
+ const stealExplicitlyEnabled = host?.steal === true ||
383
+ (host?.steal === undefined && stealEnv === true);
378
384
  const usingCompiledWorker = resolvedWorker?.runtime === "compiled";
379
385
  if (resolvedWorker?.compiled !== undefined && !usingCompiledWorker) {
380
386
  throw new Error("worker.compiled requires worker.runtime to be compiled");
@@ -429,6 +435,9 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
429
435
  if (usingInliner && resolvedWorker?.bootstrap !== undefined) {
430
436
  throw new Error("worker.bootstrap cannot be used with the inliner");
431
437
  }
438
+ if (stealExplicitlyEnabled && resolvedWorker?.runtime === "process") {
439
+ throw new Error("host.steal does not support process workers; use worker threads or disable stealing");
440
+ }
432
441
  if (resolvedWorker?.runtime === "process") {
433
442
  const processRuntime = readProcessWorkerRuntime(resolvedWorker);
434
443
  enforceProcessPermissionCompatibility(classifyProcessPermissionCompatibility({
@@ -437,7 +446,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
437
446
  target: {
438
447
  runtime: processRuntime,
439
448
  nodeMajor: processRuntime === "node"
440
- ? readKnownProcessWorkerNodeMajor(resolvedWorker)
449
+ ? readProcessWorkerNodeMajor(resolvedWorker)
441
450
  : undefined,
442
451
  },
443
452
  }));
@@ -454,7 +463,6 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
454
463
  `(import { isMain } from "knitting") so only the main program starts ` +
455
464
  `the pool.`);
456
465
  }
457
- const dispatcherEnv = nodeProcess?.env?.KNITTING_DISPATCHER;
458
466
  const explicitDispatcher = host?.dispatcher ??
459
467
  (dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread"
460
468
  ? dispatcherEnv
@@ -470,7 +478,25 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
470
478
  return "serial-channel";
471
479
  })();
472
480
  const dispatcher = explicitDispatcher ?? autoDispatcher;
473
- const serialChannel = !usingCompiledWorker &&
481
+ // Work stealing: one shared submit region, private return lanes, one
482
+ // pool-global pending registry. It is the compatible multi-thread default;
483
+ // an explicit balancer/dispatcher keeps the private-lane topology unless
484
+ // stealing itself was explicitly requested.
485
+ const useSteal = stealRequested &&
486
+ !usingCompiledWorker &&
487
+ resolvedWorker?.runtime !== "process" &&
488
+ (threads ?? 1) > 1 &&
489
+ !usingInliner;
490
+ const stealBuffers = useSteal
491
+ ? createStealPoolBuffers({
492
+ threads: threads ?? 1,
493
+ payload,
494
+ regionLanes: host?.stealRegionLanes,
495
+ abortSignalCapacity,
496
+ usesAbortSignal,
497
+ })
498
+ : undefined;
499
+ const serialChannel = !usingCompiledWorker && !useSteal &&
474
500
  dispatcher === "serial-channel";
475
501
  const serialDispatcherChannel = serialChannel
476
502
  ? new ChannelHandler()
@@ -496,14 +522,83 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
496
522
  usesAbortSignal,
497
523
  permission: permissionProtocol,
498
524
  sharedChannelHandler: serialDispatcherChannel,
525
+ stealPool: stealBuffers === undefined ? undefined : {
526
+ submitBuffers: stealBuffers.submitBuffers,
527
+ returnBuffers: stealBuffers.returnBuffers[thread],
528
+ hostSubmitLock: stealBuffers.hostSubmitLock,
529
+ hostReturnLock: stealBuffers.hostReturnLocks[thread],
530
+ sharedQueue: stealBuffers.sharedQueue,
531
+ consumers: threads ?? 1,
532
+ consumerId: thread,
533
+ regionLanes: stealBuffers.regionLanes,
534
+ abortSignalSAB: stealBuffers.abortSignalSAB,
535
+ abortSignalMax: stealBuffers.abortSignalMax,
536
+ },
499
537
  }));
538
+ const stealChannel = useSteal ? new ChannelHandler() : undefined;
539
+ if (useSteal) {
540
+ const channel = stealChannel;
541
+ const queue = stealBuffers.sharedQueue;
542
+ // Wake exactly one worker per drain, round-robin. Stealing decouples the
543
+ // wake target from the assignment: if the woken worker is busy, any other
544
+ // idle endpoint claims the region instead. So this costs one notify per
545
+ // publish regardless of pool size, rather than one per lane.
546
+ let wakeCursor = 0;
547
+ const wakeOne = () => {
548
+ const lanes = workers.length;
549
+ if (lanes === 0)
550
+ return;
551
+ const lane = wakeCursor;
552
+ wakeCursor = wakeCursor + 1 < lanes ? wakeCursor + 1 : 0;
553
+ workers[lane].laneWake?.();
554
+ };
555
+ // The shared dispatcher drives one queue, so it needs signal words of its
556
+ // own rather than any single lane's; waking is delegated to `wakeOne`.
557
+ const signalWords = new Int32Array(createSharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
558
+ const { check } = hostDispatcherLoop({
559
+ signalBox: {
560
+ opView: signalWords.subarray(0, 1),
561
+ txStatus: signalWords.subarray(1, 2),
562
+ rxStatus: signalWords.subarray(2, 3),
563
+ },
564
+ queue,
565
+ channelHandler: channel,
566
+ dispatcherOptions: host,
567
+ notifySignal: wakeOne,
568
+ });
569
+ channel.open(check);
570
+ workers.forEach((context) => {
571
+ context.bindSend(() => {
572
+ if (check.isRunning !== true) {
573
+ check.isRunning = true;
574
+ Promise.resolve().then(check);
575
+ }
576
+ wakeOne();
577
+ });
578
+ });
579
+ hostDebug?.log(`dispatcher=steal lanes=${workers.length} g=${stealBuffers.regionLanes}`);
580
+ }
500
581
  const sharedDispatcherChannel = serialDispatcherChannel;
501
582
  if (serialChannel) {
502
583
  const channel = serialDispatcherChannel;
503
584
  const checks = workers.map((context) => context.dispatcherCheck);
585
+ const laneIdle = workers.map((context) => context.txIdle);
504
586
  let serialScheduled = false;
505
587
  let serialInFlight = false;
506
588
  let serialRerun = false;
589
+ // Busy-lane set: a tick walks only the lanes that have work, not all of
590
+ // them. `active` is a dense list of lane indices; `isActive` keeps the
591
+ // membership test O(1). A lane joins on send() and leaves once its queue
592
+ // reports idle, so an N-worker pool with one busy lane costs one check per
593
+ // tick instead of N.
594
+ const active = [];
595
+ const isActive = new Uint8Array(checks.length);
596
+ const markActive = (lane) => {
597
+ if (isActive[lane] === 1)
598
+ return;
599
+ isActive[lane] = 1;
600
+ active.push(lane);
601
+ };
507
602
  const runSerialChecks = () => {
508
603
  if (serialInFlight) {
509
604
  serialRerun = true;
@@ -513,11 +608,19 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
513
608
  do {
514
609
  serialRerun = false;
515
610
  serialScheduled = false;
516
- for (let index = 0; index < checks.length; index++) {
517
- const check = checks[index];
611
+ // Walk backwards so dropping an idle lane is a swap-with-last pop.
612
+ for (let index = active.length - 1; index >= 0; index--) {
613
+ const lane = active[index];
614
+ const check = checks[lane];
518
615
  if (check.isRunning !== true)
519
616
  check.isRunning = true;
520
617
  check();
618
+ if (laneIdle[lane]()) {
619
+ isActive[lane] = 0;
620
+ const last = active.pop();
621
+ if (index < active.length)
622
+ active[index] = last;
623
+ }
521
624
  }
522
625
  } while (serialRerun);
523
626
  serialInFlight = false;
@@ -533,9 +636,10 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
533
636
  Promise.resolve().then(runSerialChecks);
534
637
  };
535
638
  channel.open(runSerialChecks);
536
- workers.forEach((context) => {
639
+ workers.forEach((context, lane) => {
537
640
  const wake = context.laneWake;
538
641
  context.bindSend(() => {
642
+ markActive(lane);
539
643
  scheduleSerialCheck();
540
644
  wake();
541
645
  });
@@ -580,6 +684,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
580
684
  closePromise = Promise.allSettled(workers.map((context) => context.kills()))
581
685
  .then(() => {
582
686
  sharedDispatcherChannel?.close();
687
+ stealChannel?.close();
583
688
  });
584
689
  return closePromise;
585
690
  };
@@ -1,8 +1,9 @@
1
- type RuntimeName = "deno" | "bun" | "node" | "andromeda" | "unknown";
1
+ type RuntimeName = "deno" | "bun" | "node" | "andromeda" | "browser" | "unknown";
2
2
  export declare const IS_DENO: boolean;
3
3
  export declare const IS_BUN: boolean;
4
4
  export declare const IS_NODE: boolean;
5
5
  export declare const IS_ANDROMEDA: boolean;
6
+ export declare const IS_BROWSER: boolean;
6
7
  export declare const RUNTIME: RuntimeName;
7
8
  export declare const SET_IMMEDIATE: typeof setImmediate & ((cb: () => void) => void);
8
9
  export declare const HAS_SHARED_WASM_MEMORY: boolean;
@@ -7,6 +7,11 @@ export const IS_NODE = typeof nodeProcess?.versions?.node === "string";
7
7
  // Andromeda (Nova engine): no `node:` builtins or `WebAssembly`; detect via
8
8
  // its private bootstrap global.
9
9
  export const IS_ANDROMEDA = typeof globals.__andromeda__ !== "undefined";
10
+ // A page or a web worker: no `node:` builtins, but `Worker`, `MessageChannel`,
11
+ // and a cross-origin isolated `SharedArrayBuffer` are all the pool needs.
12
+ export const IS_BROWSER = !IS_DENO && !IS_BUN && !IS_NODE && !IS_ANDROMEDA &&
13
+ (typeof globals.document !== "undefined" ||
14
+ typeof globals.WorkerGlobalScope === "function");
10
15
  export const RUNTIME = (IS_DENO
11
16
  ? "deno"
12
17
  : IS_BUN
@@ -15,7 +20,9 @@ export const RUNTIME = (IS_DENO
15
20
  ? "node"
16
21
  : IS_ANDROMEDA
17
22
  ? "andromeda"
18
- : "unknown");
23
+ : IS_BROWSER
24
+ ? "browser"
25
+ : "unknown");
19
26
  // `WebAssembly` may be undeclared (not just undefined) on Andromeda, where a
20
27
  // bare reference throws; capture it through `globalThis` so access is safe.
21
28
  const WebAssemblyRef = globals.WebAssembly;
@@ -159,6 +159,20 @@ export declare const HEADER_SLOT_STRIDE_BYTES: number;
159
159
  export declare const HEADER_TASK_LINE_U32: number;
160
160
  export declare const HEADER_STATIC_PAYLOAD_U32: number;
161
161
  export declare const HEADER_TASK_OFFSET_IN_SLOT_U32: number;
162
+ export declare const STEAL_ACK_SLOT_OFFSET_U32: number;
163
+ export declare const STEAL_WANT_SLOT_OFFSET_U32: number;
164
+ /**
165
+ * The payload allocator needs its own participant-owned ACK per consumer, since
166
+ * several workers can free payload slots from one shared allocator. No intent
167
+ * word: freeing needs no arbitration, a consumer only frees what it drained.
168
+ */
169
+ export declare const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32: number;
170
+ /**
171
+ * Host-owned liveness mask for stealing consumers. A dead claimant may leave
172
+ * its WANT word set forever; survivors ignore WANT from consumers whose bit is
173
+ * clear here. This preserves single-writer ownership of every WANT word.
174
+ */
175
+ export declare const STEAL_LIVE_SLOT_OFFSET_U32: number;
162
176
  export declare const HEADER_U32_LENGTH: number;
163
177
  export declare const HEADER_BYTE_LENGTH: number;
164
178
  export declare const makeTask: () => Task;
@@ -177,7 +191,7 @@ type ResolveHostOptions = {
177
191
  * - encode/decode are not re-entrant; payload codec uses a shared scratch buffer.
178
192
  */
179
193
  export type Lock2 = ReturnType<typeof lock2>;
180
- export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, }: {
194
+ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }: {
181
195
  headers?: SharedBufferSource;
182
196
  headerSlotStrideU32?: number;
183
197
  LockBoundSector?: SharedBufferSource;
@@ -189,6 +203,16 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
189
203
  resultList?: RingQueue<Task>;
190
204
  recycleList?: RingQueue<Task>;
191
205
  processBoundary?: boolean;
206
+ /**
207
+ * Number of consumer endpoints sharing this lock. `1` (default) keeps the
208
+ * classic single-consumer path. `> 1` enables region-Dekker work stealing and
209
+ * is only valid on a host->worker submit lock, never on a return lock.
210
+ */
211
+ consumers?: number;
212
+ /** This endpoint's id, `0..consumers-1`. Priority is fixed by id. */
213
+ consumerId?: number;
214
+ /** Lanes claimed per Dekker handshake. Paper rule: `slots / regionLanes >= consumers + 1`. */
215
+ regionLanes?: number;
192
216
  }) => {
193
217
  enlist: (task: Task) => true;
194
218
  encode: (task: Task, state?: number) => boolean;
@@ -207,6 +231,7 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
207
231
  getPendingFrameCount: () => number;
208
232
  getPendingPromiseCount: () => number;
209
233
  resetPendingState: () => void;
234
+ deactivateStealConsumer: (id: number) => boolean;
210
235
  takeDeferredCount: () => number;
211
236
  setPromiseHandler: (handler?: PromisePayloadHandler) => void;
212
237
  };
@@ -226,6 +226,27 @@ export const HEADER_TASK_LINE_U32 = LOCK_CACHE_LINE_BYTES /
226
226
  export const HEADER_STATIC_PAYLOAD_U32 = TaskIndex.TotalBuff -
227
227
  HEADER_TASK_LINE_U32;
228
228
  export const HEADER_TASK_OFFSET_IN_SLOT_U32 = HEADER_STATIC_PAYLOAD_U32;
229
+ // Work-stealing control words live in the unused tail of each slot's header
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.
235
+ export const STEAL_ACK_SLOT_OFFSET_U32 = HEADER_TASK_OFFSET_IN_SLOT_U32 +
236
+ TaskIndex.Size;
237
+ export const STEAL_WANT_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 1;
238
+ /**
239
+ * The payload allocator needs its own participant-owned ACK per consumer, since
240
+ * several workers can free payload slots from one shared allocator. No intent
241
+ * word: freeing needs no arbitration, a consumer only frees what it drained.
242
+ */
243
+ export const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 2;
244
+ /**
245
+ * Host-owned liveness mask for stealing consumers. A dead claimant may leave
246
+ * its WANT word set forever; survivors ignore WANT from consumers whose bit is
247
+ * clear here. This preserves single-writer ownership of every WANT word.
248
+ */
249
+ export const STEAL_LIVE_SLOT_OFFSET_U32 = STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 + 1;
229
250
  export const HEADER_U32_LENGTH = LockBound.header +
230
251
  (HEADER_SLOT_STRIDE_U32 * LockBound.slots);
231
252
  export const HEADER_BYTE_LENGTH = HEADER_U32_LENGTH *
@@ -276,7 +297,7 @@ const settleTask = (task) => {
276
297
  task[TaskIndex["FlagsToHost"]] = 0;
277
298
  }
278
299
  };
279
- export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, }) => {
300
+ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }) => {
280
301
  // Layout within `lockSectorRegion`:
281
302
  // - hostBits starts at byte 0
282
303
  // - workerBits starts at byte 64
@@ -308,6 +329,55 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
308
329
  headers: headersRegion,
309
330
  payload: payloadSAB,
310
331
  });
332
+ // ---- work stealing (multi-consumer) state ----
333
+ // With N consumers the pending set is A ^ ACK[0] ^ ... ^ ACK[N-1]; every word
334
+ // still has exactly one writer, so no shared writable word is introduced.
335
+ const stealConsumers = Math.max(1, (consumers ?? 1) | 0);
336
+ const stealEnabled = stealConsumers > 1;
337
+ // A lock2 built without `consumerId` is the producer endpoint: it encodes but
338
+ // never claims. It still frees payload slots, so it needs an acknowledgement
339
+ // word of its own rather than sharing consumer 0's.
340
+ const stealIsProducer = consumerId === undefined;
341
+ const stealId = (consumerId ?? 0) | 0;
342
+ const stealRegionLanes = (regionLanes ?? 8) | 0;
343
+ const stealRegions = (LockBound.slots / stealRegionLanes) | 0;
344
+ if (stealEnabled) {
345
+ if (stealRegionLanes < 1 || (stealRegionLanes & (stealRegionLanes - 1)) !== 0) {
346
+ throw new RangeError("regionLanes must be a power of two");
347
+ }
348
+ if (stealRegions < stealConsumers) {
349
+ throw new RangeError(`regionLanes=${stealRegionLanes} yields ${stealRegions} regions, ` +
350
+ `too few for ${stealConsumers} consumers`);
351
+ }
352
+ if (stealId < 0 || stealId >= stealConsumers) {
353
+ throw new RangeError(`consumerId ${stealId} out of range`);
354
+ }
355
+ }
356
+ // Int32 alias over the headers SAB so the control words get signed bit math
357
+ // consistent with the rest of the protocol.
358
+ const stealView = new Int32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
359
+ const stealAckIndex = new Int32Array(stealConsumers);
360
+ const stealWantIndex = new Int32Array(stealConsumers);
361
+ 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;
365
+ }
366
+ const stealLiveIndex = LockBound.header + STEAL_LIVE_SLOT_OFFSET_U32;
367
+ const stealAllLiveMask = stealConsumers === 32
368
+ ? -1
369
+ : ((1 << stealConsumers) - 1) | 0;
370
+ if (stealEnabled && stealIsProducer) {
371
+ Atomics.store(stealView, stealLiveIndex, stealAllLiveMask);
372
+ }
373
+ 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
+ };
311
381
  let promiseHandler;
312
382
  if (registeredEncodePayload === undefined ||
313
383
  registeredDecodePayload === undefined) {
@@ -362,8 +432,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
362
432
  // false-busy-only sender-side staleness property, this may hide newly freed
363
433
  // lanes but cannot make a genuinely pending lane appear free. Refresh only
364
434
  // when the cached free set is exhausted.
365
- let workerShadow = a_load(workerBits, 0) | 0;
366
- const refreshWorkerShadow = () => workerShadow = a_load(workerBits, 0) | 0;
435
+ let workerShadow = 0 | 0;
436
+ const refreshWorkerShadow = stealEnabled
437
+ ? () => workerShadow = stealAckXorAll()
438
+ : () => workerShadow = a_load(workerBits, 0) | 0;
439
+ refreshWorkerShadow();
367
440
  const ensureSenderStateHasFree = (state) => (~state) !== 0 ? state : (LastLocal ^ refreshWorkerShadow()) | 0;
368
441
  // RingQueue method aliases (hot path)
369
442
  const toBeSentPush = (task) => toBeSent.push(task);
@@ -561,6 +634,122 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
561
634
  lastTake = last;
562
635
  return true;
563
636
  };
637
+ /**
638
+ * WORKER SIDE: decode, region-Dekker stealing variant.
639
+ *
640
+ * Claims one whole region of `stealRegionLanes` lanes per handshake, so the
641
+ * StoreLoad barrier is amortised over the region rather than paid per task.
642
+ * Priority is fixed by endpoint id: a junior withdraws for a senior, so at
643
+ * most one consumer ever acknowledges a published generation.
644
+ *
645
+ * The C reference needs an explicit mfence between the intent store and the
646
+ * peer loads. `Atomics.store` is sequentially consistent in JS, so that
647
+ * barrier is implicit here and holds on x86 and ARM alike.
648
+ */
649
+ const stealLaneMask = (region) => stealRegionLanes === 32
650
+ ? -1
651
+ : ((((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
+ const stealJuniorWants = (intent) => {
661
+ const live = a_load(stealView, stealLiveIndex) | 0;
662
+ for (let c = stealId + 1; c < stealConsumers; c++) {
663
+ if (!stealIsLive(live, c))
664
+ continue;
665
+ if ((a_load(stealView, stealWantIndex[c]) & intent) !== 0)
666
+ return true;
667
+ }
668
+ return false;
669
+ };
670
+ let stealCursor = ((stealRegions * stealId) / stealConsumers) | 0;
671
+ const decodeSteal = () => {
672
+ const pending = (a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) | 0;
673
+ if (pending === 0)
674
+ return false;
675
+ let pendingRegions = 0 | 0;
676
+ if (stealRegions === 1)
677
+ pendingRegions = 1;
678
+ else {
679
+ for (let r = 0; r < stealRegions; r++) {
680
+ if ((pending & stealLaneMask(r)) !== 0)
681
+ pendingRegions |= 1 << r;
682
+ }
683
+ }
684
+ const liveBeforeClaim = a_load(stealView, stealLiveIndex) | 0;
685
+ let peerIntent = 0 | 0;
686
+ let seniorIntent = 0 | 0;
687
+ for (let c = 0; c < stealConsumers; c++) {
688
+ if (c === stealId || !stealIsLive(liveBeforeClaim, c))
689
+ continue;
690
+ const value = a_load(stealView, stealWantIndex[c]) | 0;
691
+ peerIntent |= value;
692
+ if (c < stealId)
693
+ seniorIntent |= value;
694
+ }
695
+ const notSenior = pendingRegions & ~seniorIntent;
696
+ if (notSenior === 0)
697
+ return false;
698
+ const clean = pendingRegions & ~peerIntent;
699
+ const candidates = clean !== 0 ? clean : notSenior;
700
+ let region = -1;
701
+ for (let step = 0; step < stealRegions; step++) {
702
+ const candidate = (stealCursor + step) % stealRegions;
703
+ if ((candidates & (1 << candidate)) !== 0) {
704
+ region = candidate;
705
+ break;
706
+ }
707
+ }
708
+ if (region < 0)
709
+ return false;
710
+ const intent = (1 << region) | 0;
711
+ a_store(stealView, stealWantIndex[stealId], intent);
712
+ // Dekker StoreLoad: implicit, `Atomics.store` above is seq-cst.
713
+ let seniorConflict = false;
714
+ let juniorConflict = false;
715
+ for (let c = 0; c < stealConsumers; c++) {
716
+ if (c === stealId || !stealIsLive(liveBeforeClaim, c))
717
+ continue;
718
+ if ((a_load(stealView, stealWantIndex[c]) & intent) === 0)
719
+ continue;
720
+ if (c < stealId)
721
+ seniorConflict = true;
722
+ else
723
+ juniorConflict = true;
724
+ }
725
+ if (seniorConflict) {
726
+ a_store(stealView, stealWantIndex[stealId], 0);
727
+ return false;
728
+ }
729
+ if (juniorConflict) {
730
+ while (stealJuniorWants(intent)) { /* junior withdraws */ }
731
+ }
732
+ // Last control loads before ownership. After this no control word is read
733
+ // until every slot in `take` has been decoded.
734
+ const take = ((a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) &
735
+ stealLaneMask(region)) | 0;
736
+ if (take === 0) {
737
+ a_store(stealView, stealWantIndex[stealId], 0);
738
+ return false;
739
+ }
740
+ 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;
751
+ return true;
752
+ };
564
753
  /**
565
754
  * HOST SIDE: decode version
566
755
  */
@@ -730,6 +919,24 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
730
919
  deferredCount = 0 | 0;
731
920
  pendingPromiseCount = 0 | 0;
732
921
  };
922
+ /**
923
+ * HOST SIDE: remove a terminated consumer from stealing arbitration.
924
+ *
925
+ * Its ACK remains part of the generation XOR forever, because it records
926
+ * lanes that consumer retired while alive. Only its stale WANT becomes
927
+ * ineligible. The caller must invoke this only after the endpoint can no
928
+ * longer execute; there is deliberately no matching reactivation operation.
929
+ */
930
+ const deactivateStealConsumer = (id) => {
931
+ if (!stealEnabled || !stealIsProducer)
932
+ return false;
933
+ if (!Number.isInteger(id) || id < 0 || id >= stealConsumers) {
934
+ throw new RangeError(`consumerId ${id} out of range`);
935
+ }
936
+ const bit = 1 << id;
937
+ const previous = Atomics.and(stealView, stealLiveIndex, ~bit);
938
+ return (previous & bit) !== 0;
939
+ };
733
940
  return {
734
941
  enlist,
735
942
  encode,
@@ -737,7 +944,7 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
737
944
  encodeAll,
738
945
  publish,
739
946
  flushPending,
740
- decode,
947
+ decode: stealEnabled ? decodeSteal : decode,
741
948
  hasSpace,
742
949
  resolved,
743
950
  hostBits,
@@ -748,6 +955,7 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
748
955
  getPendingFrameCount: () => toBeSent.size | 0,
749
956
  getPendingPromiseCount: () => pendingPromiseCount | 0,
750
957
  resetPendingState,
958
+ deactivateStealConsumer,
751
959
  takeDeferredCount: () => {
752
960
  const count = deferredCount | 0;
753
961
  deferredCount = 0 | 0;
@@ -311,9 +311,7 @@ export const encodePayload = ({ lockSector, payload, sab, payloadConfig, headers
311
311
  options: payload?.config ?? payloadConfig,
312
312
  });
313
313
  const maxPayloadBytes = resolvedPayloadConfig.maxPayloadBytes;
314
- const { allocTask, setSlotLength, free } = register({
315
- lockSector,
316
- });
314
+ const { allocTask, setSlotLength, free } = register({ lockSector });
317
315
  const { writeBinary: writeDynamicBinary, writeBuffer: writeDynamicBuffer, writeArrayBuffer: writeDynamicArrayBuffer, write8Binary: writeDynamic8Binary, writeUtf8: writeDynamicUtf8, } = createSharedDynamicBufferIO({
318
316
  sab: payloadSab,
319
317
  payloadConfig: resolvedPayloadConfig,
@@ -1219,9 +1217,7 @@ export const decodePayload = ({ lockSector, payload, sab, payloadConfig, headers
1219
1217
  sab: payloadSab,
1220
1218
  options: payload?.config ?? payloadConfig,
1221
1219
  });
1222
- const { free } = register({
1223
- lockSector,
1224
- });
1220
+ const { free } = register({ lockSector });
1225
1221
  const freeTaskSlot = (task) => free(getTaskSlotIndex(task));
1226
1222
  const { readUtf8: readDynamicUtf8, readBytesCopy: readDynamicBytesCopy, readBytesBufferCopy: readDynamicBufferCopy, readBufferCopy: readDynamicBuffer, readBytesArrayBufferCopy: readDynamicArrayBufferCopy, readArrayBufferCopy: readDynamicArrayBuffer, read8BytesFloatCopy: readDynamic8BytesFloatCopy, read8BytesFloatView: readDynamic8BytesFloatView, } = createSharedDynamicBufferIO({
1227
1223
  sab: payloadSab,
@@ -1,10 +1,8 @@
1
1
  import type { Task } from "./lock.js";
2
2
  import { type SharedBufferSource } from "../common/shared-buffer-region.js";
3
3
  export type RegisterMalloc = ReturnType<typeof register>;
4
- export type RegionRegistryPublishMode = "plain" | "atomic";
5
- export declare const register: ({ lockSector, publishMode, }: {
4
+ export declare const register: ({ lockSector, }: {
6
5
  lockSector?: SharedBufferSource;
7
- publishMode?: RegionRegistryPublishMode;
8
6
  }) => {
9
7
  allocTask: (task: Task) => number;
10
8
  setSlotLength: (slotIndex: number, payloadLen: number) => boolean;