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.
@@ -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,5 @@ 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
+ /** Terminate a worker, optionally waiting for it to exit. */
14
+ export declare const terminateWorkerQuietly: (worker: SpawnedWorker, awaitExit?: boolean) => Promise<void>;
@@ -90,13 +90,28 @@ export const serializeWorkerBootstrapData = (options) => {
90
90
  },
91
91
  };
92
92
  };
93
- export const terminateWorkerQuietly = (worker) => {
93
+ // Bound the wait for process-worker exit.
94
+ const WORKER_EXIT_WAIT_MS = 2_000;
95
+ /** Terminate a worker, optionally waiting for it to exit. */
96
+ export const terminateWorkerQuietly = (worker, awaitExit = false) => {
94
97
  try {
95
- // Runaway worker termination can be slow or stuck on some runtimes; once the
96
- // pool is closing it must not keep the host process alive.
97
- worker.unref?.();
98
- void Promise.resolve(worker.terminate()).catch(() => { });
98
+ if (!awaitExit)
99
+ worker.unref?.();
100
+ const exited = Promise.resolve(worker.terminate()).then(() => { }, () => { });
101
+ if (!awaitExit)
102
+ return exited;
103
+ return new Promise((resolve) => {
104
+ const timer = setTimeout(() => {
105
+ worker.unref?.();
106
+ resolve();
107
+ }, WORKER_EXIT_WAIT_MS);
108
+ void exited.then(() => {
109
+ clearTimeout(timer);
110
+ resolve();
111
+ });
112
+ });
99
113
  }
100
114
  catch {
115
+ return Promise.resolve();
101
116
  }
102
117
  };
package/src/types.d.ts CHANGED
@@ -13,6 +13,8 @@ type WorkerInvoke = (args: Uint8Array) => Promise<unknown>;
13
13
  interface WorkerContext {
14
14
  txIdle(): boolean;
15
15
  call(descriptor: WorkerCall): WorkerInvoke;
16
+ /** Ask the worker to leave its dispatch loop before termination. */
17
+ requestStop?(): Promise<void>;
16
18
  kills(): Promise<void>;
17
19
  }
18
20
  type CreateContext = WorkerContext;
@@ -34,6 +36,8 @@ type WorkerData = {
34
36
  payloadConfig?: PayloadBufferOptions;
35
37
  bufferReferenceReturn?: "copy" | "borrow";
36
38
  permission?: ResolvedPermissionProtocol;
39
+ /** Whether this host can arm an async completion waiter on the return lock. */
40
+ notifyOnHostPublish?: boolean;
37
41
  /**
38
42
  * Work stealing. When present, `lock` is a submit region shared by every
39
43
  * worker and this worker claims from it as consumer `consumerId` of
@@ -341,13 +345,46 @@ type WorkerTimers = {
341
345
  };
342
346
  type DispatcherSettings = {
343
347
  /**
344
- * How many immediate notify loops before backoff kicks in.
348
+ * How many immediate notify loops before the dispatcher stops re-arming the
349
+ * pump for free.
350
+ *
351
+ * The default depends on what the dispatcher escalates *to*. Polling
352
+ * escalates to a `setTimeout` ladder costing ~1.1ms even at delay 0, so it
353
+ * defaults to 128 — escalating is expensive and the wide window also batches
354
+ * completions. A doorbell escalates to `Atomics.waitAsync` at roughly the
355
+ * price of one hop, so pools that have one default to 1. Pools without a
356
+ * doorbell — Deno, or `doorbell: false` — keep 128.
357
+ *
358
+ * Setting this explicitly opts out of that coupling for every pool shape.
345
359
  */
346
360
  stallFreeLoops?: number;
347
361
  /**
348
362
  * Max backoff delay (milliseconds).
349
363
  */
350
364
  maxBackoffMs?: number;
365
+ /**
366
+ * Replace idle completion polling with an `Atomics.waitAsync` doorbell when
367
+ * the host runtime supports it.
368
+ *
369
+ * Defaults to enabled on Node and Bun at any worker count, where it costs
370
+ * 1.6-3.9x less host CPU per completed call. Under HTTP load, where the host
371
+ * has real work of its own, that converts to +13% to +36% throughput.
372
+ *
373
+ * Turn it off for a pool that oversubscribes its machine. The doorbell only
374
+ * progresses when the host gets scheduled, so once workers occupy every core
375
+ * a wake must preempt one: measured +5% to +12.6% rps while workers+host fit
376
+ * within the cores, -22% to -32% once they do not. That is not gated
377
+ * automatically because the core count cannot be probed portably.
378
+ *
379
+ * Forced off, overriding an explicit `true`, where a doorbell cannot work:
380
+ * on Deno, whose `waitAsync` does not wake an idle event loop, and for
381
+ * process workers, which live in another process and so cannot ring a host
382
+ * waiter at all — V8 keeps its Atomics waiter list per isolate, which is why
383
+ * they wake through a native futex addon instead. Compiled (Porffor) workers
384
+ * never reach this path: they reject `host` outright and use pipes rather
385
+ * than shared memory.
386
+ */
387
+ doorbell?: boolean;
351
388
  /**
352
389
  * Host dispatcher topology.
353
390
  * - `"per-thread"`: each worker owns its dispatcher and macro channel.
@@ -366,14 +403,12 @@ type DispatcherSettings = {
366
403
  * claim from, private return lanes, and a pool-global pending registry. The
367
404
  * endpoint that claims a task owns its response.
368
405
  *
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.
406
+ * Enabled by default for compatible multi-worker thread and process pools
407
+ * unless a balancer or private-lane dispatcher was explicitly selected.
408
+ * One-worker pools, inliners, compiled/Porffor workers, and pools above the
409
+ * current 31-claimant protocol limit retain their existing transport. Set
410
+ * `false` (or `KNITTING_STEAL=0`) to opt out; `KNITTING_STEAL=1` explicitly
411
+ * opts in and overrides a balancer/dispatcher selection.
377
412
  */
378
413
  steal?: boolean;
379
414
  /**
@@ -2,7 +2,7 @@ import { addRuntimeDataListener, createRuntimeMessageChannel, RUNTIME_IS_MAIN_TH
2
2
  import { isSharedBufferSource } from "../common/shared-buffer-region.js";
3
3
  import { isLockBufferTextCompat } from "../common/shared-buffer-text.js";
4
4
  import { createWorkerRxQueue } from "./rx-queue.js";
5
- import { createSharedMemoryTransport } from "../ipc/transport/shared-memory.js";
5
+ import { createSharedMemoryTransport, WORKER_STOP, } from "../ipc/transport/shared-memory.js";
6
6
  import { lock2 } 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.
@@ -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,21 +120,23 @@ 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 ??
126
- Math.max(1, totalNumberOfThread) * 50;
129
+ (totalNumberOfThread <= 1 ? 50 : 0);
127
130
  const parkMs = dbg !== undefined
128
131
  ? Number.POSITIVE_INFINITY
129
- : (timers?.parkMs ??
130
- Math.max(1, totalNumberOfThread) * 50);
132
+ : (timers?.parkMs ?? 1);
131
133
  const pauseSpin = (() => {
132
134
  const fn = typeof timers?.pauseNanoseconds === "number"
133
135
  ? whilePausing({ pauseInNanoseconds: timers.pauseNanoseconds })
134
136
  : pauseGeneric;
135
137
  return () => fn(); // always a closure wrapper
136
138
  })();
137
- const { opView, rxStatus, txStatus } = signals;
139
+ const { opView, rxStatus, txStatus, stopView } = signals;
138
140
  const a_store = Atomics.store;
139
141
  const a_load = Atomics.load;
140
142
  const nativeWaitU32 = getProcessWorkerNativeWaitU32();
@@ -258,17 +260,33 @@ export const workerMainLoop = async (startupData) => {
258
260
  }
259
261
  : pauseUntil;
260
262
  const flushBeforeClaim = steal !== undefined;
263
+ /** Leave the dispatch loop and acknowledge shutdown. */
264
+ const stopLoop = () => {
265
+ a_store(stopView, 0, WORKER_STOP.acknowledged);
266
+ a_store(rxStatus, 0, 0);
267
+ try {
268
+ port1.close?.();
269
+ port2.close?.();
270
+ }
271
+ catch { }
272
+ };
261
273
  const loop = () => {
262
274
  isInMacro = false;
263
275
  let progressed = true;
264
276
  let awaiting = 0;
265
277
  while (true) {
278
+ if (stopView[0] !== WORKER_STOP.running)
279
+ return stopLoop();
266
280
  if (flushBeforeClaim) {
267
281
  // Stealing only. Flush finished work before taking more on: a computed
268
282
  // response should not wait behind a claim, and that claim can block on
269
283
  // a peer withdrawing its intent. Measured to hurt the per-lane path,
270
284
  // where a claim is a cheap private decode and picking work up promptly
271
285
  // matters more, so the classic order is kept below.
286
+ // Reordering must not make `progressed` sticky: it answers "did this
287
+ // iteration move anything", so it has to start false every pass or the
288
+ // park below is unreachable and the worker spins a core forever.
289
+ progressed = false;
272
290
  if (_hasCompleted()) {
273
291
  if (_writeBatch(WRITE_MAX) > 0)
274
292
  progressed = true;