knitting 0.1.61 → 0.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,7 +105,8 @@ 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
112
  export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }: {
@@ -140,6 +151,7 @@ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug
140
151
  regionLanes: number;
141
152
  abortSignalSAB?: LockBuffers["headers"];
142
153
  abortSignalMax?: number;
154
+ processMemory?: ProcessStealMemoryLayout;
143
155
  };
144
156
  }) => (WorkerContext & {
145
157
  dispatcherCheck?: never;
@@ -1,5 +1,5 @@
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
4
  import { createSharedMemoryTransport, TRANSPORT_SIGNAL_BYTES, } from "../ipc/transport/shared-memory.js";
5
5
  import { ChannelHandler, hostDispatcherLoop, } from "./dispatcher.js";
@@ -8,7 +8,7 @@ import { HEADER_SLOT_STRIDE_U32, lock2, LOCK_SECTOR_BYTE_LENGTH, LockBound, } fr
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";
@@ -67,8 +67,11 @@ export const resolveStealRegionLanes = (consumers) => {
67
67
  };
68
68
  /** Maximum claimants that leave the protocol's required spare region. */
69
69
  export const MAX_STEAL_CONSUMERS = LockBound.slots - 1;
70
- export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, }) => {
71
- const payloadConfig = resolvePayloadBufferOptions({ options: payload });
70
+ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, processWorker, }) => {
71
+ const basePayloadConfig = resolvePayloadBufferOptions({ options: payload });
72
+ const payloadConfig = processWorker === undefined
73
+ ? basePayloadConfig
74
+ : withFixedPayloadConfig(basePayloadConfig);
72
75
  const makePayload = () => payloadConfig.mode === "growable"
73
76
  ? createSharedArrayBuffer(payloadConfig.payloadInitialBytes, payloadConfig.payloadMaxByteLength)
74
77
  : createSharedArrayBuffer(payloadConfig.payloadInitialBytes);
@@ -92,8 +95,25 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
92
95
  const lanes = regionLanes === undefined
93
96
  ? maxLanes
94
97
  : Math.min(Math.max(1, regionLanes | 0), maxLanes);
95
- const submitCarpet = carpet();
96
- const submitBuffers = toBuffers(submitCarpet.lock, makePayload());
98
+ const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
99
+ const processMemory = processWorker === undefined ? undefined : createProcessStealMemoryLayout({
100
+ threads,
101
+ signalBytes: processWorker.signalBytes,
102
+ abortBytes: usesAbortSignal === true
103
+ ? abortSignalByteLength(resolvedAbortSignalCapacity)
104
+ : 0,
105
+ abortSignalMax: usesAbortSignal === true
106
+ ? resolvedAbortSignalCapacity
107
+ : undefined,
108
+ payloadBytes: payloadConfig.payloadMaxByteLength,
109
+ sharedMemory: processWorker.sharedMemory,
110
+ });
111
+ const submitBuffers = processMemory === undefined
112
+ ? (() => {
113
+ const submitCarpet = carpet();
114
+ return toBuffers(submitCarpet.lock, makePayload());
115
+ })()
116
+ : toBuffers(processMemory.workers[0].controlLayout.lock, processMemory.workers[0].lockPayload);
97
117
  const hostSubmitLock = lock2({
98
118
  headers: submitBuffers.headers,
99
119
  headerSlotStrideU32: submitBuffers.headerSlotStrideU32,
@@ -104,11 +124,14 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
104
124
  textCompat: submitBuffers.textCompat,
105
125
  consumers: threads,
106
126
  regionLanes: lanes,
127
+ processBoundary: processMemory !== undefined,
107
128
  });
108
129
  const returnBuffers = [];
109
130
  const hostReturnLocks = [];
110
131
  for (let i = 0; i < threads; i++) {
111
- const buffers = toBuffers(carpet().returnLock, makePayload());
132
+ const buffers = processMemory === undefined
133
+ ? toBuffers(carpet().returnLock, makePayload())
134
+ : toBuffers(processMemory.workers[i].controlLayout.returnLock, processMemory.workers[i].returnPayload);
112
135
  returnBuffers.push(buffers);
113
136
  hostReturnLocks.push(lock2({
114
137
  headers: buffers.headers,
@@ -118,14 +141,15 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
118
141
  payloadSector: buffers.payloadSector,
119
142
  payloadConfig,
120
143
  textCompat: buffers.textCompat,
144
+ processBoundary: processMemory !== undefined,
121
145
  }));
122
146
  }
123
147
  // Abort-aware tasks can execute on any claimant, so all workers and the
124
148
  // pool-global queue must observe one bitmap. Per-lane abort buffers would
125
149
  // encode an id from one allocator and test it against another lane's bits.
126
- const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
127
150
  const abortSignalSAB = usesAbortSignal === true
128
- ? createSharedArrayBuffer(abortSignalByteLength(resolvedAbortSignalCapacity))
151
+ ? processMemory?.workers[0]?.controlLayout.abortSignals ??
152
+ createSharedArrayBuffer(abortSignalByteLength(resolvedAbortSignalCapacity))
129
153
  : undefined;
130
154
  const abortSignals = abortSignalSAB === undefined
131
155
  ? undefined
@@ -146,10 +170,10 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
146
170
  hostReturnLocks,
147
171
  sharedQueue,
148
172
  regionLanes: lanes,
173
+ processMemory,
149
174
  abortSignalSAB,
150
- abortSignalMax: abortSignalSAB === undefined
151
- ? undefined
152
- : resolvedAbortSignalCapacity,
175
+ abortSignalMax: processMemory?.abortSignalMax ??
176
+ (abortSignalSAB === undefined ? undefined : resolvedAbortSignalCapacity),
153
177
  };
154
178
  };
155
179
  export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }) => {
@@ -205,15 +229,25 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
205
229
  const abortBytes = stealPool === undefined && usesAbortSignal === true
206
230
  ? abortSignalByteLength(resolvedAbortSignalCapacity)
207
231
  : 0;
208
- const processWorkerMemory = useProcessWorkerRuntime
209
- ? createProcessWorkerMemoryLayout({
210
- signalBytes,
211
- abortBytes,
212
- payloadBytes: resolvedPayloadConfig.payloadMaxByteLength,
213
- thread,
214
- sharedMemory: processSharedMemorySettings,
215
- })
216
- : undefined;
232
+ // A stealing process pool carves every lane out of one mapping, so lane
233
+ // `thread` must find its slice there. Falling back to a private layout would
234
+ // silently give this lane its own submit region — it would boot and then
235
+ // never see a task the host published to the shared one.
236
+ const stealProcessMemory = stealPool?.processMemory;
237
+ const processWorkerMemory = !useProcessWorkerRuntime
238
+ ? undefined
239
+ : stealProcessMemory === undefined
240
+ ? createProcessWorkerMemoryLayout({
241
+ signalBytes,
242
+ abortBytes,
243
+ payloadBytes: resolvedPayloadConfig.payloadMaxByteLength,
244
+ thread,
245
+ sharedMemory: processSharedMemorySettings,
246
+ })
247
+ : stealProcessMemory.workers[thread] ??
248
+ (() => {
249
+ throw new RangeError(`stealing process pool has no shared-memory slice for worker ${thread}`);
250
+ })();
217
251
  const processSharedMemory = processWorkerMemory === undefined
218
252
  ? createProcessSharedMemoryAllocator(debug)
219
253
  : undefined;
@@ -401,6 +435,7 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
401
435
  channelHandler: ownChannel,
402
436
  dispatcherOptions: host,
403
437
  notifySignal: nativeNotifySignal,
438
+ crossProcess: useProcessWorkerRuntime,
404
439
  });
405
440
  if (ownsChannel && dispatcherCheck !== undefined) {
406
441
  ownChannel.open(dispatcherCheck);
@@ -442,6 +477,12 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
442
477
  payloadConfig: resolvedPayloadConfig,
443
478
  bufferReferenceReturn,
444
479
  permission,
480
+ // Mirrors the dispatcher's `canUseDoorbell`: a cross-process worker cannot
481
+ // ring the host's waiter, so do not make it pay the atomic op.
482
+ notifyOnHostPublish: !useProcessWorkerRuntime &&
483
+ host?.doorbell !== false &&
484
+ (RUNTIME === "bun" || RUNTIME === "node") &&
485
+ typeof Atomics.waitAsync === "function",
445
486
  steal: stealPool === undefined ? undefined : {
446
487
  consumers: stealPool.consumers,
447
488
  consumerId: stealPool.consumerId,
@@ -601,7 +642,9 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
601
642
  call,
602
643
  kills: async () => {
603
644
  markWorkerClosed("Thread closed", true);
604
- terminateWorkerQuietly(worker);
645
+ const termination = terminateWorkerQuietly(worker);
646
+ if (processWorkerMemory !== undefined)
647
+ await termination;
605
648
  cleanupProcessWorkerMemoryQuietly(processWorkerMemory);
606
649
  },
607
650
  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;
@@ -191,6 +191,106 @@ export const createProcessWorkerMemoryLayout = ({ signalBytes, abortBytes, paylo
191
191
  },
192
192
  };
193
193
  };
194
+ export const createProcessStealMemoryLayout = ({ threads, signalBytes, abortBytes, abortSignalMax, payloadBytes, sharedMemory, }) => {
195
+ if (!Number.isInteger(threads) || threads < 2) {
196
+ throw new RangeError("process stealing needs at least two workers");
197
+ }
198
+ const carpet = createByteCarpet();
199
+ const requestLockSlice = carpet.take("requestLockSector", LOCK_SECTOR_BYTE_LENGTH);
200
+ const requestHeadersSlice = carpet.take("requestHeaders", getHeaderBlockByteLength({
201
+ slotCount: LockBound.slots,
202
+ slotStrideU32: HEADER_SLOT_STRIDE_U32,
203
+ alignTo: 64,
204
+ }));
205
+ const abortSignalsSlice = carpet.take("abortSignals", abortBytes);
206
+ const requestPayloadSlice = carpet.take("requestPayload", payloadBytes);
207
+ const workerSlices = Array.from({ length: threads }, (_, thread) => ({
208
+ signal: carpet.take(`signals-${thread}`, signalBytes),
209
+ returnLock: carpet.take(`returnLockSector-${thread}`, LOCK_SECTOR_BYTE_LENGTH),
210
+ returnHeaders: carpet.take(`returnHeaders-${thread}`, getHeaderBlockByteLength({
211
+ slotCount: LockBound.slots,
212
+ slotStrideU32: HEADER_SLOT_STRIDE_U32,
213
+ alignTo: 64,
214
+ })),
215
+ returnPayload: carpet.take(`returnPayload-${thread}`, payloadBytes),
216
+ }));
217
+ const primitives = getProcessWorkerSharedMemoryPrimitives();
218
+ const forceNamed = sharedMemory.mode === "named" || isWindowsRuntimeHost();
219
+ const mapping = primitives.createSharedMemory(forceNamed
220
+ ? {
221
+ size: carpet.byteLength(),
222
+ mode: "create",
223
+ name: makeProcessWorkerMemoryName(0, sharedMemory.namePrefix),
224
+ }
225
+ : { size: carpet.byteLength() });
226
+ const descriptor = FileDescriptor.fromMapping(mapping);
227
+ if (isWindowsRuntimeHost() && descriptor.name === undefined) {
228
+ throw new Error("Windows process worker shared memory must use a named mapping");
229
+ }
230
+ if (sharedMemory.mode === "named" && descriptor.name === undefined) {
231
+ throw new Error("processSharedMemory mode named needs a named shared-memory backend");
232
+ }
233
+ const buffer = mapping.buffer;
234
+ const bind = (slice) => makeSharedBufferRegion(buffer, slice.byteOffset, slice.byteLength);
235
+ const abortSignals = bind(abortSignalsSlice);
236
+ const requestLock = {
237
+ headers: bind(requestHeadersSlice),
238
+ headerSlotStrideU32: HEADER_SLOT_STRIDE_U32,
239
+ lockSector: bind(requestLockSlice),
240
+ payloadSector: bind(requestLockSlice),
241
+ };
242
+ // Each worker calls cleanup, so named-memory unlink happens only after every
243
+ // endpoint has released its view. The per-worker mapping wrappers below do
244
+ // not expose close(); the host views remain attached until host exit because
245
+ // Bun's external ArrayBuffer finalizer can race an explicit munmap.
246
+ let remaining = threads;
247
+ let cleaned = false;
248
+ const cleanupSharedMapping = () => {
249
+ if (cleaned)
250
+ return;
251
+ remaining--;
252
+ if (remaining > 0)
253
+ return;
254
+ cleaned = true;
255
+ const name = descriptor.name;
256
+ if (name !== undefined && sharedMemory.unlinkOnShutdown) {
257
+ primitives.unlinkSharedMemory?.(name);
258
+ }
259
+ };
260
+ const workerMapping = {
261
+ ...mapping,
262
+ close: undefined,
263
+ };
264
+ const workers = workerSlices.map((slices) => {
265
+ const returnLock = {
266
+ headers: bind(slices.returnHeaders),
267
+ headerSlotStrideU32: HEADER_SLOT_STRIDE_U32,
268
+ lockSector: bind(slices.returnLock),
269
+ payloadSector: bind(slices.returnLock),
270
+ };
271
+ return {
272
+ mapping: workerMapping,
273
+ descriptor,
274
+ controlLayout: {
275
+ controlSAB: buffer,
276
+ signals: bind(slices.signal),
277
+ abortSignals,
278
+ lock: requestLock,
279
+ returnLock,
280
+ slices: carpet.slices,
281
+ },
282
+ lockPayload: bind(requestPayloadSlice),
283
+ returnPayload: bind(slices.returnPayload),
284
+ cleanup: cleanupSharedMapping,
285
+ };
286
+ });
287
+ return {
288
+ mapping,
289
+ descriptor,
290
+ workers,
291
+ abortSignalMax,
292
+ };
293
+ };
194
294
  const toChildProcessSharedBufferMetadata = (source, descriptor) => {
195
295
  const region = toSharedBufferRegion(source);
196
296
  const metadata = descriptor.toMetadata();
@@ -562,9 +662,23 @@ const spawnNodeHostedProcessWorker = ({ workerUrl, bootPayload, memory, processR
562
662
  queueMicrotask(() => child.send?.(bootPayload));
563
663
  }
564
664
  child.on("error", events.emitError);
565
- child.on("exit", (code) => events.emitExit(code ?? -1));
665
+ let resolveExit = () => { };
666
+ const exited = new Promise((resolve) => {
667
+ resolveExit = resolve;
668
+ });
669
+ child.on("exit", (code) => {
670
+ resolveExit();
671
+ events.emitExit(code ?? -1);
672
+ });
566
673
  return {
567
- terminate: () => child.kill(),
674
+ terminate: () => {
675
+ try {
676
+ child.kill();
677
+ }
678
+ catch {
679
+ }
680
+ return exited;
681
+ },
568
682
  unref: () => child.unref?.(),
569
683
  on: events.on,
570
684
  };
@@ -33,6 +33,8 @@ export declare function createHostTxQueue({ max, lock, returnLock, extraReturnLo
33
33
  hasPendingFrames: () => boolean;
34
34
  txIdle: () => boolean;
35
35
  completeFrame: () => number;
36
+ waitForCompletion: (onWake: () => void, timeoutMs?: number) => boolean;
37
+ setCompletionWaiterArmed: (armed: boolean) => void;
36
38
  setReturnHooks: (lane: number, hooks: ReturnHooks | undefined) => void;
37
39
  enqueue: (functionID: FunctionID, timeout?: TaskTimeout, abortSignal?: AbortSignalOption) => (rawArgs: RawArguments) => Promise<never> | import("../common/with-resolvers.js").PromiseWithMaybeReject<unknown>;
38
40
  flushToWorker: () => boolean;
@@ -54,6 +54,18 @@ export function createHostTxQueue({ max, lock, returnLock, extraReturnLocks, rel
54
54
  activeRejectPlaceholder: PLACE_HOLDER,
55
55
  onResolved: onReturnResolved,
56
56
  }));
57
+ const returnWaiters = [
58
+ returnLock,
59
+ ...(extraReturnLocks ?? []),
60
+ ].map((each) => typeof each.waitForHostChange === "function"
61
+ ? each.waitForHostChange
62
+ : () => undefined);
63
+ const returnArmers = [
64
+ returnLock,
65
+ ...(extraReturnLocks ?? []),
66
+ ].map((each) => typeof each.setHostWaiterArmed === "function"
67
+ ? each.setHostWaiterArmed
68
+ : (_armed) => { });
57
69
  // A stealing queue drains one private return lock per worker. Borrowed
58
70
  // BufferReferences must therefore be claimed with the hooks belonging to the
59
71
  // worker that produced that particular return. The hooks are bound after the
@@ -84,6 +96,60 @@ export function createHostTxQueue({ max, lock, returnLock, extraReturnLocks, rel
84
96
  }
85
97
  return resolved;
86
98
  };
99
+ // A stealing queue has one private return lock per worker. Keep at most one
100
+ // waitAsync waiter per lane: after lane A rings, lane B's unresolved waiter
101
+ // must survive the next arm or Atomics.notify(..., 1) could wake an obsolete
102
+ // waiter and starve the live one until the watchdog timeout.
103
+ const completionArmed = new Uint8Array(returnWaiters.length);
104
+ let completionWake;
105
+ let completionGeneration = 0 | 0;
106
+ const setCompletionWaiterArmed = (armed) => {
107
+ for (const setArmed of returnArmers)
108
+ setArmed(armed);
109
+ };
110
+ const waitForCompletion = (onWake, timeoutMs) => {
111
+ completionWake = onWake;
112
+ // A persistent waiter may still be pending after a send preempted the
113
+ // dispatcher. Re-arm its shared gate before relying on that waiter again.
114
+ setCompletionWaiterArmed(true);
115
+ let supported = true;
116
+ for (let index = 0; index < returnWaiters.length; index++) {
117
+ if (completionArmed[index] !== 0)
118
+ continue;
119
+ let wait;
120
+ try {
121
+ wait = returnWaiters[index](timeoutMs);
122
+ }
123
+ catch {
124
+ wait = undefined;
125
+ }
126
+ if (wait === undefined) {
127
+ supported = false;
128
+ break;
129
+ }
130
+ completionArmed[index] = 1;
131
+ const generation = completionGeneration;
132
+ const wakeLane = () => {
133
+ if (completionArmed[index] === 0)
134
+ return;
135
+ completionArmed[index] = 0;
136
+ returnArmers[index](false);
137
+ if (generation !== completionGeneration)
138
+ return;
139
+ completionWake?.();
140
+ };
141
+ if (!wait.async)
142
+ wakeLane();
143
+ else
144
+ Promise.resolve(wait.value).then(wakeLane, wakeLane);
145
+ }
146
+ if (!supported) {
147
+ completionWake = undefined;
148
+ setCompletionWaiterArmed(false);
149
+ completionGeneration = (completionGeneration + 1) | 0;
150
+ }
151
+ return supported;
152
+ };
87
153
  const hasActiveTasks = () => {
88
154
  const count = (inUsed - getPendingPromiseCount()) | 0;
89
155
  return count > 0;
@@ -116,6 +182,8 @@ export function createHostTxQueue({ max, lock, returnLock, extraReturnLocks, rel
116
182
  hasPendingFrames,
117
183
  txIdle,
118
184
  completeFrame,
185
+ waitForCompletion,
186
+ setCompletionWaiterArmed,
119
187
  setReturnHooks: (lane, hooks) => {
120
188
  if (!Number.isInteger(lane) || lane < 0 || lane >= returnHooks.length) {
121
189
  throw new RangeError(`return lane ${lane} out of range`);
@@ -10,4 +10,4 @@ export type NodeWorkerLike = {
10
10
  export declare const toWorkerSafeExecArgv: (flags: string[] | undefined) => string[] | undefined;
11
11
  export declare const toWorkerCompatExecArgv: (flags: string[] | undefined) => string[] | undefined;
12
12
  export declare const serializeWorkerBootstrapData: (options: WorkerSettings) => WorkerSettings;
13
- export declare const terminateWorkerQuietly: (worker: SpawnedWorker) => void;
13
+ export declare const terminateWorkerQuietly: (worker: SpawnedWorker) => Promise<void>;
@@ -95,8 +95,9 @@ export const terminateWorkerQuietly = (worker) => {
95
95
  // Runaway worker termination can be slow or stuck on some runtimes; once the
96
96
  // pool is closing it must not keep the host process alive.
97
97
  worker.unref?.();
98
- void Promise.resolve(worker.terminate()).catch(() => { });
98
+ return Promise.resolve(worker.terminate()).then(() => { }, () => { });
99
99
  }
100
100
  catch {
101
+ return Promise.resolve();
101
102
  }
102
103
  };
package/src/types.d.ts CHANGED
@@ -34,6 +34,8 @@ type WorkerData = {
34
34
  payloadConfig?: PayloadBufferOptions;
35
35
  bufferReferenceReturn?: "copy" | "borrow";
36
36
  permission?: ResolvedPermissionProtocol;
37
+ /** Whether this host can arm an async completion waiter on the return lock. */
38
+ notifyOnHostPublish?: boolean;
37
39
  /**
38
40
  * Work stealing. When present, `lock` is a submit region shared by every
39
41
  * worker and this worker claims from it as consumer `consumerId` of
@@ -341,13 +343,46 @@ type WorkerTimers = {
341
343
  };
342
344
  type DispatcherSettings = {
343
345
  /**
344
- * How many immediate notify loops before backoff kicks in.
346
+ * How many immediate notify loops before the dispatcher stops re-arming the
347
+ * pump for free.
348
+ *
349
+ * The default depends on what the dispatcher escalates *to*. Polling
350
+ * escalates to a `setTimeout` ladder costing ~1.1ms even at delay 0, so it
351
+ * defaults to 128 — escalating is expensive and the wide window also batches
352
+ * completions. A doorbell escalates to `Atomics.waitAsync` at roughly the
353
+ * price of one hop, so pools that have one default to 1. Pools without a
354
+ * doorbell — Deno, or `doorbell: false` — keep 128.
355
+ *
356
+ * Setting this explicitly opts out of that coupling for every pool shape.
345
357
  */
346
358
  stallFreeLoops?: number;
347
359
  /**
348
360
  * Max backoff delay (milliseconds).
349
361
  */
350
362
  maxBackoffMs?: number;
363
+ /**
364
+ * Replace idle completion polling with an `Atomics.waitAsync` doorbell when
365
+ * the host runtime supports it.
366
+ *
367
+ * Defaults to enabled on Node and Bun at any worker count, where it costs
368
+ * 1.6-3.9x less host CPU per completed call. Under HTTP load, where the host
369
+ * has real work of its own, that converts to +13% to +36% throughput.
370
+ *
371
+ * Turn it off for a pool that oversubscribes its machine. The doorbell only
372
+ * progresses when the host gets scheduled, so once workers occupy every core
373
+ * a wake must preempt one: measured +5% to +12.6% rps while workers+host fit
374
+ * within the cores, -22% to -32% once they do not. That is not gated
375
+ * automatically because the core count cannot be probed portably.
376
+ *
377
+ * Forced off, overriding an explicit `true`, where a doorbell cannot work:
378
+ * on Deno, whose `waitAsync` does not wake an idle event loop, and for
379
+ * process workers, which live in another process and so cannot ring a host
380
+ * waiter at all — V8 keeps its Atomics waiter list per isolate, which is why
381
+ * they wake through a native futex addon instead. Compiled (Porffor) workers
382
+ * never reach this path: they reject `host` outright and use pipes rather
383
+ * than shared memory.
384
+ */
385
+ doorbell?: boolean;
351
386
  /**
352
387
  * Host dispatcher topology.
353
388
  * - `"per-thread"`: each worker owns its dispatcher and macro channel.
@@ -366,14 +401,12 @@ type DispatcherSettings = {
366
401
  * claim from, private return lanes, and a pool-global pending registry. The
367
402
  * endpoint that claims a task owns its response.
368
403
  *
369
- * Enabled by default for multi-worker thread pools unless a balancer or
370
- * private-lane dispatcher was explicitly selected. One-worker pools,
371
- * inliners, compiled/Porffor workers, process workers, and pools above the
372
- * current 31-claimant protocol limit retain their existing transport.
373
- * Explicitly requesting `true` with process workers is rejected because
374
- * their transport uses separately mapped process-shared memory. Set `false`
375
- * (or `KNITTING_STEAL=0`) to opt out; `KNITTING_STEAL=1` explicitly opts in
376
- * and overrides a balancer/dispatcher selection.
404
+ * Enabled by default for compatible multi-worker thread and process pools
405
+ * unless a balancer or private-lane dispatcher was explicitly selected.
406
+ * One-worker pools, inliners, compiled/Porffor workers, and pools above the
407
+ * current 31-claimant protocol limit retain their existing transport. Set
408
+ * `false` (or `KNITTING_STEAL=0`) to opt out; `KNITTING_STEAL=1` explicitly
409
+ * opts in and overrides a balancer/dispatcher selection.
377
410
  */
378
411
  steal?: boolean;
379
412
  /**
@@ -73,7 +73,7 @@ export const workerMainLoop = async (startupData) => {
73
73
  installTerminationGuard();
74
74
  installUnhandledRejectionSilencer();
75
75
  installPerformanceNowGuard();
76
- const { debug, sab, thread, startAt, workerOptions, lock, returnLock, abortSignalSAB, abortSignalMax, payloadConfig, bufferReferenceReturn, permission, totalNumberOfThread, list, ids, names, at, steal, } = startupData;
76
+ const { debug, sab, thread, startAt, workerOptions, lock, returnLock, abortSignalSAB, abortSignalMax, payloadConfig, bufferReferenceReturn, permission, notifyOnHostPublish, totalNumberOfThread, list, ids, names, at, steal, } = startupData;
77
77
  scrubWorkerDataSensitiveBuffers(startupData);
78
78
  assertWorkerSharedMemoryBootData({ sab, lock, returnLock });
79
79
  const debugNamespaces = resolveDebugNamespaces(debug);
@@ -120,6 +120,9 @@ export const workerMainLoop = async (startupData) => {
120
120
  payloadConfig,
121
121
  textCompat: returnLock.textCompat,
122
122
  processBoundary: RUNTIME_IS_PROCESS_WORKER,
123
+ // The host parks on this lock's publication word when it has no work to
124
+ // flush. Request locks are host-produced and must not wake that waiter.
125
+ notifyOnHostPublish,
123
126
  });
124
127
  const timers = workerOptions?.timers;
125
128
  const spinMicroseconds = timers?.spinMicroseconds ??
@@ -269,6 +272,10 @@ export const workerMainLoop = async (startupData) => {
269
272
  // a peer withdrawing its intent. Measured to hurt the per-lane path,
270
273
  // where a claim is a cheap private decode and picking work up promptly
271
274
  // matters more, so the classic order is kept below.
275
+ // Reordering must not make `progressed` sticky: it answers "did this
276
+ // iteration move anything", so it has to start false every pass or the
277
+ // park below is unreachable and the worker spins a core forever.
278
+ progressed = false;
272
279
  if (_hasCompleted()) {
273
280
  if (_writeBatch(WRITE_MAX) > 0)
274
281
  progressed = true;