knitting 0.1.70 → 0.1.73

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 (45) hide show
  1. package/README.md +101 -10
  2. package/knitting.browser.d.ts +3 -1
  3. package/knitting.browser.js +1 -1
  4. package/knitting.d.ts +3 -1
  5. package/knitting.js +2 -1
  6. package/package.json +8 -3
  7. package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
  8. package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
  9. package/prebuilds/win32-x64-node-127/knitting_doorbell.node +0 -0
  10. package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
  11. package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
  12. package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
  13. package/prebuilds/win32-x64-node-137/knitting_doorbell.node +0 -0
  14. package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
  15. package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
  16. package/src/api.js +58 -34
  17. package/src/connections/node-addons.js +11 -1
  18. package/src/debug/gate.js +1 -1
  19. package/src/debug/handle.d.ts +6 -1
  20. package/src/debug/handle.js +14 -6
  21. package/src/error.d.ts +9 -0
  22. package/src/error.js +16 -2
  23. package/src/memory/lock.d.ts +48 -6
  24. package/src/memory/lock.js +350 -142
  25. package/src/memory/payloadCodec.js +31 -11
  26. package/src/permission/protocol.d.ts +1 -0
  27. package/src/permission/protocol.js +8 -3
  28. package/src/runtime/dispatcher.d.ts +6 -1
  29. package/src/runtime/dispatcher.js +24 -8
  30. package/src/runtime/inline-executor.js +2 -1
  31. package/src/runtime/pool.d.ts +13 -4
  32. package/src/runtime/pool.js +107 -47
  33. package/src/runtime/process-worker.js +10 -1
  34. package/src/runtime/tx-queue.d.ts +2 -1
  35. package/src/runtime/tx-queue.js +11 -0
  36. package/src/runtime/worker-common.d.ts +7 -0
  37. package/src/runtime/worker-common.js +28 -2
  38. package/src/types.d.ts +35 -11
  39. package/src/worker/loop.js +17 -4
  40. package/src/worker/safety/index.d.ts +1 -1
  41. package/src/worker/safety/index.js +1 -1
  42. package/src/worker/safety/process.d.ts +2 -0
  43. package/src/worker/safety/process.js +8 -1
  44. package/src/worker/safety/startup.js +11 -6
  45. package/src/worker/timers.js +25 -3
@@ -6,24 +6,29 @@ const execFlagKey = (flag) => flag.split("=", 1)[0];
6
6
  const NODE_PERMISSION_EXEC_FLAGS = new Set([
7
7
  "--permission",
8
8
  "--experimental-permission",
9
+ "--experimental-config-file",
10
+ "--experimental-default-config-file",
9
11
  "--allow-fs-read",
10
12
  "--allow-fs-write",
13
+ "--allow-fs-vfs",
11
14
  "--allow-worker",
12
15
  "--allow-child-process",
16
+ "--allow-env",
13
17
  "--allow-net",
14
18
  "--allow-addons",
15
19
  "--allow-ffi",
16
20
  "--allow-wasi",
21
+ "--allow-inspector",
22
+ "--allow-openssl-store",
17
23
  ]);
18
24
  const NODE_WORKER_SAFE_EXEC_FLAGS = new Set([
19
25
  "--experimental-ffi",
20
26
  "--experimental-transform-types",
21
- "--expose-gc",
22
27
  "--no-warnings",
23
28
  ...NODE_PERMISSION_EXEC_FLAGS,
24
29
  ]);
25
30
  const isNodeWorkerSafeExecFlag = (flag) => NODE_WORKER_SAFE_EXEC_FLAGS.has(execFlagKey(flag));
26
- const isNodePermissionExecFlag = (flag) => NODE_PERMISSION_EXEC_FLAGS.has(execFlagKey(flag));
31
+ export const isNodePermissionExecFlag = (flag) => NODE_PERMISSION_EXEC_FLAGS.has(execFlagKey(flag));
27
32
  export const toWorkerSafeExecArgv = (flags) => {
28
33
  if (!flags || flags.length === 0)
29
34
  return undefined;
@@ -40,6 +45,7 @@ export const toWorkerSafeExecArgv = (flags) => {
40
45
  }
41
46
  return deduped;
42
47
  };
48
+ /** Preserve process-worker runtime flags while replacing inherited permissions. */
43
49
  export const toWorkerCompatExecArgv = (flags) => {
44
50
  const safe = toWorkerSafeExecArgv(flags);
45
51
  if (!safe || safe.length === 0)
@@ -47,6 +53,26 @@ export const toWorkerCompatExecArgv = (flags) => {
47
53
  const compat = safe.filter((flag) => !isNodePermissionExecFlag(flag));
48
54
  return compat.length > 0 ? compat : undefined;
49
55
  };
56
+ const droppedExecArgvWarnings = new Set();
57
+ /**
58
+ * Warn once per flag set when a `workerExecArgv` flag the caller asked for did
59
+ * not reach the worker thread, instead of dropping it silently.
60
+ */
61
+ export const warnDroppedWorkerExecArgv = (requested, applied) => {
62
+ if (!requested || requested.length === 0)
63
+ return;
64
+ const kept = new Set(applied ?? []);
65
+ const dropped = requested.filter((flag) => !kept.has(flag));
66
+ if (dropped.length === 0)
67
+ return;
68
+ const key = dropped.join(" ");
69
+ if (droppedExecArgvWarnings.has(key))
70
+ return;
71
+ droppedExecArgvWarnings.add(key);
72
+ console.warn(`knitting: workerExecArgv ${dropped.join(", ")} cannot be applied to a ` +
73
+ `worker thread and was dropped. V8 and process-wide flags must be ` +
74
+ `passed to the host process instead.`);
75
+ };
50
76
  const isPlainRecord = (value) => {
51
77
  if (value === null || typeof value !== "object")
52
78
  return false;
package/src/types.d.ts CHANGED
@@ -28,6 +28,12 @@ type WorkerData = {
28
28
  thread: number;
29
29
  totalNumberOfThread: number;
30
30
  debug?: DebugOptions;
31
+ /**
32
+ * Host's debug zero as `performance.timeOrigin + performance.now()` (Unix
33
+ * epoch ms), so worker debug clocks line up with the host's. Set only when
34
+ * debug is requested.
35
+ */
36
+ debugEpoch?: number;
31
37
  startAt: number;
32
38
  workerOptions?: WorkerSettings;
33
39
  at: number[];
@@ -50,8 +56,8 @@ type WorkerData = {
50
56
  consumers: number;
51
57
  consumerId: number;
52
58
  regionLanes: number;
53
- /** Region mutual-exclusion discipline; see `DispatcherSettings.stealClaim`. */
54
- claim?: "dekker" | "cas-mask";
59
+ /** Claim discipline; see `DispatcherSettings.stealClaim`. */
60
+ claim?: "dekker" | "ticket";
55
61
  };
56
62
  };
57
63
  type UnsafeOptions = {
@@ -173,12 +179,16 @@ type SingleTaskPool<A extends TaskInput = Args, B extends Args = Args, AS extend
173
179
  shutdown: (delayMs?: number) => Promise<void>;
174
180
  /** Starts shutdown at scope exit. Use `shutdown()` when you must await it. */
175
181
  [Symbol.dispose]: () => void;
182
+ /** `await using` awaits worker teardown at scope exit. */
183
+ [Symbol.asyncDispose]: () => Promise<void>;
176
184
  };
177
185
  type Pool<T extends Record<string, TaskLike<any> | TaskFunctionLike>> = {
178
186
  /** Await worker teardown now. `using` disposes at scope exit without awaiting. */
179
187
  shutdown: (delayMs?: number) => Promise<void>;
180
188
  /** Starts shutdown at scope exit. Use `shutdown()` when you must await it. */
181
189
  [Symbol.dispose]: () => void;
190
+ /** `await using` awaits worker teardown at scope exit. */
191
+ [Symbol.asyncDispose]: () => Promise<void>;
182
192
  /**
183
193
  * Typed task callers. Each call accepts the task input or a native Promise.
184
194
  * Thrown errors/rejections reject here as Error objects with cause chains.
@@ -214,8 +224,8 @@ type Balancer = BalancerStrategy | {
214
224
  */
215
225
  strategy?: BalancerStrategy;
216
226
  };
217
- /** Debug namespaces for host setup, worker state, imports, globals, and lifecycle. */
218
- type DebugNamespace = "host" | "globals" | "signals" | "imports" | "lifecycle";
227
+ /** Debug namespaces for host setup, worker state, imports, globals, lifecycle, and steal claims. */
228
+ type DebugNamespace = "host" | "globals" | "signals" | "imports" | "lifecycle" | "steal";
219
229
  type DebugFlags = {
220
230
  [Namespace in DebugNamespace]?: boolean;
221
231
  };
@@ -340,7 +350,10 @@ type WorkerTimers = {
340
350
  */
341
351
  spinMicroseconds?: number;
342
352
  /**
343
- * Atomics.wait timeout when parked (milliseconds).
353
+ * Atomics.wait timeout when parked (milliseconds). Defaults to 1000 for
354
+ * thread workers, which the host wakes on every publish, so the timeout is
355
+ * only a safety net; 1 for process workers, most of which only rediscover
356
+ * work when it expires.
344
357
  */
345
358
  parkMs?: number;
346
359
  /**
@@ -383,8 +396,13 @@ type DispatcherSettings = {
383
396
  * tasks; Dekker requires at least one spare region per live consumer.
384
397
  */
385
398
  stealRegionLanes?: number;
386
- /** Region-claim discipline: per-consumer Dekker intents or a shared CAS mask. */
387
- stealClaim?: "dekker" | "cas-mask";
399
+ /**
400
+ * Publication-ordered tickets (`"ticket"`, the default) or Dekker regions
401
+ * (`"dekker"`). Unrecognised values are rejected rather than defaulted, so a
402
+ * removed discipline such as `cas-mask` fails at pool creation. Ticket pools
403
+ * reject pending and future calls on worker failure.
404
+ */
405
+ stealClaim?: "dekker" | "ticket";
388
406
  };
389
407
  type CreatePool = {
390
408
  /** Number of workers. Default: 1. */
@@ -412,14 +430,20 @@ type CreatePool = {
412
430
  */
413
431
  host?: DispatcherSettings;
414
432
  /**
415
- * Extra Node.js execArgv flags for worker threads (e.g. ["--expose-gc"]).
416
- * Defaults to process.execArgv plus "--expose-gc" when allowed.
433
+ * Extra Node.js execArgv flags for worker threads (e.g. ["--no-warnings"]).
434
+ * Defaults to compatible flags from process.execArgv. Node permission flags
435
+ * are replaced by the resolved `permission` policy.
436
+ *
437
+ * Node rejects V8 and process-wide flags (`--expose-gc`,
438
+ * `--max-old-space-size`, ...). Unsupported caller flags are dropped with a
439
+ * warning. Permission flags are never dropped to start an unpermissioned
440
+ * thread worker; pool creation fails if Node cannot apply them.
417
441
  */
418
442
  workerExecArgv?: string[];
419
443
  /**
420
444
  * Runtime permission protocol.
421
- * Omit to use strict defaults with `allowImport: true`; worker console is
422
- * quiet unless `permission: { console: true }`.
445
+ * Omit to use strict defaults with `allowImport: true`. `console` is
446
+ * accepted but not enforced: worker console output is always forwarded.
423
447
  *
424
448
  * Task code cannot terminate the host: process/Deno exit APIs are blocked.
425
449
  * Use `"strict"` (default for object mode) or `"unsafe"`.
@@ -13,12 +13,14 @@ import { getFunctions } from "./task-loader.js";
13
13
  import { IS_BUN_WINDOWS, pauseGeneric, sleepUntilChanged, whilePausing, } from "./timers.js";
14
14
  import { IS_ANDROMEDA, RUNTIME, SET_IMMEDIATE } from "../common/runtime.js";
15
15
  import { getNodeProcess } from "../common/node-compat.js";
16
- import { assertWorkerImportsResolved, assertWorkerSharedMemoryBootData, installPerformanceNowGuard, installTerminationGuard, installUnhandledRejectionSilencer, scrubWorkerDataSensitiveBuffers, } from "./safety/index.js";
16
+ import { assertWorkerImportsResolved, assertWorkerSharedMemoryBootData, installPerformanceNowGuard, installTerminationGuard, installUnhandledRejectionSilencer, reportSilencedRejections, scrubWorkerDataSensitiveBuffers, } from "./safety/index.js";
17
17
  import { signalAbortFactory } from "../shared/abortSignal.js";
18
18
  import { runWorkerBootstrap } from "./bootstrap.js";
19
19
  import { getProcessWorkerNativeWaitU32, installProcessWorkerBootstrap, } from "./process-worker-bootstrap.js";
20
20
  import { resolveDebugNamespaces } from "../debug/gate.js";
21
21
  const WORKER_FATAL_MESSAGE_KEY = "__knittingWorkerFatal";
22
+ // Park timeout while finished results wait for return slots (see the loop).
23
+ const BLOCKED_RETURN_PARK_MS = 1;
22
24
  const reportWorkerStartupFatal = (error) => {
23
25
  const message = String(error?.message ?? error);
24
26
  const payload = {
@@ -59,17 +61,22 @@ export const workerMainLoop = async (startupData) => {
59
61
  installTerminationGuard();
60
62
  installUnhandledRejectionSilencer();
61
63
  installPerformanceNowGuard();
62
- const { debug, sab, thread, startAt, workerOptions, lock, returnLock, abortSignalSAB, abortSignalMax, payloadConfig, sharedReturn, permission, notifyOnHostPublish, processCompletionDoorbell, denoCompletionDoorbell, nodeCompletionDoorbell, totalNumberOfThread, list, ids, names, at, steal, } = startupData;
64
+ const { debug, debugEpoch, sab, thread, startAt, workerOptions, lock, returnLock, abortSignalSAB, abortSignalMax, payloadConfig, sharedReturn, permission, notifyOnHostPublish, processCompletionDoorbell, denoCompletionDoorbell, nodeCompletionDoorbell, totalNumberOfThread, list, ids, names, at, steal, } = startupData;
63
65
  scrubWorkerDataSensitiveBuffers(startupData);
64
66
  assertWorkerSharedMemoryBootData({ sab, lock, returnLock });
65
67
  const debugNamespaces = resolveDebugNamespaces(debug);
66
68
  const dbg = debugNamespaces.size > 0
67
- ? await import("../debug/handle.ts").then((module) => module.initDebug({
69
+ ? await import("../debug/handle.js").then((module) => module.initDebug({
68
70
  name: `w${thread}`,
69
71
  runtime: RUNTIME,
70
72
  namespaces: debugNamespaces,
73
+ epoch: debugEpoch,
71
74
  }))
72
75
  : undefined;
76
+ if (dbg?.enabled("lifecycle") === true) {
77
+ // Detached task promises reject silently otherwise (see the silencer).
78
+ reportSilencedRejections((reason) => dbg.log("lifecycle", `unhandled rejection silenced: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`));
79
+ }
73
80
  // Andromeda cannot parse enums.
74
81
  const Comment = {
75
82
  thisIsAHint: 0,
@@ -97,6 +104,10 @@ export const workerMainLoop = async (startupData) => {
97
104
  consumerId: steal?.consumerId,
98
105
  regionLanes: steal?.regionLanes,
99
106
  stealClaim: steal?.claim,
107
+ // Undefined unless `steal` is traced, so the claim path stays untouched.
108
+ traceClaim: steal !== undefined && dbg?.enabled("steal") === true
109
+ ? (message) => dbg.log("steal", message)
110
+ : undefined,
100
111
  });
101
112
  const notifyDenoHost = createDenoCompletionNotifier(denoCompletionDoorbell);
102
113
  const notifyNodeHost = createNodeCompletionNotifier(nodeCompletionDoorbell);
@@ -342,7 +353,9 @@ export const workerMainLoop = async (startupData) => {
342
353
  } while (txStatus[Comment.thisIsAHint] === 1);
343
354
  continue;
344
355
  }
345
- _pauseUntil(wakeToken, spinMicroseconds, parkMs);
356
+ // Results that could not be written wait on the host freeing return
357
+ // slots, which rings no worker in particular: keep that park short.
358
+ _pauseUntil(wakeToken, spinMicroseconds, _hasCompleted() ? Math.min(parkMs, BLOCKED_RETURN_PARK_MS) : parkMs);
346
359
  wakeToken = a_load(opView, 0);
347
360
  }
348
361
  }
@@ -1,4 +1,4 @@
1
- export { installTerminationGuard, installUnhandledRejectionSilencer, } from "./process.js";
1
+ export { installTerminationGuard, installUnhandledRejectionSilencer, reportSilencedRejections, } from "./process.js";
2
2
  export { installPerformanceNowGuard } from "./performance.js";
3
3
  export { scrubWorkerDataSensitiveBuffers } from "./worker-data.js";
4
4
  export { assertWorkerSharedMemoryBootData, assertWorkerImportsResolved, } from "./startup.js";
@@ -1,4 +1,4 @@
1
- export { installTerminationGuard, installUnhandledRejectionSilencer, } from "./process.js";
1
+ export { installTerminationGuard, installUnhandledRejectionSilencer, reportSilencedRejections, } from "./process.js";
2
2
  export { installPerformanceNowGuard } from "./performance.js";
3
3
  export { scrubWorkerDataSensitiveBuffers } from "./worker-data.js";
4
4
  export { assertWorkerSharedMemoryBootData, assertWorkerImportsResolved, } from "./startup.js";
@@ -1,2 +1,4 @@
1
1
  export declare const installTerminationGuard: () => void;
2
+ /** Debug hook: see what the silencer below swallows. Unset by default. */
3
+ export declare const reportSilencedRejections: (report: ((reason: unknown) => void) | undefined) => void;
2
4
  export declare const installUnhandledRejectionSilencer: () => void;
@@ -66,6 +66,11 @@ export const installTerminationGuard = () => {
66
66
  }
67
67
  proc.__knittingTerminationGuard = true;
68
68
  };
69
+ let reportSilencedRejection;
70
+ /** Debug hook: see what the silencer below swallows. Unset by default. */
71
+ export const reportSilencedRejections = (report) => {
72
+ reportSilencedRejection = report;
73
+ };
69
74
  export const installUnhandledRejectionSilencer = () => {
70
75
  const proc = getNodeProcess();
71
76
  if (!proc || typeof proc.on !== "function") {
@@ -75,5 +80,7 @@ export const installUnhandledRejectionSilencer = () => {
75
80
  return;
76
81
  proc.__knittingUnhandledRejectionSilencer = true;
77
82
  // Worker task code may create detached promises; keep workers alive.
78
- proc.on("unhandledRejection", () => { });
83
+ proc.on("unhandledRejection", (reason) => {
84
+ reportSilencedRejection?.(reason);
85
+ });
79
86
  };
@@ -21,10 +21,15 @@ export const assertWorkerImportsResolved = ({ list, ids, names, listOfFunctions
21
21
  if (listOfFunctions.length > 0 &&
22
22
  (names === undefined || listOfFunctions.length === names.length))
23
23
  return;
24
- console.log(list);
25
- console.log(ids);
26
- if (names !== undefined)
27
- console.log(names);
28
- console.log(listOfFunctions);
29
- throw new Error("No imports were found.");
24
+ const found = new Set(listOfFunctions.map((entry) => entry?.name));
25
+ const missing = (names ?? []).filter((name) => !found.has(name));
26
+ const tasks = missing.length > 0
27
+ ? ` Missing task export(s): ${missing.join(", ")}.`
28
+ : "";
29
+ const modules = list.length > 0 ? ` Looked in: ${list.join(", ")}.` : "";
30
+ throw new Error(`No imports were found.${tasks}${modules}` +
31
+ " Workers re-import each listed module and look tasks up by export" +
32
+ " name: a bare function must be exported from the module that calls" +
33
+ " createPool, and a bundle that inlines task modules drops their" +
34
+ " exports.");
30
35
  };
@@ -2,10 +2,24 @@
2
2
  const Comment = {
3
3
  thisIsAHint: 0,
4
4
  };
5
+ // A full collection costs milliseconds. Run on every idle entry, it made each
6
+ // sequential call ~1000x slower under --expose-gc; once a second keeps the
7
+ // idle collection without paying it per call.
8
+ const IDLE_GC_INTERVAL_MS = 1000;
5
9
  const maybeGc = (() => {
6
10
  const host = globalThis;
7
- const gc = typeof host.gc === "function"
8
- ? (() => host.gc())
11
+ // Capture the function itself: the global is deleted below, so reading
12
+ // `host.gc` at call time would throw on the first idle pass.
13
+ const exposedGc = host.gc;
14
+ let lastGcAt = -Infinity;
15
+ const gc = typeof exposedGc === "function"
16
+ ? (() => {
17
+ const now = performance.now();
18
+ if (now - lastGcAt < IDLE_GC_INTERVAL_MS)
19
+ return;
20
+ lastGcAt = now;
21
+ exposedGc();
22
+ })
9
23
  : undefined;
10
24
  if (gc) {
11
25
  try {
@@ -82,7 +96,6 @@ export const sleepUntilChanged = ({ at, opView, pauseInNanoseconds, rxStatus, tx
82
96
  };
83
97
  return (value, spinMicroseconds, parkMs) => {
84
98
  const until = p_now() + (spinMicroseconds / 1000);
85
- maybeGc();
86
99
  let spinChecks = 0;
87
100
  while (true) {
88
101
  if (a_load(opView, at) !== value ||
@@ -94,9 +107,18 @@ export const sleepUntilChanged = ({ at, opView, pauseInNanoseconds, rxStatus, tx
94
107
  if ((spinChecks++ & 63) === 0 && p_now() >= until)
95
108
  break;
96
109
  }
110
+ // Collect only once the spin found nothing, then re-check before parking.
111
+ maybeGc();
97
112
  if (tryProgress())
98
113
  return;
99
114
  a_store(rxStatus, 0, 0);
115
+ // The other half of the host's post-publish check: a publication that
116
+ // landed before rxStatus cleared is visible now, and one after it finds
117
+ // rxStatus clear and rings.
118
+ if (a_load(opView, at) !== value || tryProgress()) {
119
+ a_store(rxStatus, 0, 1);
120
+ return;
121
+ }
100
122
  if (nativeWaitU32 !== undefined) {
101
123
  nativeWaitU32(opView.buffer, opView.byteOffset + (at * Int32Array.BYTES_PER_ELEMENT), value >>> 0, nativeWaitTimeoutMs(parkMs));
102
124
  }