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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { createHostTxQueue } from "./tx-queue.js";
2
- import { type ProcessSharedMemoryBacking } from "./process-worker.js";
2
+ import { type ProcessSharedMemoryBacking, type ProcessStealMemoryLayout, type ResolvedProcessSharedMemorySettings } from "./process-worker.js";
3
3
  import { type Sab } from "../ipc/transport/shared-memory.js";
4
4
  import { ChannelHandler, type DispatcherCheck } from "./dispatcher.js";
5
5
  import { lock2, type Task } from "../memory/lock.js";
@@ -20,12 +20,16 @@ import { type PayloadBufferOptions } from "../memory/payload-config.js";
20
20
  export declare const resolveStealRegionLanes: (consumers: number) => number;
21
21
  /** Maximum claimants that leave the protocol's required spare region. */
22
22
  export declare const MAX_STEAL_CONSUMERS: number;
23
- export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, }: {
23
+ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, processWorker, }: {
24
24
  threads: number;
25
25
  payload?: PayloadBufferOptions;
26
26
  regionLanes?: number;
27
27
  abortSignalCapacity?: number;
28
28
  usesAbortSignal?: boolean;
29
+ processWorker?: {
30
+ signalBytes: number;
31
+ sharedMemory: ResolvedProcessSharedMemorySettings;
32
+ };
29
33
  }) => {
30
34
  submitBuffers: LockBuffers;
31
35
  returnBuffers: LockBuffers[];
@@ -48,6 +52,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
48
52
  shouldSettle?: (task: Task) => boolean;
49
53
  activeRejectPlaceholder?: Task["reject"];
50
54
  }) => () => number;
55
+ waitForHostChange: (timeoutMs?: number) => import("../memory/lock.js").WaitAsyncResult | undefined;
56
+ setHostWaiterArmed: (armed: boolean) => void;
51
57
  hasPendingFrames: () => boolean;
52
58
  getPendingFrameCount: () => number;
53
59
  getPendingPromiseCount: () => number;
@@ -75,6 +81,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
75
81
  shouldSettle?: (task: Task) => boolean;
76
82
  activeRejectPlaceholder?: Task["reject"];
77
83
  }) => () => number;
84
+ waitForHostChange: (timeoutMs?: number) => import("../memory/lock.js").WaitAsyncResult | undefined;
85
+ setHostWaiterArmed: (armed: boolean) => void;
78
86
  hasPendingFrames: () => boolean;
79
87
  getPendingFrameCount: () => number;
80
88
  getPendingPromiseCount: () => number;
@@ -88,6 +96,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
88
96
  hasPendingFrames: () => boolean;
89
97
  txIdle: () => boolean;
90
98
  completeFrame: () => number;
99
+ waitForCompletion: (onWake: () => void, timeoutMs?: number) => boolean;
100
+ setCompletionWaiterArmed: (armed: boolean) => void;
91
101
  setReturnHooks: (lane: number, hooks: (import("../connections/buffer-reference.js").BufferReferenceReturnHooks | ((token: bigint) => void)) | undefined) => void;
92
102
  enqueue: (functionID: number, timeout?: import("../types.js").TaskTimeout, abortSignal?: import("../types.js").AbortSignalOption) => (rawArgs: unknown) => Promise<never> | import("../common/with-resolvers.js").PromiseWithMaybeReject<unknown>;
93
103
  flushToWorker: () => boolean;
@@ -95,10 +105,11 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
95
105
  settlePromisePayload: (task: Task, isRejected: boolean, value: unknown) => boolean;
96
106
  };
97
107
  regionLanes: number;
98
- abortSignalSAB: SharedArrayBuffer;
108
+ processMemory: ProcessStealMemoryLayout;
109
+ abortSignalSAB: SharedArrayBuffer | import("../types.js").SharedBufferRegion;
99
110
  abortSignalMax: number;
100
111
  };
101
- export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }: {
112
+ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, workerCount, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }: {
102
113
  list: string[];
103
114
  ids: number[];
104
115
  names: string[];
@@ -108,6 +119,12 @@ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug
108
119
  debug?: DebugOptions;
109
120
  hostDebug?: (message: string) => void;
110
121
  totalNumberOfThread: number;
122
+ /**
123
+ * Workers only, excluding any host inline lane. Drives the spin policy:
124
+ * the inliner runs on the host thread and never spins, so counting it
125
+ * would strip a one-worker pool of the budget it should keep.
126
+ */
127
+ workerCount?: number;
111
128
  source?: string;
112
129
  workerOptions?: WorkerSettings;
113
130
  workerExecArgv?: string[];
@@ -140,6 +157,7 @@ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug
140
157
  regionLanes: number;
141
158
  abortSignalSAB?: LockBuffers["headers"];
142
159
  abortSignalMax?: number;
160
+ processMemory?: ProcessStealMemoryLayout;
143
161
  };
144
162
  }) => (WorkerContext & {
145
163
  dispatcherCheck?: never;
@@ -1,14 +1,14 @@
1
1
  import { createHostTxQueue } from "./tx-queue.js";
2
- import { cleanupProcessWorkerMemoryQuietly, createProcessSharedMemoryAllocator, createProcessWorkerMemoryLayout, createProcessWorkerNativeSignalNotifier, readProcessSharedMemorySettings, readProcessWorkerCommandPrefix, readProcessWorkerRuntime, spawnProcessWorker, toProcessWorkerBootPayload, } from "./process-worker.js";
2
+ import { cleanupProcessWorkerMemoryQuietly, createProcessSharedMemoryAllocator, createProcessStealMemoryLayout, createProcessWorkerMemoryLayout, createProcessWorkerNativeSignalNotifier, readProcessSharedMemorySettings, readProcessWorkerCommandPrefix, readProcessWorkerRuntime, spawnProcessWorker, toProcessWorkerBootPayload, } from "./process-worker.js";
3
3
  import { serializeWorkerBootstrapData, terminateWorkerQuietly, toWorkerCompatExecArgv, toWorkerSafeExecArgv, } from "./worker-common.js";
4
- import { createSharedMemoryTransport, TRANSPORT_SIGNAL_BYTES, } from "../ipc/transport/shared-memory.js";
4
+ import { createSharedMemoryTransport, TRANSPORT_SIGNAL_BYTES, WORKER_STOP, } from "../ipc/transport/shared-memory.js";
5
5
  import { ChannelHandler, hostDispatcherLoop, } from "./dispatcher.js";
6
6
  import { HEADER_SLOT_STRIDE_U32, lock2, LOCK_SECTOR_BYTE_LENGTH, LockBound, } from "../memory/lock.js";
7
7
  // Side-effect import: registers the payload codec (cycle break for Andromeda;
8
8
  // see lock.ts). Must run before any lock2() call.
9
9
  import "../memory/payloadCodec.js";
10
10
  import "../worker/loop.js";
11
- import { createSharedArrayBuffer, createWasmSharedArrayBuffer, IS_BROWSER, } from "../common/runtime.js";
11
+ import { createSharedArrayBuffer, createWasmSharedArrayBuffer, IS_BROWSER, RUNTIME, } from "../common/runtime.js";
12
12
  import { HAS_NODE_WORKER_THREADS, RUNTIME_WORKER, } from "../common/worker-runtime.js";
13
13
  import { probeLockBufferTextCompat } from "../common/shared-buffer-text.js";
14
14
  import { signalAbortFactory } from "../shared/abortSignal.js";
@@ -20,8 +20,12 @@ const WORKER_FATAL_MESSAGE_KEY = "__knittingWorkerFatal";
20
20
  const isWorkerFatalMessage = (value) => !!value &&
21
21
  typeof value === "object" &&
22
22
  typeof value[WORKER_FATAL_MESSAGE_KEY] === "string";
23
+ // Bound the wait for a worker to acknowledge loop exit.
24
+ const WORKER_STOP_ACK_TIMEOUT_MS = 50;
25
+ const WORKER_STOP_NEEDS_ACK = RUNTIME === "deno";
23
26
  // Keep idle workers self-healing if an Atomics.notify wake is missed.
24
27
  const DEFAULT_WORKER_PARK_MS = 1;
28
+ const SINGLE_WORKER_SPIN_US = 50;
25
29
  const DEFAULT_ABORT_SIGNAL_CAPACITY = 258;
26
30
  const sanitizePositiveInteger = (value) => {
27
31
  if (!Number.isFinite(value))
@@ -31,15 +35,20 @@ const sanitizePositiveInteger = (value) => {
31
35
  };
32
36
  const resolveAbortSignalCapacity = (value) => sanitizePositiveInteger(value) ?? DEFAULT_ABORT_SIGNAL_CAPACITY;
33
37
  const abortSignalByteLength = (capacity) => Math.max(1, Math.ceil(capacity / 32)) * Uint32Array.BYTES_PER_ELEMENT;
34
- const withDefaultWorkerTimers = (options) => {
38
+ const withDefaultWorkerTimers = (options, workerCount) => {
35
39
  const parkMs = options?.timers?.parkMs ?? DEFAULT_WORKER_PARK_MS;
40
+ // `??` and not `||`: an explicit `spinMicroseconds: 0` is the whole point of
41
+ // the multi-worker default and must not fall through to it.
42
+ const spinMicroseconds = options?.timers?.spinMicroseconds ??
43
+ (workerCount <= 1 ? SINGLE_WORKER_SPIN_US : 0);
36
44
  if (options === undefined)
37
- return { timers: { parkMs } };
45
+ return { timers: { parkMs, spinMicroseconds } };
38
46
  return {
39
47
  ...options,
40
48
  timers: {
41
49
  ...options.timers,
42
50
  parkMs,
51
+ spinMicroseconds,
43
52
  },
44
53
  };
45
54
  };
@@ -67,8 +76,11 @@ export const resolveStealRegionLanes = (consumers) => {
67
76
  };
68
77
  /** Maximum claimants that leave the protocol's required spare region. */
69
78
  export const MAX_STEAL_CONSUMERS = LockBound.slots - 1;
70
- export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, }) => {
71
- const payloadConfig = resolvePayloadBufferOptions({ options: payload });
79
+ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, processWorker, }) => {
80
+ const basePayloadConfig = resolvePayloadBufferOptions({ options: payload });
81
+ const payloadConfig = processWorker === undefined
82
+ ? basePayloadConfig
83
+ : withFixedPayloadConfig(basePayloadConfig);
72
84
  const makePayload = () => payloadConfig.mode === "growable"
73
85
  ? createSharedArrayBuffer(payloadConfig.payloadInitialBytes, payloadConfig.payloadMaxByteLength)
74
86
  : createSharedArrayBuffer(payloadConfig.payloadInitialBytes);
@@ -92,8 +104,25 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
92
104
  const lanes = regionLanes === undefined
93
105
  ? maxLanes
94
106
  : Math.min(Math.max(1, regionLanes | 0), maxLanes);
95
- const submitCarpet = carpet();
96
- const submitBuffers = toBuffers(submitCarpet.lock, makePayload());
107
+ const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
108
+ const processMemory = processWorker === undefined ? undefined : createProcessStealMemoryLayout({
109
+ threads,
110
+ signalBytes: processWorker.signalBytes,
111
+ abortBytes: usesAbortSignal === true
112
+ ? abortSignalByteLength(resolvedAbortSignalCapacity)
113
+ : 0,
114
+ abortSignalMax: usesAbortSignal === true
115
+ ? resolvedAbortSignalCapacity
116
+ : undefined,
117
+ payloadBytes: payloadConfig.payloadMaxByteLength,
118
+ sharedMemory: processWorker.sharedMemory,
119
+ });
120
+ const submitBuffers = processMemory === undefined
121
+ ? (() => {
122
+ const submitCarpet = carpet();
123
+ return toBuffers(submitCarpet.lock, makePayload());
124
+ })()
125
+ : toBuffers(processMemory.workers[0].controlLayout.lock, processMemory.workers[0].lockPayload);
97
126
  const hostSubmitLock = lock2({
98
127
  headers: submitBuffers.headers,
99
128
  headerSlotStrideU32: submitBuffers.headerSlotStrideU32,
@@ -104,11 +133,14 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
104
133
  textCompat: submitBuffers.textCompat,
105
134
  consumers: threads,
106
135
  regionLanes: lanes,
136
+ processBoundary: processMemory !== undefined,
107
137
  });
108
138
  const returnBuffers = [];
109
139
  const hostReturnLocks = [];
110
140
  for (let i = 0; i < threads; i++) {
111
- const buffers = toBuffers(carpet().returnLock, makePayload());
141
+ const buffers = processMemory === undefined
142
+ ? toBuffers(carpet().returnLock, makePayload())
143
+ : toBuffers(processMemory.workers[i].controlLayout.returnLock, processMemory.workers[i].returnPayload);
112
144
  returnBuffers.push(buffers);
113
145
  hostReturnLocks.push(lock2({
114
146
  headers: buffers.headers,
@@ -118,14 +150,15 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
118
150
  payloadSector: buffers.payloadSector,
119
151
  payloadConfig,
120
152
  textCompat: buffers.textCompat,
153
+ processBoundary: processMemory !== undefined,
121
154
  }));
122
155
  }
123
156
  // Abort-aware tasks can execute on any claimant, so all workers and the
124
157
  // pool-global queue must observe one bitmap. Per-lane abort buffers would
125
158
  // encode an id from one allocator and test it against another lane's bits.
126
- const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
127
159
  const abortSignalSAB = usesAbortSignal === true
128
- ? createSharedArrayBuffer(abortSignalByteLength(resolvedAbortSignalCapacity))
160
+ ? processMemory?.workers[0]?.controlLayout.abortSignals ??
161
+ createSharedArrayBuffer(abortSignalByteLength(resolvedAbortSignalCapacity))
129
162
  : undefined;
130
163
  const abortSignals = abortSignalSAB === undefined
131
164
  ? undefined
@@ -146,13 +179,13 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
146
179
  hostReturnLocks,
147
180
  sharedQueue,
148
181
  regionLanes: lanes,
182
+ processMemory,
149
183
  abortSignalSAB,
150
- abortSignalMax: abortSignalSAB === undefined
151
- ? undefined
152
- : resolvedAbortSignalCapacity,
184
+ abortSignalMax: processMemory?.abortSignalMax ??
185
+ (abortSignalSAB === undefined ? undefined : resolvedAbortSignalCapacity),
153
186
  };
154
187
  };
155
- export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }) => {
188
+ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, workerCount, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }) => {
156
189
  if (workerOptions?.runtime === "compiled") {
157
190
  return spawnCompiledWorkerContext({
158
191
  list,
@@ -165,7 +198,7 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
165
198
  }
166
199
  const tsFileUrl = new URL(import.meta.url);
167
200
  const poliWorker = RUNTIME_WORKER;
168
- const resolvedWorkerOptions = serializeWorkerBootstrapData(withDefaultWorkerTimers(workerOptions));
201
+ const resolvedWorkerOptions = serializeWorkerBootstrapData(withDefaultWorkerTimers(workerOptions, workerCount ?? totalNumberOfThread));
169
202
  const useProcessWorkerRuntime = resolvedWorkerOptions.runtime === "process";
170
203
  const processWorkerRuntime = useProcessWorkerRuntime
171
204
  ? readProcessWorkerRuntime(resolvedWorkerOptions)
@@ -205,15 +238,25 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
205
238
  const abortBytes = stealPool === undefined && usesAbortSignal === true
206
239
  ? abortSignalByteLength(resolvedAbortSignalCapacity)
207
240
  : 0;
208
- const processWorkerMemory = useProcessWorkerRuntime
209
- ? createProcessWorkerMemoryLayout({
210
- signalBytes,
211
- abortBytes,
212
- payloadBytes: resolvedPayloadConfig.payloadMaxByteLength,
213
- thread,
214
- sharedMemory: processSharedMemorySettings,
215
- })
216
- : undefined;
241
+ // A stealing process pool carves every lane out of one mapping, so lane
242
+ // `thread` must find its slice there. Falling back to a private layout would
243
+ // silently give this lane its own submit region — it would boot and then
244
+ // never see a task the host published to the shared one.
245
+ const stealProcessMemory = stealPool?.processMemory;
246
+ const processWorkerMemory = !useProcessWorkerRuntime
247
+ ? undefined
248
+ : stealProcessMemory === undefined
249
+ ? createProcessWorkerMemoryLayout({
250
+ signalBytes,
251
+ abortBytes,
252
+ payloadBytes: resolvedPayloadConfig.payloadMaxByteLength,
253
+ thread,
254
+ sharedMemory: processSharedMemorySettings,
255
+ })
256
+ : stealProcessMemory.workers[thread] ??
257
+ (() => {
258
+ throw new RangeError(`stealing process pool has no shared-memory slice for worker ${thread}`);
259
+ })();
217
260
  const processSharedMemory = processWorkerMemory === undefined
218
261
  ? createProcessSharedMemoryAllocator(debug)
219
262
  : undefined;
@@ -401,6 +444,7 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
401
444
  channelHandler: ownChannel,
402
445
  dispatcherOptions: host,
403
446
  notifySignal: nativeNotifySignal,
447
+ crossProcess: useProcessWorkerRuntime,
404
448
  });
405
449
  if (ownsChannel && dispatcherCheck !== undefined) {
406
450
  ownChannel.open(dispatcherCheck);
@@ -442,6 +486,12 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
442
486
  payloadConfig: resolvedPayloadConfig,
443
487
  bufferReferenceReturn,
444
488
  permission,
489
+ // Mirrors the dispatcher's `canUseDoorbell`: a cross-process worker cannot
490
+ // ring the host's waiter, so do not make it pay the atomic op.
491
+ notifyOnHostPublish: !useProcessWorkerRuntime &&
492
+ host?.doorbell !== false &&
493
+ (RUNTIME === "bun" || RUNTIME === "node") &&
494
+ typeof Atomics.waitAsync === "function",
445
495
  steal: stealPool === undefined ? undefined : {
446
496
  consumers: stealPool.consumers,
447
497
  consumerId: stealPool.consumerId,
@@ -596,12 +646,39 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
596
646
  return pending;
597
647
  };
598
648
  };
649
+ // Ask the worker to leave its loop before termination.
650
+ const requestWorkerStop = async () => {
651
+ const stopView = signalBox.stopView;
652
+ if (stopView === undefined)
653
+ return;
654
+ try {
655
+ Atomics.store(stopView, 0, WORKER_STOP.requested);
656
+ laneWake();
657
+ notifySignal?.();
658
+ }
659
+ catch {
660
+ return;
661
+ }
662
+ if (!WORKER_STOP_NEEDS_ACK)
663
+ return;
664
+ const deadline = Date.now() + WORKER_STOP_ACK_TIMEOUT_MS;
665
+ while (Atomics.load(stopView, 0) !== WORKER_STOP.acknowledged) {
666
+ if (Date.now() >= deadline)
667
+ return;
668
+ await new Promise((resolve) => setTimeout(resolve, 1));
669
+ }
670
+ };
599
671
  const context = {
600
672
  txIdle,
601
673
  call,
674
+ requestStop: requestWorkerStop,
602
675
  kills: async () => {
603
676
  markWorkerClosed("Thread closed", true);
604
- terminateWorkerQuietly(worker);
677
+ // Process workers must exit before their shared memory is unmapped.
678
+ const awaitExit = processWorkerMemory !== undefined;
679
+ const termination = terminateWorkerQuietly(worker, awaitExit);
680
+ if (awaitExit)
681
+ await termination;
605
682
  cleanupProcessWorkerMemoryQuietly(processWorkerMemory);
606
683
  },
607
684
  lock,
@@ -29,6 +29,20 @@ export type ProcessWorkerMemoryLayout = {
29
29
  returnPayload: SharedBufferSource;
30
30
  cleanup: () => void;
31
31
  };
32
+ /**
33
+ * Process-worker memory for a stealing pool.
34
+ *
35
+ * The request side is one shared region all child processes may claim from;
36
+ * each worker keeps its own signals and return region. Every per-worker view
37
+ * points into the same OS mapping so the existing fd/name boot protocol can
38
+ * carry all of the slices without adding a second descriptor to WorkerData.
39
+ */
40
+ export type ProcessStealMemoryLayout = {
41
+ mapping: SharedMemoryMapping<SharedMemoryBuffer>;
42
+ descriptor: FileDescriptor;
43
+ workers: ProcessWorkerMemoryLayout[];
44
+ abortSignalMax?: number;
45
+ };
32
46
  export type ProcessWorkerRuntime = "bun" | "deno" | "node";
33
47
  export type ProcessWorkerCommandPrefix = NonNullable<WorkerSettings["processCommandPrefix"]>;
34
48
  export type ResolvedProcessSharedMemorySettings = {
@@ -61,6 +75,14 @@ export declare const createProcessWorkerMemoryLayout: ({ signalBytes, abortBytes
61
75
  thread: number;
62
76
  sharedMemory: ResolvedProcessSharedMemorySettings;
63
77
  }) => ProcessWorkerMemoryLayout;
78
+ export declare const createProcessStealMemoryLayout: ({ threads, signalBytes, abortBytes, abortSignalMax, payloadBytes, sharedMemory, }: {
79
+ threads: number;
80
+ signalBytes: number;
81
+ abortBytes: number;
82
+ abortSignalMax?: number;
83
+ payloadBytes: number;
84
+ sharedMemory: ResolvedProcessSharedMemorySettings;
85
+ }) => ProcessStealMemoryLayout;
64
86
  export declare const toProcessWorkerBootPayload: (workerData: WorkerData, memory: ProcessWorkerMemoryLayout) => ProcessWorkerBootPayload;
65
87
  export declare const readProcessWorkerRuntime: (options: WorkerSettings | undefined) => ProcessWorkerRuntime;
66
88
  export declare const readProcessWorkerCommandPrefix: (options: WorkerSettings | undefined) => ProcessWorkerCommandPrefix | undefined;