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
package/knitting.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { workerMainLoop } from "./src/worker/loop.js";
2
2
  import { checkCompiledWorker, createPool, importTask, isMain, setModuleUrl, task } from "./src/api.js";
3
3
  import { Envelope } from "./src/common/envelope.js";
4
+ import { KnittingError } from "./src/error.js";
4
5
  import { isNumericArray, NumericArray } from "./src/connections/numeric-array.js";
5
- export { checkCompiledWorker as checkCompiledWorker, createPool as createPool, Envelope as Envelope, importTask as importTask, isMain as isMain, isNumericArray as isNumericArray, NumericArray as NumericArray, setModuleUrl as setModuleUrl, task as task, workerMainLoop as workerMainLoop, };
6
+ export { checkCompiledWorker as checkCompiledWorker, createPool as createPool, Envelope as Envelope, importTask as importTask, isMain as isMain, isNumericArray as isNumericArray, KnittingError as KnittingError, NumericArray as NumericArray, setModuleUrl as setModuleUrl, task as task, workerMainLoop as workerMainLoop, };
6
7
  export type { EnvelopeBody as EnvelopeBody, EnvelopeHeader as EnvelopeHeader, } from "./src/common/envelope.js";
8
+ export type { KnittingErrorCode } from "./src/error.js";
7
9
  export type { CompiledWorkerCheck, CompiledWorkerOptions, CompiledWorkerSource, } from "./src/types.js";
package/knitting.js CHANGED
@@ -2,5 +2,6 @@
2
2
  import { workerMainLoop } from "./src/worker/loop.js";
3
3
  import { checkCompiledWorker, createPool, importTask, isMain, setModuleUrl, task, } from "./src/api.js";
4
4
  import { Envelope } from "./src/common/envelope.js";
5
+ import { KnittingError } from "./src/error.js";
5
6
  import { isNumericArray, NumericArray, } from "./src/connections/numeric-array.js";
6
- export { checkCompiledWorker as checkCompiledWorker, createPool as createPool, Envelope as Envelope, importTask as importTask, isMain as isMain, isNumericArray as isNumericArray, NumericArray as NumericArray, setModuleUrl as setModuleUrl, task as task, workerMainLoop as workerMainLoop, };
7
+ export { checkCompiledWorker as checkCompiledWorker, createPool as createPool, Envelope as Envelope, importTask as importTask, isMain as isMain, isNumericArray as isNumericArray, KnittingError as KnittingError, NumericArray as NumericArray, setModuleUrl as setModuleUrl, task as task, workerMainLoop as workerMainLoop, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knitting",
3
- "version": "0.1.70",
3
+ "version": "0.1.73",
4
4
  "description": "Shared-memory IPC runtime for Node.js, Deno, and Bun.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -83,7 +83,7 @@
83
83
  "release:npm": "npm run test:all && npm publish",
84
84
  "release:npm:dry-run": "npm publish --dry-run",
85
85
  "test": "npm run test:deno",
86
- "test:deno": "deno test -A --ignore=test/runtime.node.test.ts --ignore=test/runtime.process.test.ts",
86
+ "test:deno": "deno test -A --ignore=test/runtime.node.test.ts --ignore=test/runtime.process.test.ts test",
87
87
  "test:node": "node --no-warnings --experimental-transform-types --test \"./test/*.test.ts\"",
88
88
  "test:node:26": "node --no-warnings --experimental-ffi test/node26-ffi.integration.mjs",
89
89
  "test:bun": "bun test",
@@ -102,7 +102,12 @@
102
102
  "bun": ">=1.0.0"
103
103
  },
104
104
  "peerDependencies": {
105
- "typescript": "^5.2.0"
105
+ "typescript": ">=5.2.0"
106
+ },
107
+ "peerDependenciesMeta": {
108
+ "typescript": {
109
+ "optional": true
110
+ }
106
111
  },
107
112
  "devDependencies": {
108
113
  "@types/bun": "latest",
package/src/api.js CHANGED
@@ -4,6 +4,7 @@ import { genTaskID, stableTaskID } from "./common/task-source.js";
4
4
  import { toModuleUrl } from "./common/module-url.js";
5
5
  import { endpointSymbol } from "./common/task-symbol.js";
6
6
  import { createStealPoolBuffers, MAX_STEAL_CONSUMERS, spawnWorkerContext, } from "./runtime/pool.js";
7
+ import { isNodePermissionExecFlag } from "./runtime/worker-common.js";
7
8
  import { ChannelHandler, hostDispatcherLoop } from "./runtime/dispatcher.js";
8
9
  import { createDenoCompletionDoorbell } from "./runtime/deno-doorbell.js";
9
10
  import { createSharedArrayBuffer, RUNTIME } from "./common/runtime.js";
@@ -11,17 +12,20 @@ import { RUNTIME_IS_MAIN_THREAD, RUNTIME_POOL_DEPTH, RUNTIME_WORKER_DATA, } from
11
12
  import { classifyProcessPermissionCompatibility, enforceProcessPermissionCompatibility, resolvePermissionProtocol, toRuntimePermissionFlags, } from "./permission/index.js";
12
13
  import { getNodeProcess } from "./common/node-compat.js";
13
14
  import { readProcessSharedMemorySettings, readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
15
+ import { assertStealClaim, DEFAULT_STEAL_CLAIM, } from "./memory/lock.js";
14
16
  import { TRANSPORT_SIGNAL_BYTES } from "./ipc/transport/shared-memory.js";
15
17
  import { inspectCompiledWorkerArtifact } from "./runtime/compiled-artifact.js";
16
18
  import { managerMethod } from "./runtime/balancer.js";
17
19
  import { createInlineExecutor } from "./runtime/inline-executor.js";
18
20
  import { createHostArgAllocator } from "./runtime/host-arg-arena.js";
19
21
  const hasDebugNamespace = (namespaces, namespace) => namespaces.has("*") || namespaces.has(namespace);
20
- const createHostDebug = (namespaces) => {
22
+ const createHostDebug = (namespaces, epoch) => {
21
23
  const enabled = (namespace) => hasDebugNamespace(namespaces, namespace);
22
24
  if (!enabled("host"))
23
25
  return undefined;
24
- const base = performance.now();
26
+ // Same rebase the workers do (see debug/handle.ts), so host and worker lines
27
+ // share one zero.
28
+ const base = epoch - performance.timeOrigin;
25
29
  const tag = `host·${RUNTIME}`;
26
30
  const log = (message) => {
27
31
  const elapsed = (performance.now() - base).toFixed(1);
@@ -250,8 +254,14 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
250
254
  (debug !== undefined && debug !== false);
251
255
  let debugNamespaces;
252
256
  const getDebugNamespaces = () => debugNamespaces ??= resolveDebugNamespaces(debug);
253
- const hostDebug = debugRequested
254
- ? createHostDebug(getDebugNamespaces())
257
+ // Absolute (Unix-epoch ms) zero for every debug clock in this pool. Raw
258
+ // `performance.now()` is relative to each thread/process's own time origin;
259
+ // `timeOrigin + now()` is not, so workers can rebase onto this value.
260
+ const debugEpoch = debugRequested
261
+ ? performance.timeOrigin + performance.now()
262
+ : undefined;
263
+ const hostDebug = debugEpoch !== undefined
264
+ ? createHostDebug(getDebugNamespaces(), debugEpoch)
255
265
  : undefined;
256
266
  const debugEnabled = (namespace) => debugRequested && hasDebugNamespace(getDebugNamespaces(), namespace);
257
267
  /**
@@ -278,6 +288,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
278
288
  return {
279
289
  shutdown: mainThreadOnlyProxy,
280
290
  [Symbol.dispose]: () => { },
291
+ [Symbol.asyncDispose]: async () => { },
281
292
  call: mainThreadOnlyProxy,
282
293
  };
283
294
  }
@@ -306,20 +317,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
306
317
  const permissionExecArgv = toRuntimePermissionFlags(permissionProtocol);
307
318
  const nodeProcess = getNodeProcess();
308
319
  const allowedFlags = nodeProcess?.allowedNodeEnvironmentFlags ?? null;
309
- const isNodePermissionFlag = (flag) => {
310
- const key = flag.split("=", 1)[0];
311
- return key === "--permission" ||
312
- key === "--experimental-permission" ||
313
- key === "--allow-fs-read" ||
314
- key === "--allow-fs-write" ||
315
- key === "--allow-worker" ||
316
- key === "--allow-child-process" ||
317
- key === "--allow-net" ||
318
- key === "--allow-addons" ||
319
- key === "--allow-ffi" ||
320
- key === "--allow-wasi";
321
- };
322
- const stripNodePermissionFlags = (flags) => flags?.filter((flag) => !isNodePermissionFlag(flag));
320
+ const stripNodePermissionFlags = (flags) => flags?.filter((flag) => !isNodePermissionExecFlag(flag));
323
321
  const dedupeFlags = (flags) => {
324
322
  const out = [];
325
323
  const seen = new Set();
@@ -346,16 +344,11 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
346
344
  ? nodeProcess.execArgv
347
345
  : undefined;
348
346
  const defaultExecArgvCandidate = workerExecArgv ??
349
- (inheritedExecArgv
350
- ? (allowedFlags?.has("--expose-gc") === true
351
- ? (inheritedExecArgv.includes("--expose-gc")
352
- ? inheritedExecArgv
353
- : [...inheritedExecArgv, "--expose-gc"])
354
- : inheritedExecArgv)
355
- : undefined);
356
- const defaultExecArgv = permissionProtocol?.unsafe === true
357
- ? stripNodePermissionFlags(defaultExecArgvCandidate)
358
- : defaultExecArgvCandidate;
347
+ inheritedExecArgv;
348
+ // Knitting resolves the worker's permission policy itself. Do not inherit
349
+ // the host's Node grants (or caller-supplied permission flags), which could
350
+ // broaden that policy when Node combines repeated --allow-* options.
351
+ const defaultExecArgv = stripNodePermissionFlags(defaultExecArgvCandidate);
359
352
  const combinedExecArgv = dedupeFlags([
360
353
  ...permissionExecArgv,
361
354
  ...(defaultExecArgv ?? []),
@@ -367,6 +360,16 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
367
360
  hostDebug?.log(`permission=${permissionProtocol?.mode ?? "off"} execArgv=${formatDebugList(execArgv)}`);
368
361
  const usesAbortSignal = listOfFunctions.some((fn) => fn.abortSignal !== undefined);
369
362
  const resolvedWorker = resolveWorkerSettings(worker, callerHref);
363
+ if (RUNTIME === "node" &&
364
+ resolvedWorker?.runtime !== "process" &&
365
+ resolvedWorker?.runtime !== "compiled") {
366
+ const missingPermissionFlags = permissionExecArgv.filter((flag) => isNodePermissionExecFlag(flag) && !execArgv?.includes(flag));
367
+ if (missingPermissionFlags.length > 0) {
368
+ throw new Error("Node cannot apply the resolved permission policy to this thread " +
369
+ `worker (unsupported flags: ${missingPermissionFlags.join(", ")}). ` +
370
+ "Refusing to start the worker without those permissions.");
371
+ }
372
+ }
370
373
  const dispatcherEnv = nodeProcess?.env?.KNITTING_DISPATCHER;
371
374
  const stealEnvRaw = nodeProcess?.env?.KNITTING_STEAL?.trim().toLowerCase();
372
375
  const stealEnv = stealEnvRaw === "1" || stealEnvRaw === "true"
@@ -383,12 +386,15 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
383
386
  const stealRequested = host?.steal ?? stealEnv ?? stealDefaultCompatible;
384
387
  const stealClaimEnvRaw = nodeProcess?.env?.KNITTING_STEAL_CLAIM?.trim()
385
388
  .toLowerCase();
386
- const stealClaimEnv = stealClaimEnvRaw === "cas-mask" ||
387
- stealClaimEnvRaw === "dekker"
388
- ? stealClaimEnvRaw
389
- : undefined;
389
+ // An unrecognised discipline is an error, not a fallback: `cas-mask` and
390
+ // typos used to select Dekker silently and run under the wrong name.
391
+ const stealClaimEnv = stealClaimEnvRaw === undefined || stealClaimEnvRaw === ""
392
+ ? undefined
393
+ : assertStealClaim(stealClaimEnvRaw, "KNITTING_STEAL_CLAIM");
390
394
  // Select the stealing claim discipline, preferring the explicit option.
391
- const stealClaim = host?.stealClaim ?? stealClaimEnv ?? "dekker";
395
+ const stealClaim = host?.stealClaim === undefined
396
+ ? stealClaimEnv ?? DEFAULT_STEAL_CLAIM
397
+ : assertStealClaim(host.stealClaim, "host.stealClaim");
392
398
  const usingCompiledWorker = resolvedWorker?.runtime === "compiled";
393
399
  if (resolvedWorker?.compiled !== undefined && !usingCompiledWorker) {
394
400
  throw new Error("worker.compiled requires worker.runtime to be compiled");
@@ -526,6 +532,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
526
532
  at,
527
533
  thread,
528
534
  debug,
535
+ debugEpoch,
529
536
  hostDebug: hostDebug?.log,
530
537
  totalNumberOfThread,
531
538
  // Worker count without the inline lane. The inliner runs on the host
@@ -534,6 +541,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
534
541
  source,
535
542
  workerOptions: resolvedWorker,
536
543
  workerExecArgv: execArgv,
544
+ requestedExecArgv: workerExecArgv,
537
545
  host,
538
546
  payload,
539
547
  sharedBytesEnabled,
@@ -564,14 +572,27 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
564
572
  // Round-robin wake keeps stealing at one notify per publish; any idle worker
565
573
  // can claim the region.
566
574
  let wakeCursor = 0;
575
+ let lastWoken = 0;
567
576
  const wakeOne = () => {
568
577
  const lanes = workers.length;
569
578
  if (lanes === 0)
570
579
  return;
571
- const lane = wakeCursor;
580
+ const lane = lastWoken = wakeCursor;
572
581
  wakeCursor = wakeCursor + 1 < lanes ? wakeCursor + 1 : 0;
573
582
  workers[lane].laneWake?.();
574
583
  };
584
+ // Any awake worker will claim what was just published, so a lost wake
585
+ // only strands work when every worker is parked. Ring the lane this pass
586
+ // already rang: one still waking up absorbs it, and a second lane would
587
+ // be one more thread woken per call.
588
+ const wakeIfAllParked = () => {
589
+ for (let lane = 0; lane < workers.length; lane++) {
590
+ const context = workers[lane];
591
+ if (context.laneAwake?.() !== false)
592
+ return;
593
+ }
594
+ workers[lastWoken]?.laneWake?.();
595
+ };
575
596
  // The shared dispatcher drives one queue, so it needs signal words of its
576
597
  // own rather than any single lane's; waking is delegated to `wakeOne`.
577
598
  const signalWords = new Int32Array(createSharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
@@ -585,6 +606,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
585
606
  channelHandler: channel,
586
607
  dispatcherOptions: host,
587
608
  notifySignal: wakeOne,
609
+ wakeAfterPass: wakeIfAllParked,
588
610
  crossProcess: resolvedWorker?.runtime === "process",
589
611
  nativeCompletionDoorbell: denoCompletionDoorbell !== undefined ||
590
612
  workers.some((context) => context
@@ -896,6 +918,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
896
918
  return {
897
919
  shutdown: shutdownWithDelay,
898
920
  [Symbol.dispose]: disposePool,
921
+ [Symbol.asyncDispose]: () => shutdownWithDelay(),
899
922
  call: Object.fromEntries(callEntries),
900
923
  // Only the shared submit queue has a single arena to build arguments in.
901
924
  sharedArgBytes: createHostArgAllocator(sharedArgsEnabled ? stealBuffers?.submitBuffers.payload : undefined),
@@ -910,6 +933,7 @@ const createSingleTaskPool = (single, options) => {
910
933
  call: pool.call[SINGLE_TASK_KEY],
911
934
  shutdown: pool.shutdown,
912
935
  [Symbol.dispose]: pool[Symbol.dispose],
936
+ [Symbol.asyncDispose]: pool[Symbol.asyncDispose],
913
937
  };
914
938
  };
915
939
  const buildTaskDefinitionFromCaller = (input, callerHref, at, imported = false) => {
@@ -34,6 +34,14 @@ export const formatNodeNativeAddonLoadError = (name, platformInfo, errors) => {
34
34
  ? "an unknown version"
35
35
  : `v${platformInfo.version}`;
36
36
  const target = `${platformInfo.platform}-${platformInfo.arch}`;
37
+ if (errors.some((error) => error.includes("ERR_DLOPEN_DISABLED"))) {
38
+ return (`knitting: Node.js ${version} blocked loading native addon ${name} ` +
39
+ `because --allow-addons is not enabled. For a thread worker pool, ` +
40
+ `set permission.node.allowAddons to true; if the host itself runs ` +
41
+ `with --permission, start it with --allow-addons too. This permits ` +
42
+ `task code to load Node native addons; only enable it for trusted ` +
43
+ `tasks.${attempts}`);
44
+ }
37
45
  if (abi === NODE_26_MODULE_ABI) {
38
46
  return (`knitting: Node.js 26 uses node:ffi instead of ABI-specific addons. ` +
39
47
  `Restart Node with the --experimental-ffi flag` +
@@ -85,7 +93,9 @@ export const loadNodeNativeAddon = (require, name, specifier) => {
85
93
  return require(candidate);
86
94
  }
87
95
  catch (error) {
88
- errors.push(`${candidate}: ${String(error)}`);
96
+ const code = error?.code;
97
+ errors.push(`${candidate}: ${typeof code === "string" ? `${code}: ` : ""}` +
98
+ String(error));
89
99
  }
90
100
  }
91
101
  throw new Error(formatNodeNativeAddonLoadError(name, readNodePlatformInfo(), errors));
package/src/debug/gate.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Read once at module load from `KNITTING_DEBUG`. This module is deliberately
5
5
  * tiny and dependency-light: importing it must never pull in the logger or the
6
6
  * environment-diff machinery. Callers branch on {@link DEBUG_ENABLED} and only
7
- * then `await import("./handle.ts")`, so when debug is off nothing else under
7
+ * then `await import("./handle.js")`, so when debug is off nothing else under
8
8
  * `src/debug` is ever loaded — literally zero cost, not merely cheap.
9
9
  *
10
10
  * `KNITTING_DEBUG` is a comma-separated list of namespaces:
@@ -3,6 +3,11 @@ export type DebugInit = {
3
3
  readonly name: string;
4
4
  readonly runtime: string;
5
5
  readonly namespaces: ReadonlySet<string>;
6
+ /**
7
+ * Host debug epoch as `timeOrigin + now()`. When absent (debug enabled only
8
+ * inside the worker), the clock starts when this handle initialises.
9
+ */
10
+ readonly epoch?: number;
6
11
  };
7
12
  export type Debug = {
8
13
  /**
@@ -20,4 +25,4 @@ export type Debug = {
20
25
  */
21
26
  envPhase: (label: string) => void;
22
27
  };
23
- export declare const initDebug: ({ name, runtime, namespaces }: DebugInit) => Debug;
28
+ export declare const initDebug: ({ name, runtime, namespaces, epoch }: DebugInit) => Debug;
@@ -4,16 +4,24 @@
4
4
  * baseline snapshot it takes exists when debug is off.
5
5
  *
6
6
  * Diagnostics go to stderr so they never corrupt a worker's stdout, and every
7
- * line is tagged with the worker id, runtime, and a clock relative to when this
8
- * worker's debug initialised. The clock is worker-local on purpose: a main-thread
9
- * timestamp can't be compared against `performance.now()` here because the time
10
- * origins differ across the thread/process boundary (it would read negative).
7
+ * line is tagged with the worker id, runtime, and a clock in milliseconds since
8
+ * the host's debug epoch, so host and worker lines interleave on one timeline.
9
+ *
10
+ * Raw `performance.now()` can't be compared across the thread/process boundary:
11
+ * bun and deno give each worker its own time origin, and a process worker's
12
+ * origin is its own process start. `performance.timeOrigin + performance.now()`
13
+ * is comparable (measured within a few µs on bun, node and deno, for both
14
+ * threads and processes), so the host sends that absolute value and each worker
15
+ * rebases onto it once. Staying in the local `now()` frame afterwards keeps
16
+ * full precision; the absolute sum alone only resolves ~0.24µs.
11
17
  */
12
18
  import { describeGlobalKey, diffGlobals, snapshotGlobals, } from "./env-diff.js";
13
- export const initDebug = ({ name, runtime, namespaces }) => {
19
+ export const initDebug = ({ name, runtime, namespaces, epoch }) => {
14
20
  const all = namespaces.has("*");
15
21
  const enabled = (namespace) => all || namespaces.has(namespace);
16
- const base = performance.now();
22
+ const base = epoch === undefined
23
+ ? performance.now()
24
+ : epoch - performance.timeOrigin;
17
25
  const tag = `${name}·${runtime}`;
18
26
  const log = (namespace, message) => {
19
27
  if (!enabled(namespace))
package/src/error.d.ts CHANGED
@@ -6,6 +6,15 @@ export declare const ErrorKnitting: {
6
6
  readonly Serializable: 3;
7
7
  };
8
8
  export type ErrorKnitting = typeof ErrorKnitting[keyof typeof ErrorKnitting];
9
+ export type KnittingErrorCode = "KNT_ERROR_0" | "KNT_ERROR_1" | "KNT_ERROR_2" | "KNT_ERROR_3" | "THREAD_CLOSED" | "WORKER_CRASHED" | "WORKER_EXITED" | "WORKER_STARTUP_FAILED";
10
+ /**
11
+ * Rejection raised by Knitting itself rather than by task code. Branch on
12
+ * `code`; `message` keeps the human-readable text.
13
+ */
14
+ export declare class KnittingError extends Error {
15
+ readonly code: KnittingErrorCode;
16
+ constructor(code: KnittingErrorCode, message: string, cause?: unknown);
17
+ }
9
18
  export declare const encoderError: ({ task, type, onPromise, detail, }: {
10
19
  task: Task;
11
20
  type: ErrorKnitting;
package/src/error.js CHANGED
@@ -8,6 +8,18 @@ export const ErrorKnitting = {
8
8
  Json: 2,
9
9
  Serializable: 3,
10
10
  };
11
+ /**
12
+ * Rejection raised by Knitting itself rather than by task code. Branch on
13
+ * `code`; `message` keeps the human-readable text.
14
+ */
15
+ export class KnittingError extends Error {
16
+ code;
17
+ constructor(code, message, cause) {
18
+ super(message, cause === undefined ? undefined : { cause });
19
+ this.name = "KnittingError";
20
+ this.code = code;
21
+ }
22
+ }
11
23
  const reasonFrom = (task, type, detail) => {
12
24
  switch (type) {
13
25
  case ErrorKnitting.Function: {
@@ -41,10 +53,12 @@ export const encoderError = ({ task, type, onPromise, detail, }) => {
41
53
  }
42
54
  if (!beginPromisePayload(task))
43
55
  return false;
56
+ // Built here, not in the microtask, so the stack still names the caller.
57
+ const error = new KnittingError(`KNT_ERROR_${type}`, reason);
44
58
  queueMicrotask(() => {
45
59
  finishPromisePayload(task);
46
- task.value = reason;
47
- onPromise(task, true, reason);
60
+ task.value = error;
61
+ onPromise(task, true, error);
48
62
  });
49
63
  return false;
50
64
  };
@@ -181,8 +181,26 @@ export declare const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32: number;
181
181
  export declare const STEAL_LIVE_SLOT_OFFSET_U32: number;
182
182
  /** Host-owned arm word for the return-lock completion doorbell. */
183
183
  export declare const DOORBELL_ARMED_SLOT_OFFSET_U32: number;
184
- /** Shared region-owner mask for the `cas-mask` claim discipline. */
185
- export declare const STEAL_CLAIM_MASK_SLOT_OFFSET_U32: number;
184
+ /**
185
+ * Non-wrapping 64-bit claim head for the `ticket` discipline. The producer
186
+ * publishes only the low 32 bits of the tail. A successful head CAS validates
187
+ * the snapshot: with that head unchanged, at most 32 tickets can be pending,
188
+ * so unsigned subtraction recovers the exact distance even across tail wrap.
189
+ * Head and tail occupy different slots' header lines (slots 0 and 1).
190
+ */
191
+ export declare const STEAL_TICKET_HEAD_SLOT_OFFSET_U32: number;
192
+ /** Negative head permanently closes a failed ticket queue. */
193
+ export declare const STEAL_TICKET_FAILED = -1n;
194
+ /**
195
+ * Publication-order ring. `order[t & 31]` is the slot the producer filled for
196
+ * ticket `t`, so a ticket names a lane without the ticket having to *be* the
197
+ * lane: the producer keeps its free-bit allocation and claims follow publication
198
+ * order. Cell `i` uses the former ACK word; words 14..15
199
+ * are reserved for the head in slot 0 and the tail in slot 1.
200
+ */
201
+ export declare const STEAL_TICKET_ORDER_SLOT_OFFSET_U32: number;
202
+ /** Ring cells, and therefore the wrap mask, follow the slot count. */
203
+ export declare const STEAL_TICKET_RING_MASK: number;
186
204
  export declare const HEADER_U32_LENGTH: number;
187
205
  export declare const HEADER_BYTE_LENGTH: number;
188
206
  export declare const makeTask: () => Task;
@@ -194,18 +212,37 @@ type ResolveHostOptions = {
194
212
  };
195
213
  export type Lock2 = ReturnType<typeof lock2>;
196
214
  /**
197
- * Region mutual-exclusion discipline for stealing consumers.
215
+ * Claim discipline for stealing consumers.
198
216
  *
199
217
  * - `dekker`: per-consumer intent words and an O(N) peer survey.
200
- * - `cas-mask`: one shared owner mask for all regions.
218
+ * - `ticket`: one monotonic counter; a CAS on the head claims lanes in
219
+ * publication order, with no regions and no per-consumer state.
201
220
  */
202
- export type StealClaimDiscipline = "dekker" | "cas-mask";
221
+ export declare const STEAL_CLAIM_DISCIPLINES: readonly ["dekker", "ticket"];
222
+ export type StealClaimDiscipline = typeof STEAL_CLAIM_DISCIPLINES[number];
223
+ /**
224
+ * Validate a claim discipline coming from an untrusted source — an env var, or
225
+ * a JS caller the type system never saw.
226
+ *
227
+ * This throws rather than falling back, because a silent fallback is worse than
228
+ * a crash here: a stale `cas-mask` setting or a typo would quietly run Dekker,
229
+ * and every benchmark and test built on it would report Dekker's numbers under
230
+ * another name. Both sides of a lock must also agree, so an unrecognised value
231
+ * that resolved differently on host and worker would corrupt the protocol.
232
+ */
233
+ export declare const assertStealClaim: (value: unknown, source: string) => StealClaimDiscipline;
234
+ /**
235
+ * The discipline chosen when nothing selects one. Host and worker default
236
+ * independently, so this must be a single shared constant: two sides landing on
237
+ * different disciplines would read the same buffer under different protocols.
238
+ */
239
+ export declare const DEFAULT_STEAL_CLAIM: StealClaimDiscipline;
203
240
  export type WaitAsyncState = "not-equal" | "ok" | "timed-out";
204
241
  export type WaitAsyncResult = {
205
242
  async: boolean;
206
243
  value: WaitAsyncState | PromiseLike<WaitAsyncState>;
207
244
  };
208
- export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, sharedReturn, moveReturn, consumers, consumerId, regionLanes, stealClaim, notifyOnHostPublish, notifyHostPublish, }: {
245
+ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, sharedReturn, moveReturn, consumers, consumerId, regionLanes, stealClaim, notifyOnHostPublish, notifyHostPublish, traceClaim, }: {
209
246
  headers?: SharedBufferSource;
210
247
  headerSlotStrideU32?: number;
211
248
  LockBoundSector?: SharedBufferSource;
@@ -244,6 +281,11 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
244
281
  notifyOnHostPublish?: boolean;
245
282
  /** Runtime-native host wake used when Atomics.waitAsync cannot wake it. */
246
283
  notifyHostPublish?: () => void;
284
+ /**
285
+ * Debug sink for steal claims. Only consulted when `decode` is chosen, so an
286
+ * untraced lock returns the bare claim function; see {@link traceStealClaims}.
287
+ */
288
+ traceClaim?: (message: string) => void;
247
289
  }) => {
248
290
  enlist: (task: Task) => true;
249
291
  encode: (task: Task, state?: number) => boolean;