knitting 0.1.63 → 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 (99) hide show
  1. package/README.md +623 -342
  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/map.md +0 -6
  7. package/package.json +10 -5
  8. package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
  9. package/prebuilds/darwin-arm64-node-127/knitting_doorbell.node +0 -0
  10. package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
  11. package/prebuilds/darwin-arm64-node-137/knitting_doorbell.node +0 -0
  12. package/prebuilds/darwin-x64-node-127/knitting_buffer_pointer.node +0 -0
  13. package/prebuilds/darwin-x64-node-127/knitting_doorbell.node +0 -0
  14. package/prebuilds/darwin-x64-node-137/knitting_buffer_pointer.node +0 -0
  15. package/prebuilds/darwin-x64-node-137/knitting_doorbell.node +0 -0
  16. package/prebuilds/linux-x64-node-127/knitting_buffer_pointer.node +0 -0
  17. package/prebuilds/linux-x64-node-127/knitting_doorbell.node +0 -0
  18. package/prebuilds/linux-x64-node-137/knitting_buffer_pointer.node +0 -0
  19. package/prebuilds/linux-x64-node-137/knitting_doorbell.node +0 -0
  20. package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
  21. package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
  22. package/prebuilds/win32-x64-node-127/knitting_doorbell.node +0 -0
  23. package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
  24. package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
  25. package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
  26. package/prebuilds/win32-x64-node-137/knitting_doorbell.node +0 -0
  27. package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
  28. package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
  29. package/scripts/build-native-addons.ts +5 -0
  30. package/shared-memory.d.ts +3 -0
  31. package/shared-memory.js +3 -0
  32. package/src/api.js +158 -71
  33. package/src/common/with-resolvers.js +2 -5
  34. package/src/common/worker-runtime.d.ts +7 -0
  35. package/src/common/worker-runtime.js +7 -0
  36. package/src/connections/buffer-reference.d.ts +10 -36
  37. package/src/connections/buffer-reference.js +15 -170
  38. package/src/connections/node-addons.d.ts +1 -1
  39. package/src/connections/node-addons.js +11 -1
  40. package/src/connections/shared-array-buffer-payload.d.ts +7 -0
  41. package/src/connections/shared-array-buffer-payload.js +27 -11
  42. package/src/debug/gate.js +1 -1
  43. package/src/debug/handle.d.ts +6 -1
  44. package/src/debug/handle.js +14 -6
  45. package/src/error.d.ts +9 -0
  46. package/src/error.js +16 -2
  47. package/src/knitting_buffer_pointer.cc +57 -2
  48. package/src/knitting_doorbell.cc +220 -0
  49. package/src/memory/knitting-body.d.ts +44 -0
  50. package/src/memory/knitting-body.js +51 -0
  51. package/src/memory/knitting-buffer-http.d.ts +116 -0
  52. package/src/memory/knitting-buffer-http.js +255 -0
  53. package/src/memory/knitting-buffer.d.ts +250 -0
  54. package/src/memory/knitting-buffer.js +695 -0
  55. package/src/memory/lazy-region-registry.d.ts +83 -0
  56. package/src/memory/lazy-region-registry.js +355 -0
  57. package/src/memory/lock.d.ts +80 -15
  58. package/src/memory/lock.js +473 -139
  59. package/src/memory/payloadCodec.d.ts +18 -2
  60. package/src/memory/payloadCodec.js +340 -76
  61. package/src/memory/regionRegistry.d.ts +6 -0
  62. package/src/memory/regionRegistry.js +125 -240
  63. package/src/memory/shared-buffer-io.d.ts +7 -0
  64. package/src/memory/shared-buffer-io.js +34 -8
  65. package/src/permission/protocol.d.ts +1 -0
  66. package/src/permission/protocol.js +8 -3
  67. package/src/runtime/deno-doorbell.d.ts +26 -0
  68. package/src/runtime/deno-doorbell.js +117 -0
  69. package/src/runtime/dispatcher.d.ts +13 -6
  70. package/src/runtime/dispatcher.js +101 -63
  71. package/src/runtime/host-arg-arena.d.ts +3 -0
  72. package/src/runtime/host-arg-arena.js +16 -0
  73. package/src/runtime/inline-executor.js +2 -1
  74. package/src/runtime/node-doorbell.d.ts +14 -0
  75. package/src/runtime/node-doorbell.js +84 -0
  76. package/src/runtime/pool.d.ts +30 -15
  77. package/src/runtime/pool.js +199 -151
  78. package/src/runtime/process-worker.d.ts +9 -0
  79. package/src/runtime/process-worker.js +32 -3
  80. package/src/runtime/tx-queue.d.ts +4 -6
  81. package/src/runtime/tx-queue.js +63 -48
  82. package/src/runtime/worker-common.d.ts +7 -0
  83. package/src/runtime/worker-common.js +28 -2
  84. package/src/types.d.ts +66 -78
  85. package/src/worker/loop.js +95 -60
  86. package/src/worker/rx-queue.d.ts +2 -3
  87. package/src/worker/rx-queue.js +34 -40
  88. package/src/worker/safety/index.d.ts +1 -1
  89. package/src/worker/safety/index.js +1 -1
  90. package/src/worker/safety/process.d.ts +2 -0
  91. package/src/worker/safety/process.js +8 -1
  92. package/src/worker/safety/startup.js +11 -6
  93. package/src/worker/shared-return.d.ts +9 -0
  94. package/src/worker/shared-return.js +22 -0
  95. package/src/worker/task-loader.js +1 -2
  96. package/src/worker/timers.d.ts +2 -6
  97. package/src/worker/timers.js +39 -22
  98. package/unsafe.d.ts +2 -1
  99. package/unsafe.js +2 -1
@@ -0,0 +1,117 @@
1
+ import { RUNTIME } from "../common/runtime.js";
2
+ const getDeno = () => globalThis.Deno;
3
+ /**
4
+ * Creates Deno's native thread-to-event-loop bridge without loading a custom
5
+ * library. `threadSafe()` is essential: ordinary UnsafeCallbacks may be
6
+ * called cross-thread, but do not wake an idle Deno event loop.
7
+ *
8
+ * The default pool configuration asks Deno for FFI permission once, so the
9
+ * native wake path is used whenever the caller approves it. `--allow-ffi`
10
+ * skips the prompt; `--deny-ffi`, `--no-prompt`, or a rejection preserves the
11
+ * portable polling dispatcher. Set `host.doorbell` to `false` to avoid asking.
12
+ */
13
+ export const createDenoCompletionDoorbell = () => {
14
+ if (RUNTIME !== "deno")
15
+ return undefined;
16
+ const deno = getDeno();
17
+ const queryPermission = deno?.permissions?.querySync;
18
+ const requestPermission = deno?.permissions?.requestSync;
19
+ const Callback = deno?.UnsafeCallback;
20
+ const pointerValue = deno?.UnsafePointer?.value;
21
+ if (typeof queryPermission !== "function" ||
22
+ typeof Callback?.threadSafe !== "function" ||
23
+ typeof pointerValue !== "function") {
24
+ return undefined;
25
+ }
26
+ // `querySync` avoids a redundant prompt for an already granted or explicitly
27
+ // denied capability. Only the normal `prompt` state asks, and a refusal
28
+ // leaves the portable polling path intact.
29
+ try {
30
+ let state = queryPermission({ name: "ffi" }).state;
31
+ if (state === "prompt" && typeof requestPermission === "function") {
32
+ state = requestPermission({ name: "ffi" }).state;
33
+ }
34
+ if (state !== "granted")
35
+ return undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ const listeners = new Map();
41
+ let closed = false;
42
+ let callback;
43
+ try {
44
+ callback = Callback.threadSafe({ parameters: ["i32"], result: "void" }, (lane) => {
45
+ if (closed)
46
+ return;
47
+ try {
48
+ listeners.get(lane)?.();
49
+ }
50
+ catch {
51
+ // FFI callbacks must never propagate an exception into the worker
52
+ // thread that rang them. The dispatcher watchdog remains a final
53
+ // liveness backstop if a host pump has already been closed.
54
+ }
55
+ });
56
+ const pointer = pointerValue(callback.pointer);
57
+ if (typeof pointer !== "bigint" || pointer === 0n) {
58
+ callback.close();
59
+ return undefined;
60
+ }
61
+ return {
62
+ pointer,
63
+ listen: (lane, notify) => {
64
+ if (!closed)
65
+ listeners.set(lane, notify);
66
+ },
67
+ unref: () => {
68
+ try {
69
+ callback.unref?.();
70
+ }
71
+ catch {
72
+ // best effort; a runtime may already be tearing down its callback
73
+ }
74
+ },
75
+ close: () => {
76
+ if (closed)
77
+ return;
78
+ closed = true;
79
+ listeners.clear();
80
+ callback.close();
81
+ },
82
+ };
83
+ }
84
+ catch {
85
+ // Creating a callback is native code. Treat an unsupported Deno build just
86
+ // like a denied FFI capability and keep the portable poll path.
87
+ try {
88
+ callback?.close();
89
+ }
90
+ catch {
91
+ // best effort; callback may not have been initialized
92
+ }
93
+ return undefined;
94
+ }
95
+ };
96
+ /**
97
+ * Reconstruct a host-owned thread-safe callback in a Deno worker. The pointer
98
+ * stays process-local and is never included in process-worker boot payloads.
99
+ */
100
+ export const createDenoCompletionNotifier = (pointer) => {
101
+ if (RUNTIME !== "deno" || pointer === undefined || pointer === 0n) {
102
+ return undefined;
103
+ }
104
+ const deno = getDeno();
105
+ const FnPointer = deno?.UnsafeFnPointer;
106
+ const pointerCreate = deno?.UnsafePointer?.create;
107
+ if (typeof FnPointer !== "function" || typeof pointerCreate !== "function") {
108
+ return undefined;
109
+ }
110
+ try {
111
+ const callback = new FnPointer(pointerCreate(pointer), { parameters: ["i32"], result: "void" });
112
+ return (lane) => callback.call(lane);
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ };
@@ -2,20 +2,30 @@ import { type MultiQueue } from "./tx-queue.js";
2
2
  import { type MainSignal } from "../ipc/transport/shared-memory.js";
3
3
  import { type RuntimeMessageChannelLike, type RuntimeMessagePortLike } from "../common/worker-runtime.js";
4
4
  import type { DispatcherSettings } from "../types.js";
5
- export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }: {
5
+ export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, armCompletionNotifier, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, wakeAfterPass, crossProcess, nativeCompletionDoorbell, processCompletionDoorbell, }: {
6
6
  queue: MultiQueue;
7
7
  signalBox: MainSignal;
8
8
  channelHandler: ChannelHandler;
9
9
  dispatcherOptions?: DispatcherSettings;
10
10
  notifySignal?: () => void;
11
- /** Workers live in other processes; disables the doorbell. */
11
+ /**
12
+ * Runs at the end of every drain pass. Defaults to waking this lane's worker
13
+ * if it is parked; a shared queue supplies its own policy.
14
+ */
15
+ wakeAfterPass?: () => void;
16
+ /** Workers live in other processes; disables only the atomic doorbell. */
12
17
  crossProcess?: boolean;
18
+ /** A runtime-native completion ring that wakes the host event loop. */
19
+ nativeCompletionDoorbell?: boolean;
20
+ /** A process IPC completion doorbell that wakes the host event loop. */
21
+ processCompletionDoorbell?: boolean;
13
22
  }) => {
14
23
  check: {
15
24
  (): void;
16
25
  isRunning: boolean;
17
26
  rerun: boolean;
18
27
  };
28
+ wakeCompletion: () => void;
19
29
  };
20
30
  type CheckWithState = (() => void) & {
21
31
  isRunning: boolean;
@@ -30,10 +40,7 @@ export declare class ChannelHandler {
30
40
  port2: RuntimeMessagePortLike | undefined;
31
41
  constructor(pump?: ChannelHandlerPump);
32
42
  notify(): void;
33
- /**
34
- * Registers the handler the pump calls back into. On the channel pump this is
35
- * also where the ports are opened, so `notify` can reach port 1.
36
- */
43
+ /** Register the pump handler and start channel ports when present. */
37
44
  open(f: () => void): void;
38
45
  /**
39
46
  * Detaches the handler, and closes the channel if this pump has one.
@@ -1,36 +1,15 @@
1
1
  import { createRuntimeMessageChannel, } from "../common/worker-runtime.js";
2
2
  import { RUNTIME, SET_IMMEDIATE } from "../common/runtime.js";
3
- /**
4
- * Macrotask primitive for the host pump, picked per runtime: a round trip costs
5
- * 757ns on Bun via MessageChannel but 4662ns on Deno, where `setImmediate` is
6
- * 1110ns. Bun keeps the channel; browser and Andromeda have no choice.
7
- *
8
- * `serial-channel` opts back into the channel explicitly (see `src/api.ts`): one
9
- * hop drives every lane's check in turn and relies on the channel's delivery to
10
- * do it, so a merely cheaper pump starves it -- it cost 25% throughput there.
11
- */
3
+ /** Runtime-specific macrotask primitive used by the host dispatcher. */
12
4
  const IMMEDIATE_PUMP = RUNTIME === "deno" || RUNTIME === "node"
13
5
  ? SET_IMMEDIATE
14
6
  : undefined;
15
- /**
16
- * Free drain hops before `scheduleNotify` stops re-arming the pump for free.
17
- *
18
- * The window has to match what the dispatcher escalates *to*, so these are
19
- * chosen together and `canUseDoorbell` picks between them. Polling escalates to
20
- * the `setTimeout` ladder, whose finest rung is ~1.1ms on every runtime, so it
21
- * wants a wide window; that sleep is also load-bearing, since it batches
22
- * completions. A doorbell escalates to `Atomics.waitAsync` at about the price of
23
- * one hop, so it wants a narrow one.
24
- *
25
- * Mixing them is the trap: a doorbell behind the wide window keeps every poll
26
- * hop *and* adds the arm, and loses to plain polling at every thread count.
27
- * Measurements behind both values: `docs/host-doorbell-proposal.md`.
28
- */
7
+ // Polling tolerates a wider free window; a doorbell should arm promptly.
29
8
  const POLL_STALL_FREE_LOOPS = 128;
30
9
  const DOORBELL_STALL_FREE_LOOPS = 1;
31
- export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }) => {
10
+ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, armCompletionNotifier, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, wakeAfterPass, crossProcess, nativeCompletionDoorbell, processCompletionDoorbell, }) => {
11
+ const a_add = Atomics.add;
32
12
  const a_load = Atomics.load;
33
- const a_store = Atomics.store;
34
13
  const a_notify = Atomics.notify;
35
14
  const canNotifySignal = opView.buffer instanceof SharedArrayBuffer;
36
15
  const wakeSignal = notifySignal ??
@@ -38,15 +17,25 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
38
17
  if (canNotifySignal)
39
18
  a_notify(opView, 0, 1);
40
19
  });
20
+ // Bump rather than store a constant: the worker parks on the value it last
21
+ // read, so only a changed word makes a ring that lands before its wait count.
22
+ const wakeLane = () => {
23
+ a_add(opView, 0, 1);
24
+ wakeSignal();
25
+ };
26
+ const wakeIfParked = wakeAfterPass ?? (() => {
27
+ if (a_load(rxStatus, 0) === 0)
28
+ wakeLane();
29
+ });
41
30
  const notify = () => channelHandler.notify();
42
- // `crossProcess` is a capability, not a preference, so it overrides an
43
- // explicit `doorbell: true`: V8's Atomics waiter list is per isolate, so a
44
- // worker in another process can never ring the host's waiter, and an armed
45
- // doorbell would just sleep to the watchdog.
46
- const canUseDoorbell = crossProcess !== true &&
47
- (dispatcherOptions?.doorbell ?? true) &&
48
- (RUNTIME === "bun" || RUNTIME === "node") &&
31
+ // Atomics waiters are process-local, so cross-process workers cannot use this
32
+ // doorbell.
33
+ const canUseAtomicDoorbell = (RUNTIME === "bun" || RUNTIME === "node") &&
49
34
  typeof Atomics.waitAsync === "function";
35
+ const canUseDoorbell = (dispatcherOptions?.doorbell ?? true) &&
36
+ (processCompletionDoorbell === true ||
37
+ (crossProcess !== true &&
38
+ (canUseAtomicDoorbell || nativeCompletionDoorbell === true)));
50
39
  let doorbellEnabled = canUseDoorbell;
51
40
  let doorbellArmed = false;
52
41
  let doorbellEpoch = 0 | 0;
@@ -80,24 +69,55 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
80
69
  doorbellEpoch = token;
81
70
  doorbellArmed = true;
82
71
  let woke = false;
83
- const wake = () => {
72
+ const wake = (direct = false) => {
84
73
  if (!doorbellArmed || doorbellEpoch !== token || woke)
85
74
  return;
86
75
  woke = true;
87
76
  doorbellArmed = false;
88
77
  doorbellEpoch = (doorbellEpoch + 1) | 0;
89
78
  setCompletionWaiterArmed(false);
79
+ if (direct) {
80
+ // A native arm that observes an already-published completion has no
81
+ // callback to wait for. Drain it now: routing this known result back
82
+ // through setImmediate recreates the very millisecond-sized hop that
83
+ // the native doorbell exists to remove.
84
+ check.isRunning = true;
85
+ check();
86
+ return;
87
+ }
90
88
  // Keep the existing macrotask boundary. Calling check directly from a
91
89
  // resolved waitAsync promise can chain microtasks under a hot workload
92
90
  // and starve host I/O.
93
91
  notify();
94
92
  };
95
93
  let supported = false;
96
- try {
97
- supported = waitForCompletion(wake, DOORBELL_WATCHDOG_MS);
94
+ if (nativeCompletionDoorbell === true ||
95
+ processCompletionDoorbell === true) {
96
+ // A native arm reports two different things and they must not be
97
+ // conflated: `false` means a completion was already published so the arm
98
+ // deliberately did not ring, which is a normal hot-path outcome. Only a
99
+ // throw means the transport is unusable. Treating the race as
100
+ // "unsupported" disabled the doorbell permanently on its second arm.
101
+ let armed = false;
102
+ let usable = true;
103
+ try {
104
+ armed = armCompletionNotifier();
105
+ }
106
+ catch {
107
+ usable = false;
108
+ }
109
+ supported = usable;
110
+ // The arm itself drains the completion it just observed.
111
+ if (usable && !armed)
112
+ wake(true);
98
113
  }
99
- catch {
100
- supported = false;
114
+ else {
115
+ try {
116
+ supported = waitForCompletion(wake, DOORBELL_WATCHDOG_MS);
117
+ }
118
+ catch {
119
+ supported = false;
120
+ }
101
121
  }
102
122
  // Some runtimes reject waitAsync on their SharedArrayBuffer variants; fall
103
123
  // back to polling rather than hanging the pool.
@@ -111,7 +131,9 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
111
131
  doorbellArmed = false;
112
132
  doorbellEpoch = (doorbellEpoch + 1) | 0;
113
133
  setCompletionWaiterArmed(false);
114
- notify();
134
+ if (nativeCompletionDoorbell !== true &&
135
+ processCompletionDoorbell !== true)
136
+ notify();
115
137
  }
116
138
  };
117
139
  const check = () => {
@@ -124,9 +146,7 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
124
146
  // A waitAsync cannot be cancelled, so bumping its epoch makes the eventual
125
147
  // callback inert instead.
126
148
  cancelDoorbell();
127
- // Idle lane: skip the drain. Otherwise it pays a txStatus write, an rxStatus
128
- // load, and an Atomics.notify that wakes a parked worker for nothing — the
129
- // waste that makes serial-channel latency grow with thread count.
149
+ // Avoid waking an idle lane.
130
150
  if (txIdle()) {
131
151
  check.isRunning = false;
132
152
  return;
@@ -139,15 +159,12 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
139
159
  do {
140
160
  check.rerun = false;
141
161
  txStatus[0] = 1;
142
- // Wake the worker before draining so it can start processing while we flush.
143
- if (a_load(rxStatus, 0) === 0) {
144
- a_store(opView, 0, 1);
145
- wakeSignal();
146
- }
147
- // Local vars so V8 keeps them as unboxed int32. Only a reaped completion
148
- // counts as progress for `stallCount`: the pump exists to notice work a
149
- // worker cannot announce, and letting a flush reset the counter would
150
- // leave the escalation unreachable for a pool that never runs dry.
162
+ // Wake a parked worker whenever its receive status is clear, so it is
163
+ // up by the time the frames land. Pending frames are not a reliable gate
164
+ // because the queue may have room.
165
+ if (a_load(rxStatus, 0) === 0)
166
+ wakeLane();
167
+ // Only completed frames count as progress for backoff purposes.
151
168
  let completed = false;
152
169
  let progressed = true;
153
170
  while (progressed) {
@@ -162,9 +179,18 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
162
179
  progressed = true;
163
180
  }
164
181
  }
182
+ // The ring above was decided before the frames flushed here existed, and
183
+ // calls publish straight from enqueue without passing through this loop.
184
+ // A worker that parked in between saw neither the frames nor a ring and
185
+ // slept out its whole parkMs. Publishing ends in a seq-cst store and the
186
+ // worker re-checks after clearing rxStatus, so re-reading rxStatus after
187
+ // the pass means either the worker sees the frames or this sees it parked.
188
+ wakeIfParked();
165
189
  txStatus[0] = 0;
166
190
  if (!txIdle()) {
167
- if (completed || hasPendingFrames()) {
191
+ // Queued frames indicate back-pressure, not progress; only a completion
192
+ // frees a request slot.
193
+ if (completed) {
168
194
  stallCount = 0 | 0;
169
195
  }
170
196
  else {
@@ -182,6 +208,15 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
182
208
  };
183
209
  check.isRunning = false;
184
210
  check.rerun = false;
211
+ /** Enter the drain from a native or IPC completion event. */
212
+ const wakeCompletion = () => {
213
+ if (inFlight || check.isRunning) {
214
+ check.rerun = true;
215
+ return;
216
+ }
217
+ check.isRunning = true;
218
+ check();
219
+ };
185
220
  const scheduleNotify = () => {
186
221
  if (stallCount <= stallFreeLoops) {
187
222
  notify();
@@ -213,7 +248,7 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
213
248
  }
214
249
  }, delay);
215
250
  };
216
- return { check };
251
+ return { check, wakeCompletion };
217
252
  };
218
253
  export class ChannelHandler {
219
254
  // Set only when the pump is a MessageChannel. The `setImmediate` pump has no
@@ -223,14 +258,16 @@ export class ChannelHandler {
223
258
  port2;
224
259
  #handler;
225
260
  #notify;
261
+ #pending = false;
262
+ #run = () => {
263
+ // Clear before draining so a busy lane can request the next turn.
264
+ this.#pending = false;
265
+ this.#handler?.();
266
+ };
226
267
  constructor(pump = "auto") {
227
268
  if (pump === "auto" && IMMEDIATE_PUMP !== undefined) {
228
269
  const immediate = IMMEDIATE_PUMP;
229
- // Allocated once; the pump fires hundreds of thousands of times a second.
230
- // Routing via `#handler` also makes a callback outliving `close()` inert.
231
- const run = () => {
232
- this.#handler?.();
233
- };
270
+ const run = this.#run;
234
271
  this.#notify = () => {
235
272
  immediate(run);
236
273
  };
@@ -246,23 +283,24 @@ export class ChannelHandler {
246
283
  };
247
284
  }
248
285
  notify() {
286
+ // Coalesce notifications into one pending drain.
287
+ if (this.#pending)
288
+ return;
289
+ this.#pending = true;
249
290
  this.#notify();
250
291
  }
251
- /**
252
- * Registers the handler the pump calls back into. On the channel pump this is
253
- * also where the ports are opened, so `notify` can reach port 1.
254
- */
292
+ /** Register the pump handler and start channel ports when present. */
255
293
  open(f) {
256
294
  this.#handler = f;
257
295
  const port1 = this.port1;
258
296
  if (port1 === undefined)
259
297
  return;
260
298
  if (typeof port1.on === "function") {
261
- port1.on("message", f);
299
+ port1.on("message", this.#run);
262
300
  }
263
301
  else {
264
302
  // @ts-ignore
265
- port1.onmessage = f;
303
+ port1.onmessage = this.#run;
266
304
  }
267
305
  this.port1?.start?.();
268
306
  this.port2?.start?.();
@@ -0,0 +1,3 @@
1
+ export type HostArgAllocator = (byteLength: number) => Uint8Array;
2
+ /** Create an argument allocator with a plain-allocation fallback. */
3
+ export declare const createHostArgAllocator: (payload: object | undefined) => HostArgAllocator;
@@ -0,0 +1,16 @@
1
+ import { getSharedReturnAllocator } from "../memory/payloadCodec.js";
2
+ /** Create an argument allocator with a plain-allocation fallback. */
3
+ export const createHostArgAllocator = (payload) => {
4
+ const allocate = payload === undefined
5
+ ? undefined
6
+ : getSharedReturnAllocator(payload);
7
+ if (allocate === undefined) {
8
+ return (byteLength) => new Uint8Array(byteLength);
9
+ }
10
+ return (byteLength) => {
11
+ if (!Number.isInteger(byteLength) || byteLength < 0) {
12
+ throw new RangeError("sharedArgBytes(byteLength) requires a non-negative integer");
13
+ }
14
+ return allocate(byteLength, false) ?? new Uint8Array(byteLength);
15
+ };
16
+ };
@@ -1,4 +1,5 @@
1
1
  import { withResolvers } from "../common/with-resolvers.js";
2
+ import { KnittingError } from "../error.js";
2
3
  import RingQueue from "../ipc/tools/ring-queue.js";
3
4
  import { createRuntimeMessageChannel } from "../common/worker-runtime.js";
4
5
  // const objects, not `enum`s: Andromeda's Nova engine can't parse `enum`. Same
@@ -270,7 +271,7 @@ export const createInlineExecutor = ({ tasks, genTaskID, batchSize, }) => {
270
271
  if (stateByIndex[index] !== SlotStateMacro.Pending)
271
272
  continue;
272
273
  try {
273
- deferredByIndex[index]?.reject("Thread closed");
274
+ deferredByIndex[index]?.reject(new KnittingError("THREAD_CLOSED", "Thread closed"));
274
275
  }
275
276
  catch {
276
277
  }
@@ -0,0 +1,14 @@
1
+ export type NodeCompletionDoorbell = {
2
+ /** Process-local handle a Node thread worker may ring through the addon. */
3
+ pointer: bigint;
4
+ /** Invalidates the native handle after every worker that holds it has exited. */
5
+ close: () => void;
6
+ };
7
+ /**
8
+ * Bridge a Node worker thread into the host's libuv I/O phase. The addon owns
9
+ * the `uv_async_t`; this wrapper owns the JS callback and makes missing native
10
+ * prebuilds a normal capability fallback.
11
+ */
12
+ export declare const createNodeCompletionDoorbell: (notify: () => void) => NodeCompletionDoorbell | undefined;
13
+ /** Rebuild the process-local native ring in a Node thread worker. */
14
+ export declare const createNodeCompletionNotifier: (pointer: bigint | undefined) => (() => void) | undefined;
@@ -0,0 +1,84 @@
1
+ import { RUNTIME } from "../common/runtime.js";
2
+ import { getNodeBuiltinModule } from "../common/node-compat.js";
3
+ import { loadNodeNativeAddon } from "../connections/node-addons.js";
4
+ const loadDoorbellAddon = () => {
5
+ if (RUNTIME !== "node")
6
+ return undefined;
7
+ const nodeModule = getNodeBuiltinModule("node:module");
8
+ if (nodeModule === undefined)
9
+ return undefined;
10
+ try {
11
+ return loadNodeNativeAddon(nodeModule.createRequire(import.meta.url), "knitting_doorbell");
12
+ }
13
+ catch {
14
+ // Native wakeups are an optional fast path. A missing prebuild must retain
15
+ // the portable Atomics.waitAsync dispatcher rather than preventing plain
16
+ // thread workers from starting.
17
+ return undefined;
18
+ }
19
+ };
20
+ /**
21
+ * Bridge a Node worker thread into the host's libuv I/O phase. The addon owns
22
+ * the `uv_async_t`; this wrapper owns the JS callback and makes missing native
23
+ * prebuilds a normal capability fallback.
24
+ */
25
+ export const createNodeCompletionDoorbell = (notify) => {
26
+ const addon = loadDoorbellAddon();
27
+ if (addon === undefined)
28
+ return undefined;
29
+ let pointer;
30
+ let closed = false;
31
+ try {
32
+ pointer = addon.createCompletionDoorbell(() => {
33
+ if (closed)
34
+ return;
35
+ try {
36
+ notify();
37
+ }
38
+ catch {
39
+ // An exception must not escape a libuv callback. A scheduled host
40
+ // drain or the dispatcher watchdog still provides a liveness backstop.
41
+ }
42
+ });
43
+ if (typeof pointer !== "bigint" || pointer === 0n)
44
+ return undefined;
45
+ // A dormant pool must not be kept alive solely by an idle uv_async_t.
46
+ addon.unrefCompletionDoorbell(pointer);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ return {
52
+ pointer,
53
+ close: () => {
54
+ if (closed)
55
+ return;
56
+ closed = true;
57
+ try {
58
+ addon.closeCompletionDoorbell(pointer);
59
+ }
60
+ catch {
61
+ // Shutdown can race Node environment teardown; the addon's environment
62
+ // cleanup hook owns the remaining lifetime in that case.
63
+ }
64
+ },
65
+ };
66
+ };
67
+ /** Rebuild the process-local native ring in a Node thread worker. */
68
+ export const createNodeCompletionNotifier = (pointer) => {
69
+ if (RUNTIME !== "node" || pointer === undefined || pointer === 0n) {
70
+ return undefined;
71
+ }
72
+ const addon = loadDoorbellAddon();
73
+ if (addon === undefined)
74
+ return undefined;
75
+ return () => {
76
+ try {
77
+ addon.ringCompletionDoorbell(pointer);
78
+ }
79
+ catch {
80
+ // The host can close while a forced worker teardown is in flight. Its
81
+ // return-lock watchdog covers that final in-flight publication.
82
+ }
83
+ };
84
+ };