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.
- package/README.md +623 -342
- package/knitting.browser.d.ts +3 -1
- package/knitting.browser.js +1 -1
- package/knitting.d.ts +3 -1
- package/knitting.js +2 -1
- package/map.md +0 -6
- package/package.json +10 -5
- package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/linux-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/linux-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/linux-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/linux-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
- package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
- package/scripts/build-native-addons.ts +5 -0
- package/shared-memory.d.ts +3 -0
- package/shared-memory.js +3 -0
- package/src/api.js +158 -71
- package/src/common/with-resolvers.js +2 -5
- package/src/common/worker-runtime.d.ts +7 -0
- package/src/common/worker-runtime.js +7 -0
- package/src/connections/buffer-reference.d.ts +10 -36
- package/src/connections/buffer-reference.js +15 -170
- package/src/connections/node-addons.d.ts +1 -1
- package/src/connections/node-addons.js +11 -1
- package/src/connections/shared-array-buffer-payload.d.ts +7 -0
- package/src/connections/shared-array-buffer-payload.js +27 -11
- package/src/debug/gate.js +1 -1
- package/src/debug/handle.d.ts +6 -1
- package/src/debug/handle.js +14 -6
- package/src/error.d.ts +9 -0
- package/src/error.js +16 -2
- package/src/knitting_buffer_pointer.cc +57 -2
- package/src/knitting_doorbell.cc +220 -0
- package/src/memory/knitting-body.d.ts +44 -0
- package/src/memory/knitting-body.js +51 -0
- package/src/memory/knitting-buffer-http.d.ts +116 -0
- package/src/memory/knitting-buffer-http.js +255 -0
- package/src/memory/knitting-buffer.d.ts +250 -0
- package/src/memory/knitting-buffer.js +695 -0
- package/src/memory/lazy-region-registry.d.ts +83 -0
- package/src/memory/lazy-region-registry.js +355 -0
- package/src/memory/lock.d.ts +80 -15
- package/src/memory/lock.js +473 -139
- package/src/memory/payloadCodec.d.ts +18 -2
- package/src/memory/payloadCodec.js +340 -76
- package/src/memory/regionRegistry.d.ts +6 -0
- package/src/memory/regionRegistry.js +125 -240
- package/src/memory/shared-buffer-io.d.ts +7 -0
- package/src/memory/shared-buffer-io.js +34 -8
- package/src/permission/protocol.d.ts +1 -0
- package/src/permission/protocol.js +8 -3
- package/src/runtime/deno-doorbell.d.ts +26 -0
- package/src/runtime/deno-doorbell.js +117 -0
- package/src/runtime/dispatcher.d.ts +13 -6
- package/src/runtime/dispatcher.js +101 -63
- package/src/runtime/host-arg-arena.d.ts +3 -0
- package/src/runtime/host-arg-arena.js +16 -0
- package/src/runtime/inline-executor.js +2 -1
- package/src/runtime/node-doorbell.d.ts +14 -0
- package/src/runtime/node-doorbell.js +84 -0
- package/src/runtime/pool.d.ts +30 -15
- package/src/runtime/pool.js +199 -151
- package/src/runtime/process-worker.d.ts +9 -0
- package/src/runtime/process-worker.js +32 -3
- package/src/runtime/tx-queue.d.ts +4 -6
- package/src/runtime/tx-queue.js +63 -48
- package/src/runtime/worker-common.d.ts +7 -0
- package/src/runtime/worker-common.js +28 -2
- package/src/types.d.ts +66 -78
- package/src/worker/loop.js +95 -60
- package/src/worker/rx-queue.d.ts +2 -3
- package/src/worker/rx-queue.js +34 -40
- package/src/worker/safety/index.d.ts +1 -1
- package/src/worker/safety/index.js +1 -1
- package/src/worker/safety/process.d.ts +2 -0
- package/src/worker/safety/process.js +8 -1
- package/src/worker/safety/startup.js +11 -6
- package/src/worker/shared-return.d.ts +9 -0
- package/src/worker/shared-return.js +22 -0
- package/src/worker/task-loader.js +1 -2
- package/src/worker/timers.d.ts +2 -6
- package/src/worker/timers.js +39 -22
- package/unsafe.d.ts +2 -1
- package/unsafe.js +2 -1
package/README.md
CHANGED
|
@@ -66,6 +66,9 @@ cross-runtime shared memory.
|
|
|
66
66
|
- Deno 2+
|
|
67
67
|
- Bun 1+
|
|
68
68
|
|
|
69
|
+
See [Platform and native support](#platform-and-native-support) for the prebuild
|
|
70
|
+
matrix and the flags native features need.
|
|
71
|
+
|
|
69
72
|
## Install
|
|
70
73
|
|
|
71
74
|
From npm:
|
|
@@ -101,6 +104,9 @@ if (isMain) {
|
|
|
101
104
|
}
|
|
102
105
|
```
|
|
103
106
|
|
|
107
|
+
The examples use `using`, which Node.js 22 cannot parse: there, write
|
|
108
|
+
`const pool = ...` and call `await pool.shutdown()` when you are done.
|
|
109
|
+
|
|
104
110
|
Use the `isMain` guard when a module can be loaded by both the host and its
|
|
105
111
|
workers. Export tasks at module scope so Knitting can find them, then create and
|
|
106
112
|
use the pool only from the main program.
|
|
@@ -149,10 +155,16 @@ if (isMain) {
|
|
|
149
155
|
}
|
|
150
156
|
```
|
|
151
157
|
|
|
152
|
-
`using` starts pool shutdown when the scope exits and does not wait for it.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
`using` starts pool shutdown when the scope exits and does not wait for it. Use
|
|
159
|
+
`await using pool = ...` or `await pool.shutdown()` when you need to wait for
|
|
160
|
+
shutdown; only `shutdown()` takes a shutdown delay.
|
|
161
|
+
|
|
162
|
+
Deno 2+, Bun 1+, and Node.js 24+ parse `using` natively. Node.js 22 does not: it
|
|
163
|
+
has `Symbol.dispose`, but the declaration itself is a `SyntaxError`, and Node's
|
|
164
|
+
own type stripping (`--experimental-strip-types` /
|
|
165
|
+
`--experimental-transform-types`) does not downlevel it, so a `.ts` file with
|
|
166
|
+
`using` fails there too. For Node 22, compile the file with TypeScript 5.2+
|
|
167
|
+
(`target: "es2022"`), or call `await pool.shutdown()` instead.
|
|
156
168
|
|
|
157
169
|
For simple tasks that do not need timeout or abort metadata, exported functions
|
|
158
170
|
can be used directly:
|
|
@@ -314,6 +326,125 @@ if (isMain) {
|
|
|
314
326
|
}
|
|
315
327
|
```
|
|
316
328
|
|
|
329
|
+
## Payloads
|
|
330
|
+
|
|
331
|
+
Worker calls can carry the following values across the shared-memory transport:
|
|
332
|
+
|
|
333
|
+
- `string`, `number`, `boolean`, `bigint`, `null`, and `undefined`.
|
|
334
|
+
- Plain objects and arrays made from supported values.
|
|
335
|
+
- `ArrayBuffer`, Node `Buffer`, `DataView`, and supported typed arrays.
|
|
336
|
+
- `ProcessSharedBuffer`.
|
|
337
|
+
- `BufferReference` from `knitting/unsafe` for experimental zero-copy buffers to
|
|
338
|
+
thread workers (same process only; see below).
|
|
339
|
+
- `Envelope` for a JSON header plus a binary body (`ArrayBuffer`,
|
|
340
|
+
`SharedArrayBuffer`, `ProcessSharedBuffer`, or `BufferReference`).
|
|
341
|
+
- `Error`, `Date`, and global symbols created with `Symbol.for(...)`.
|
|
342
|
+
- Native `Promise<supported-value>` inputs. The promise is awaited before
|
|
343
|
+
dispatch.
|
|
344
|
+
- Thenables are not awaited by the transport.
|
|
345
|
+
|
|
346
|
+
If it isn't on that list, assume it isn't portable. Some things don't (or
|
|
347
|
+
shouldn't) cross the boundary:
|
|
348
|
+
|
|
349
|
+
- DOM objects and platform handles.
|
|
350
|
+
- Functions, unless they are exported pool tasks or part of a `task` or
|
|
351
|
+
`importTask` definition.
|
|
352
|
+
- Cyclic object graphs.
|
|
353
|
+
- `Map`, `Set`, `WeakMap`, and non-global symbols.
|
|
354
|
+
- Objects with behavior that depends on prototypes, getters, setters, or hidden
|
|
355
|
+
process-local state.
|
|
356
|
+
|
|
357
|
+
### Envelope
|
|
358
|
+
|
|
359
|
+
`Envelope` pairs a JSON-serializable header with a binary body. Use it when a
|
|
360
|
+
call needs both structured metadata and raw bytes in a single argument — the
|
|
361
|
+
transport carries one special binary value per call, so an envelope is the way
|
|
362
|
+
to attach a header to one.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { createPool, Envelope, isMain, task } from "knitting";
|
|
366
|
+
|
|
367
|
+
export const processImage = task<
|
|
368
|
+
Envelope<{ format: string }>,
|
|
369
|
+
Envelope<{ width: number; height: number }>
|
|
370
|
+
>({
|
|
371
|
+
f: (envelope) => {
|
|
372
|
+
const pixels = new Uint8Array(envelope.payload);
|
|
373
|
+
// ... process pixels
|
|
374
|
+
return new Envelope({ width: 800, height: 600 }, pixels.buffer);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (isMain) {
|
|
379
|
+
const pool = createPool({ threads: 2 })({ processImage });
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const buffer = new ArrayBuffer(1024);
|
|
383
|
+
const result = await pool.call.processImage(
|
|
384
|
+
new Envelope({ format: "png" }, buffer),
|
|
385
|
+
);
|
|
386
|
+
console.log(result.header); // { width: 800, height: 600 }
|
|
387
|
+
} finally {
|
|
388
|
+
await pool.shutdown();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
#### Body types
|
|
394
|
+
|
|
395
|
+
The body is generic — `Envelope<Header, Body>` — and accepts any of the binary
|
|
396
|
+
shapes the transport understands:
|
|
397
|
+
|
|
398
|
+
| Body | Copy? | Workers | Notes |
|
|
399
|
+
| --------------------- | ----------------- | ---------------- | ------------------------------------------------------------------- |
|
|
400
|
+
| `ArrayBuffer` | copied | thread + process | The default body; works everywhere. |
|
|
401
|
+
| `SharedArrayBuffer` | zero-copy, shared | thread only | Shared by reference; process workers reject it. |
|
|
402
|
+
| `ProcessSharedBuffer` | zero-copy, shared | thread + process | Cross-process shared memory. |
|
|
403
|
+
| `BufferReference` | zero-copy, moved | thread only | From `knitting/unsafe`; same constraints as bare `BufferReference`. |
|
|
404
|
+
|
|
405
|
+
The header keeps its fast paths regardless of the body: a small header is
|
|
406
|
+
written inline, and only large headers spill to the dynamic payload region. A
|
|
407
|
+
zero-copy body keeps its own semantics — a `SharedArrayBuffer` stays shared by
|
|
408
|
+
reference, and a `BufferReference` body is still moved (its source is detached)
|
|
409
|
+
and joins the same borrow/copy/release flow it follows on its own.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
import { createPool, Envelope, isMain, task } from "knitting";
|
|
413
|
+
import { BufferReference } from "knitting/unsafe";
|
|
414
|
+
|
|
415
|
+
export const invert = task<
|
|
416
|
+
Envelope<{ op: string }, BufferReference>,
|
|
417
|
+
Envelope<{ op: string }, BufferReference>
|
|
418
|
+
>({
|
|
419
|
+
f: (envelope) => {
|
|
420
|
+
const pixels = envelope.payload.toUint8Array();
|
|
421
|
+
const out = new Uint8Array(pixels.length);
|
|
422
|
+
for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i];
|
|
423
|
+
return new Envelope({ op: "inverted" }, new BufferReference(out));
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (isMain) {
|
|
428
|
+
using pool = createPool({ threads: 1 })({ invert });
|
|
429
|
+
const pixels = new Uint8Array([0, 64, 128, 192, 255]);
|
|
430
|
+
|
|
431
|
+
using result = await pool.call.invert(
|
|
432
|
+
new Envelope({ op: "invert" }, new BufferReference(pixels)),
|
|
433
|
+
);
|
|
434
|
+
console.log(result.header, [...result.payload.toUint8Array()]);
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`Envelope` is disposable: disposing it (via `using` or `Symbol.dispose`)
|
|
439
|
+
disposes a disposable body such as a `BufferReference`, and is a harmless no-op
|
|
440
|
+
for `ArrayBuffer` / `SharedArrayBuffer` bodies. See
|
|
441
|
+
[Large binary values in thread workers](#large-binary-values-in-thread-workers)
|
|
442
|
+
for the full `BufferReference` constraints, which apply unchanged to a
|
|
443
|
+
`BufferReference` body.
|
|
444
|
+
|
|
445
|
+
If a payload is large, set `payload.maxPayloadBytes` deliberately and prefer
|
|
446
|
+
binary/shared-memory shapes over deeply nested objects.
|
|
447
|
+
|
|
317
448
|
## Creating Pools
|
|
318
449
|
|
|
319
450
|
You typically create one pool per set of tasks and reuse it.
|
|
@@ -342,29 +473,17 @@ Common options you might tweak:
|
|
|
342
473
|
| `worker.hardTimeoutMs` | Force pool shutdown when a task exceeds this many milliseconds. |
|
|
343
474
|
| `worker.runtime` | Choose `"thread"`, `"process"`, or experimental `"compiled"` workers. |
|
|
344
475
|
| `worker.processRuntime` | Choose `"node"`, `"deno"`, or `"bun"`; standalone `"porffor"` selects compilation and rebuilds once per pool. |
|
|
345
|
-
| `worker.processSharedMemory` | Process-worker memory discovery: `"inherit"` by default on POSIX, or `"named"` for wrappers/containers
|
|
476
|
+
| `worker.processSharedMemory` | Process-worker memory discovery: `"inherit"` by default on Node/Bun POSIX hosts, or `"named"` for wrappers/containers. Deno and Windows hosts use named mappings automatically. |
|
|
346
477
|
| `permission` | Runtime permission policy for workers. |
|
|
347
478
|
| `host.dispatcher` | Experimental host dispatcher topology: `"per-thread"` or `"serial-channel"`. |
|
|
348
479
|
| `host.steal` | Shared-submit work stealing for compatible multi-worker thread/process pools; enabled by default. Set `false` to use private submit lanes. |
|
|
349
|
-
| `
|
|
480
|
+
| `host.stealRegionLanes` | Submit lanes claimed per stealing handshake (a power of two). Smaller regions are fairer for expensive tasks; wider regions amortise arbitration for cheap ones. |
|
|
481
|
+
| `host.stealClaim` | Claim discipline: `"ticket"` (default) or `"dekker"`. Also settable with `KNITTING_STEAL_CLAIM`; an unrecognised value is rejected. |
|
|
482
|
+
| `host.doorbell` | Wait for completion notifications instead of polling an empty return mailbox; enabled by default where supported. Set `false` to force polling. |
|
|
483
|
+
| `host.nativeDoorbell` | Opt into Node's native `uv_async_t` completion bridge for thread workers. Off by default; ignored when `host.doorbell` is `false`. |
|
|
484
|
+
| `debug` | Enable diagnostics (`host`, `globals`, `signals`, `imports`, `lifecycle`, `steal`) or use `KNITTING_DEBUG`. |
|
|
350
485
|
| `source` | Worker source override for advanced runtimes. |
|
|
351
486
|
|
|
352
|
-
Most users can leave `host.dispatcher` alone. It selects the dispatcher for
|
|
353
|
-
private-lane pools: Bun and single-worker pools default to `"per-thread"`, while
|
|
354
|
-
multi-worker Node/Deno pools use `"serial-channel"`. Selecting a dispatcher or
|
|
355
|
-
balancer explicitly preserves that private-lane topology unless
|
|
356
|
-
`host.steal: true` is also explicit.
|
|
357
|
-
|
|
358
|
-
Ordinary multi-worker thread and process pools use shared-submit work stealing
|
|
359
|
-
by default. It is not used by one-worker pools, the inliner, compiled/Porffor
|
|
360
|
-
workers, or pools with an explicit balancer/dispatcher, so those modes retain
|
|
361
|
-
their existing transport. Process workers use one process-shared submit region
|
|
362
|
-
and one private return region per process. Pools above the current 31-claimant
|
|
363
|
-
protocol limit also fall back. Set `host: { steal: false }` or
|
|
364
|
-
`KNITTING_STEAL=0` to opt out for uniformly cheap, low-concurrency workloads
|
|
365
|
-
where arbitration has nothing to rebalance. `host: { steal: true }` or
|
|
366
|
-
`KNITTING_STEAL=1` forces it for an otherwise compatible pool.
|
|
367
|
-
|
|
368
487
|
### Worker bootstrap
|
|
369
488
|
|
|
370
489
|
Use `worker.bootstrap` when a worker needs privileged setup before task modules
|
|
@@ -388,6 +507,104 @@ good place to remove environment variables, install runtime guards, open shared
|
|
|
388
507
|
memory metadata, or prepare globals that task modules should see at import time.
|
|
389
508
|
Bootstrap is worker-only and cannot be combined with the inline host lane.
|
|
390
509
|
|
|
510
|
+
## Scheduling and Tuning
|
|
511
|
+
|
|
512
|
+
### Choosing a balancer
|
|
513
|
+
|
|
514
|
+
Choose a balancer based on the shape of your work:
|
|
515
|
+
|
|
516
|
+
- `"roundRobin"` is simple and works well for similarly sized tasks.
|
|
517
|
+
- `"firstIdle"` helps when task durations vary.
|
|
518
|
+
- `"randomLane"` is useful for simple spreading and experiments.
|
|
519
|
+
- `"firstIdleOrRandom"` prefers an idle worker, then falls back to random.
|
|
520
|
+
- `"robinRound"` is kept as a legacy alias of `"roundRobin"`.
|
|
521
|
+
|
|
522
|
+
### Dispatcher and work stealing
|
|
523
|
+
|
|
524
|
+
Most users can leave `host.dispatcher` alone. It selects the dispatcher for
|
|
525
|
+
private-lane pools: Bun and single-worker pools default to `"per-thread"`, while
|
|
526
|
+
multi-worker Node/Deno pools use `"serial-channel"`. Selecting a dispatcher or
|
|
527
|
+
balancer explicitly preserves that private-lane topology unless
|
|
528
|
+
`host.steal: true` is also explicit.
|
|
529
|
+
|
|
530
|
+
Ordinary multi-worker thread and process pools use shared-submit work stealing
|
|
531
|
+
by default. It is not used by one-worker pools, the inliner, compiled/Porffor
|
|
532
|
+
workers, or pools with an explicit balancer/dispatcher, so those modes retain
|
|
533
|
+
their existing transport. Process workers use one process-shared submit region
|
|
534
|
+
and one private return region per process. Pools above the current 31-claimant
|
|
535
|
+
protocol limit also fall back. Set `host: { steal: false }` or
|
|
536
|
+
`KNITTING_STEAL=0` to opt out for uniformly cheap, low-concurrency workloads
|
|
537
|
+
where arbitration has nothing to rebalance. `host: { steal: true }` or
|
|
538
|
+
`KNITTING_STEAL=1` forces it for an otherwise compatible pool.
|
|
539
|
+
|
|
540
|
+
Two options tune the arbitration itself, and both only apply to a stealing pool:
|
|
541
|
+
|
|
542
|
+
- `host.stealRegionLanes` is how many submit lanes one handshake claims (a power
|
|
543
|
+
of two). **A region is a batch**: a wide region amortises arbitration best for
|
|
544
|
+
cheap tasks, but it also lets one worker claim work the others could have run
|
|
545
|
+
in parallel. Set it to `1` (or a small value) when per-task cost dominates
|
|
546
|
+
arbitration cost. The default is the widest region the lane budget allows.
|
|
547
|
+
- `host.stealClaim` selects the claim discipline: `"ticket"` (the default)
|
|
548
|
+
claims in publication order from one monotonic counter; `"dekker"` gives each
|
|
549
|
+
consumer its own intent slot and requires at least one region per consumer.
|
|
550
|
+
Set `KNITTING_STEAL_CLAIM=ticket` or `KNITTING_STEAL_CLAIM=dekker` to select
|
|
551
|
+
it from the environment; the explicit option wins. **An unrecognised value is
|
|
552
|
+
an error, not a fallback**: a typo, or a `cas-mask` setting left over from
|
|
553
|
+
when that discipline existed, fails at pool creation rather than quietly
|
|
554
|
+
running a discipline you did not choose.
|
|
555
|
+
|
|
556
|
+
The ticket discipline uses a 64-bit claim head and a wrapping 32-bit
|
|
557
|
+
publication tail. The head CAS validates the tail snapshot: at most 32 tickets
|
|
558
|
+
can be pending, so unsigned subtraction recovers the distance across a tail wrap
|
|
559
|
+
without ever recycling a claim identity. `stealRegionLanes` caps the number of
|
|
560
|
+
tickets one claim takes, and its default width is unchanged from Dekker's.
|
|
561
|
+
Claims are ordered, but workers may decode or complete later claims first.
|
|
562
|
+
Ticket identities never wrap: the queue fails closed if a claim would exhaust
|
|
563
|
+
the signed 64-bit sequence.
|
|
564
|
+
|
|
565
|
+
Ticket is the default while it is under evaluation, so ordinary runs exercise
|
|
566
|
+
it; `"dekker"` remains explicitly selectable and is unchanged. The two differ in
|
|
567
|
+
how they behave after a fatal fault. Dekker releases the region and lets a peer
|
|
568
|
+
finish the rest. **Ticket fails closed**: a fatal decoder or worker failure
|
|
569
|
+
closes the shared queue, rejects outstanding calls, and rejects every future
|
|
570
|
+
call, so that pool has to be shut down and replaced. Claimed work is not
|
|
571
|
+
replayed, because a failed decoder may already have consumed payload state.
|
|
572
|
+
Ordinary task promise rejections stay task-local under both.
|
|
573
|
+
|
|
574
|
+
### Completion doorbells
|
|
575
|
+
|
|
576
|
+
By default the host waits to be told a result is ready instead of repeatedly
|
|
577
|
+
polling an empty return mailbox. `host.doorbell` is enabled wherever a wake path
|
|
578
|
+
exists, and each runtime uses the one it has:
|
|
579
|
+
|
|
580
|
+
- Node and Bun thread workers use `Atomics.waitAsync`.
|
|
581
|
+
- Deno uses a thread-safe FFI callback, because its `waitAsync` does not wake an
|
|
582
|
+
idle event loop. It is skipped when FFI permission is unavailable.
|
|
583
|
+
- Process workers use a process-local completion transport, since Atomics
|
|
584
|
+
waiters are per-isolate and cannot be rung from another process.
|
|
585
|
+
- Anything unsupported or denied falls back to the portable polling path.
|
|
586
|
+
|
|
587
|
+
`host.nativeDoorbell: true` additionally opts Node thread workers into the
|
|
588
|
+
native `uv_async_t` bridge from the `knitting_doorbell` addon. It is off by
|
|
589
|
+
default, does not apply to process workers, and is ignored entirely when
|
|
590
|
+
`host.doorbell` is `false`.
|
|
591
|
+
Node thread permissions must allow native addons for this bridge; otherwise
|
|
592
|
+
Knitting uses the portable wake path.
|
|
593
|
+
|
|
594
|
+
Set `host: { doorbell: false }` to force polling — useful for controlled
|
|
595
|
+
comparisons, and for pools that oversubscribe the machine. A doorbell only makes
|
|
596
|
+
progress when the host gets scheduled, so once workers occupy every core a wake
|
|
597
|
+
has to preempt one.
|
|
598
|
+
|
|
599
|
+
### Useful tuning options
|
|
600
|
+
|
|
601
|
+
- Increase `threads` for parallel CPU-heavy work.
|
|
602
|
+
- Increase `payload.payloadMaxByteLength` only when the transport buffer needs
|
|
603
|
+
more room.
|
|
604
|
+
- Increase `payload.maxPayloadBytes` only when individual calls genuinely need
|
|
605
|
+
larger payloads.
|
|
606
|
+
- Use process workers when isolation matters more than startup cost.
|
|
607
|
+
|
|
391
608
|
## Worker Runtimes
|
|
392
609
|
|
|
393
610
|
By default, workers use runtime-local threads where possible (the lowest
|
|
@@ -437,11 +654,12 @@ using pool = createPool({
|
|
|
437
654
|
You can also provide a `processCommandPrefix` when workers need to be launched
|
|
438
655
|
through a wrapper such as a package manager, container command, or runtime shim.
|
|
439
656
|
|
|
440
|
-
That prefix is also useful for sandbox and resource-control tools.
|
|
441
|
-
|
|
442
|
-
|
|
657
|
+
That prefix is also useful for sandbox and resource-control tools. On Node and
|
|
658
|
+
Bun POSIX hosts, process workers receive their shared-memory handle on stdin,
|
|
659
|
+
which is file descriptor 0. Wrappers that leave stdin alone usually work;
|
|
443
660
|
wrappers that replace, close, or proxy stdin without passing the fd through will
|
|
444
|
-
stop the worker from booting.
|
|
661
|
+
stop the worker from booting. Deno-hosted and Windows pools use named mappings
|
|
662
|
+
instead.
|
|
445
663
|
|
|
446
664
|
For wrappers that cannot preserve fd 0, use named process-worker memory instead.
|
|
447
665
|
The worker process must share the same OS IPC namespace as the host so it can
|
|
@@ -588,12 +806,11 @@ hooks, permission policies, and host inlining still fail during pool creation or
|
|
|
588
806
|
invocation; `worker.hardTimeoutMs` remains available because the host enforces
|
|
589
807
|
it.
|
|
590
808
|
|
|
591
|
-
### Windows process workers
|
|
809
|
+
### Deno and Windows process workers
|
|
592
810
|
|
|
593
|
-
On Windows, Knitting automatically uses named shared
|
|
594
|
-
process-worker control channel. You do not need to set
|
|
595
|
-
`processSharedMemory: "named"` yourself — the runtime
|
|
596
|
-
it.
|
|
811
|
+
On Windows and when the host is Deno, Knitting automatically uses named shared
|
|
812
|
+
memory for the process-worker control channel. You do not need to set
|
|
813
|
+
`processSharedMemory: "named"` yourself — the runtime selects it automatically.
|
|
597
814
|
|
|
598
815
|
```ts
|
|
599
816
|
// Works on Windows without extra options.
|
|
@@ -642,87 +859,10 @@ const pool = createPool({
|
|
|
642
859
|
})({ add });
|
|
643
860
|
```
|
|
644
861
|
|
|
645
|
-
## Browsers
|
|
646
|
-
|
|
647
|
-
Knitting also runs in the browser, where the pool spawns web workers over
|
|
648
|
-
`SharedArrayBuffer` instead of threads. Two rules apply there and nowhere else.
|
|
649
|
-
|
|
650
|
-
**The page must be cross-origin isolated.** Browsers hand out
|
|
651
|
-
`SharedArrayBuffer` only under these two response headers, and `createPool`
|
|
652
|
-
fails with a clear error when they are missing:
|
|
653
|
-
|
|
654
|
-
```
|
|
655
|
-
Cross-Origin-Opener-Policy: same-origin
|
|
656
|
-
Cross-Origin-Embedder-Policy: require-corp
|
|
657
|
-
```
|
|
658
|
-
|
|
659
|
-
**Task modules must declare their own URL.** On Node, Deno, and Bun a task
|
|
660
|
-
finds its module by walking the stack; a bundler erases the paths that depends
|
|
661
|
-
on, so call `setModuleUrl(import.meta.url)` in the module that exports tasks:
|
|
662
|
-
|
|
663
|
-
```ts
|
|
664
|
-
import { createPool, isMain, setModuleUrl, task } from "knitting/browser";
|
|
665
|
-
|
|
666
|
-
setModuleUrl(import.meta.url);
|
|
667
|
-
|
|
668
|
-
export const square = task({ f: (value: number) => value * value });
|
|
669
|
-
|
|
670
|
-
if (isMain) {
|
|
671
|
-
const pool = createPool({ threads: 4 })({ square });
|
|
672
|
-
console.log(await pool.call.square(7)); // 49
|
|
673
|
-
await pool.shutdown();
|
|
674
|
-
}
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
Bundle that module with any browser-targeting bundler. The result is
|
|
678
|
-
self-hosting: the page loads it, and every worker the pool spawns loads the
|
|
679
|
-
same file, which is why both sides agree on the module URL.
|
|
680
|
-
|
|
681
|
-
`knitting/browser` ships as one self-contained file, so it also works without a
|
|
682
|
-
bundler at all — serve it next to a plain task module:
|
|
683
|
-
|
|
684
|
-
```html
|
|
685
|
-
<script type="module" src="./tasks.js"></script>
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
```js
|
|
689
|
-
// tasks.js
|
|
690
|
-
import { createPool, isMain, setModuleUrl, task } from "./knitting.browser.js";
|
|
691
|
-
|
|
692
|
-
setModuleUrl(import.meta.url);
|
|
693
|
-
|
|
694
|
-
export const square = task({ f: (value) => value * value });
|
|
695
|
-
|
|
696
|
-
if (isMain) {
|
|
697
|
-
const pool = createPool({ threads: 2 })({ square });
|
|
698
|
-
console.log(await pool.call.square(7)); // 49
|
|
699
|
-
await pool.shutdown();
|
|
700
|
-
}
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
It is the same API as the main entry without the compiled worker (Porffor)
|
|
704
|
-
helpers, which need a filesystem. Process workers, native addons, FFI, and the
|
|
705
|
-
permission system are inert in a browser — permissions are skipped entirely,
|
|
706
|
-
since there is no filesystem or process to police.
|
|
707
|
-
|
|
708
|
-
Process workers, compiled workers, native addons, FFI, `BufferReference`, and
|
|
709
|
-
`ProcessSharedBuffer` are all unavailable in a page, and permissions are
|
|
710
|
-
skipped rather than enforced. [BROWSER.md](BROWSER.md) documents every one of
|
|
711
|
-
those, with the error each raises.
|
|
712
|
-
|
|
713
|
-
The published file is bundled and minified, with the Node-only subsystems
|
|
714
|
-
(process workers, compiled workers, native addons, FFI, permissions) replaced
|
|
715
|
-
by stubs that keep their browser behaviour — roughly 94 KB, 32 KB over gzip.
|
|
716
|
-
Both layouts above are covered by the browser test lane:
|
|
717
|
-
|
|
718
|
-
```bash
|
|
719
|
-
npm run build:browser # build/knitting.browser.js and .min.js
|
|
720
|
-
npm run test:browser # end-to-end checks in headless Chromium
|
|
721
|
-
```
|
|
722
|
-
|
|
723
862
|
## Permissions
|
|
724
863
|
|
|
725
|
-
Knitting defaults to a strict worker permission policy
|
|
864
|
+
Knitting defaults to a strict worker permission policy where the selected
|
|
865
|
+
runtime supports it:
|
|
726
866
|
|
|
727
867
|
```ts
|
|
728
868
|
permission: { mode: "strict", allowImport: true }
|
|
@@ -780,16 +920,33 @@ backward compatible and produce a once-per-runtime warning.
|
|
|
780
920
|
cannot be represented. When a wrapper or cross-runtime host hides the target
|
|
781
921
|
Node version, Knitting uses the conservative Node 22/24 capability set.
|
|
782
922
|
|
|
783
|
-
These compatibility checks currently cover process workers.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
923
|
+
These compatibility checks currently cover process workers. Node thread
|
|
924
|
+
workers receive their resolved Node permission flags, and Knitting fails pool
|
|
925
|
+
creation if Node cannot apply them. Deno thread workers inherit the creator's
|
|
926
|
+
permissions because Knitting does not yet set Deno's worker-specific permission
|
|
927
|
+
options, which are unstable and gated by `--unstable-worker-options`; Bun
|
|
928
|
+
thread workers do not have a matching permission mechanism here.
|
|
929
|
+
For cross-runtime permission enforcement, use a process worker with a runtime
|
|
930
|
+
that supports the restrictions you need; Deno has the broadest coverage in the
|
|
931
|
+
table above. Runtime permissions are guardrails, not the only security boundary
|
|
932
|
+
for hostile code.
|
|
787
933
|
|
|
788
934
|
The top-level `ffi` permission is the explicit cross-runtime native-code
|
|
789
935
|
capability. On Node it enables both native addons and `node:ffi`; Node's
|
|
790
936
|
`--allow-ffi` permission is currently unrestricted. The legacy/runtime-specific
|
|
791
937
|
`node.allowAddons` and `node.allowFfi` switches are independent—enabling addons
|
|
792
|
-
does not silently enable FFI.
|
|
938
|
+
does not silently enable FFI. Node thread workers deny addon loading by default
|
|
939
|
+
under the strict policy. Set `permission.node.allowAddons: true` when a task
|
|
940
|
+
needs an addon-backed feature on addon-backed Node versions: `SharedArrayBuffer`
|
|
941
|
+
arguments, returns and `Envelope` bodies, `ProcessSharedBuffer`, and
|
|
942
|
+
`BufferReference`. Node 26 maps the same pointers through `node:ffi`, so there
|
|
943
|
+
the switch is `permission.node.allowFfi`; top-level `ffi: true` grants both.
|
|
944
|
+
Without it a Node thread worker cannot map those pointers: the worker crashes
|
|
945
|
+
and later calls on it fail. Either switch lets task code load native code
|
|
946
|
+
generally, so use it only for trusted tasks. Two paths degrade instead of
|
|
947
|
+
failing when addon loading is denied: large returns are copied rather than
|
|
948
|
+
moved, and the optional native completion doorbell falls back to the portable
|
|
949
|
+
wake path.
|
|
793
950
|
|
|
794
951
|
Node process workers are a transport exception: Knitting needs `--allow-addons`
|
|
795
952
|
on Node 22/24 or `--allow-ffi` on Node 26 to map their shared memory. Deno
|
|
@@ -797,126 +954,212 @@ process workers likewise need `--allow-ffi`. Those capabilities apply to the
|
|
|
797
954
|
entire worker process, including task code, so explicit native-code denial fails
|
|
798
955
|
closed. Use an OS sandbox when task code is hostile.
|
|
799
956
|
|
|
800
|
-
##
|
|
957
|
+
## Runtime Safety
|
|
801
958
|
|
|
802
|
-
|
|
959
|
+
Knitting aims to make the safer path the default:
|
|
803
960
|
|
|
804
|
-
-
|
|
805
|
-
-
|
|
806
|
-
-
|
|
807
|
-
-
|
|
808
|
-
-
|
|
809
|
-
|
|
810
|
-
-
|
|
811
|
-
`
|
|
812
|
-
- `Error`, `Date`, and global symbols created with `Symbol.for(...)`.
|
|
813
|
-
- Native `Promise<supported-value>` inputs. The promise is awaited before
|
|
814
|
-
dispatch.
|
|
815
|
-
- Thenables are not awaited by the transport.
|
|
961
|
+
- Strict worker permissions are the default.
|
|
962
|
+
- Anonymous shared memory is the default.
|
|
963
|
+
- Named shared memory requires an explicit `mode`.
|
|
964
|
+
- Payload sizes are bounded.
|
|
965
|
+
- Abort-aware tasks reserve shared abort slots.
|
|
966
|
+
- Workers can be guarded with `worker.hardTimeoutMs`.
|
|
967
|
+
- Shutdown can stop immediately or wait for submitted work with
|
|
968
|
+
`worker.resolveAfterFinishingAll`.
|
|
816
969
|
|
|
817
|
-
|
|
818
|
-
|
|
970
|
+
When Knitting itself rejects a call, the reason is a `KnittingError` with a
|
|
971
|
+
`code`: `KNT_ERROR_0`–`KNT_ERROR_3` for an argument the host cannot encode, and
|
|
972
|
+
`WORKER_STARTUP_FAILED`, `WORKER_CRASHED`, `WORKER_EXITED` or `THREAD_CLOSED`
|
|
973
|
+
when the worker behind the call is gone. Calls to a dead worker reject at once
|
|
974
|
+
instead of staying pending. A return value the worker cannot encode still
|
|
975
|
+
rejects with the bare `KNT_ERROR_n` string.
|
|
819
976
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
- Cyclic object graphs.
|
|
824
|
-
- `Map`, `Set`, `WeakMap`, and non-global symbols.
|
|
825
|
-
- Objects with behavior that depends on prototypes, getters, setters, or hidden
|
|
826
|
-
process-local state.
|
|
977
|
+
That said, workers still run code. If you treat tasks like plugins, keep
|
|
978
|
+
permissions tight, keep named shared-memory names hard to guess, and avoid
|
|
979
|
+
passing broad capabilities into worker code.
|
|
827
980
|
|
|
828
|
-
|
|
981
|
+
## Browsers
|
|
829
982
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
983
|
+
Knitting also runs in the browser, where the pool spawns web workers over
|
|
984
|
+
`SharedArrayBuffer` instead of threads. Two rules apply there and nowhere else.
|
|
985
|
+
|
|
986
|
+
**The page must be cross-origin isolated.** Browsers hand out
|
|
987
|
+
`SharedArrayBuffer` only under these two response headers, and `createPool`
|
|
988
|
+
fails with a clear error when they are missing:
|
|
989
|
+
|
|
990
|
+
```
|
|
991
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
992
|
+
Cross-Origin-Embedder-Policy: require-corp
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
**Task modules must declare their own URL.** On Node, Deno, and Bun a task
|
|
996
|
+
finds its module by walking the stack; a bundler erases the paths that depends
|
|
997
|
+
on, so call `setModuleUrl(import.meta.url)` in the module that exports tasks:
|
|
834
998
|
|
|
835
999
|
```ts
|
|
836
|
-
import { createPool,
|
|
1000
|
+
import { createPool, isMain, setModuleUrl, task } from "knitting/browser";
|
|
837
1001
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
>({
|
|
842
|
-
f: (envelope) => {
|
|
843
|
-
const pixels = new Uint8Array(envelope.payload);
|
|
844
|
-
// ... process pixels
|
|
845
|
-
return new Envelope({ width: 800, height: 600 }, pixels.buffer);
|
|
846
|
-
},
|
|
847
|
-
});
|
|
1002
|
+
setModuleUrl(import.meta.url);
|
|
1003
|
+
|
|
1004
|
+
export const square = task({ f: (value: number) => value * value });
|
|
848
1005
|
|
|
849
1006
|
if (isMain) {
|
|
850
|
-
const pool = createPool({ threads:
|
|
1007
|
+
const pool = createPool({ threads: 4 })({ square });
|
|
1008
|
+
console.log(await pool.call.square(7)); // 49
|
|
1009
|
+
await pool.shutdown();
|
|
1010
|
+
}
|
|
1011
|
+
```
|
|
851
1012
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1013
|
+
Bundle that module with any browser-targeting bundler. The result is
|
|
1014
|
+
self-hosting: the page loads it, and every worker the pool spawns loads the
|
|
1015
|
+
same file, which is why both sides agree on the module URL.
|
|
1016
|
+
|
|
1017
|
+
`knitting/browser` ships as one self-contained file, so it also works without a
|
|
1018
|
+
bundler at all — serve it next to a plain task module:
|
|
1019
|
+
|
|
1020
|
+
```html
|
|
1021
|
+
<script type="module" src="./tasks.js"></script>
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
```js
|
|
1025
|
+
// tasks.js
|
|
1026
|
+
import { createPool, isMain, setModuleUrl, task } from "./knitting.browser.js";
|
|
1027
|
+
|
|
1028
|
+
setModuleUrl(import.meta.url);
|
|
1029
|
+
|
|
1030
|
+
export const square = task({ f: (value) => value * value });
|
|
1031
|
+
|
|
1032
|
+
if (isMain) {
|
|
1033
|
+
const pool = createPool({ threads: 2 })({ square });
|
|
1034
|
+
console.log(await pool.call.square(7)); // 49
|
|
1035
|
+
await pool.shutdown();
|
|
861
1036
|
}
|
|
862
1037
|
```
|
|
863
1038
|
|
|
864
|
-
|
|
1039
|
+
It is the same API as the main entry without the compiled worker (Porffor)
|
|
1040
|
+
helpers, which need a filesystem. Process workers, compiled workers, native
|
|
1041
|
+
addons, FFI, `BufferReference`, and `ProcessSharedBuffer` are all unavailable in
|
|
1042
|
+
a page, and permissions are skipped rather than enforced — there is no
|
|
1043
|
+
filesystem or process to police. [BROWSER.md](BROWSER.md) documents every one of
|
|
1044
|
+
those, with the error each raises.
|
|
865
1045
|
|
|
866
|
-
The
|
|
867
|
-
|
|
1046
|
+
The published file is bundled and minified, with the Node-only subsystems
|
|
1047
|
+
(process workers, compiled workers, native addons, FFI, permissions) replaced
|
|
1048
|
+
by stubs that keep their browser behaviour — roughly 94 KB, 32 KB over gzip.
|
|
1049
|
+
Both layouts above are covered by the browser test lane:
|
|
868
1050
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
| `ProcessSharedBuffer` | zero-copy, shared | thread + process | Cross-process shared memory. |
|
|
874
|
-
| `BufferReference` | zero-copy, moved | thread only | From `knitting/unsafe`; same constraints as bare `BufferReference`. |
|
|
1051
|
+
```bash
|
|
1052
|
+
npm run build:browser # build/knitting.browser.js and .min.js
|
|
1053
|
+
npm run test:browser # end-to-end checks in headless Chromium
|
|
1054
|
+
```
|
|
875
1055
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1056
|
+
## Shared Memory Channels
|
|
1057
|
+
|
|
1058
|
+
### Choosing a region or a moved request body
|
|
1059
|
+
|
|
1060
|
+
For thread-worker request handlers, `createKnittingAllocator()` can choose the
|
|
1061
|
+
cheaper representation for each body. Bodies below the HTTP default of 2 MiB
|
|
1062
|
+
are written into the allocator arena; larger bodies are moved into a
|
|
1063
|
+
`BufferReference`. The threshold is configurable because the best crossover
|
|
1064
|
+
depends on request concurrency and the runtime.
|
|
1065
|
+
|
|
1066
|
+
`allocOrRefer()` makes that choice without making you handle it. It returns one
|
|
1067
|
+
disposable handle: send `body.wire` to a task, and the host owns the bytes
|
|
1068
|
+
until the handle is disposed.
|
|
881
1069
|
|
|
882
1070
|
```ts
|
|
883
|
-
import {
|
|
884
|
-
import { BufferReference } from "knitting/unsafe";
|
|
1071
|
+
import { createKnittingAllocator } from "knitting/shared-memory";
|
|
885
1072
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
>({
|
|
890
|
-
f: (envelope) => {
|
|
891
|
-
const pixels = envelope.payload.toUint8Array();
|
|
892
|
-
const out = new Uint8Array(pixels.length);
|
|
893
|
-
for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i];
|
|
894
|
-
return new Envelope({ op: "inverted" }, new BufferReference(out));
|
|
895
|
-
},
|
|
1073
|
+
const allocator = createKnittingAllocator({
|
|
1074
|
+
// Size this for the number of small bodies that may be live at once.
|
|
1075
|
+
arenaByteLength: 8 * 1024 * 1024,
|
|
896
1076
|
});
|
|
897
1077
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1078
|
+
using body = await allocator.allocOrRefer(request, {
|
|
1079
|
+
referenceAboveBytes: 2 * 1024 * 1024,
|
|
1080
|
+
maxByteLength: 8 * 1024 * 1024,
|
|
1081
|
+
});
|
|
901
1082
|
|
|
902
|
-
|
|
903
|
-
new Envelope({ op: "invert" }, new BufferReference(pixels)),
|
|
904
|
-
);
|
|
905
|
-
console.log(result.header, [...result.payload.toUint8Array()]);
|
|
906
|
-
}
|
|
1083
|
+
await pool.call.processBody(body.wire);
|
|
907
1084
|
```
|
|
908
1085
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
for `ArrayBuffer` / `SharedArrayBuffer` bodies. See
|
|
912
|
-
[Experimental zero-copy buffers for thread workers](#experimental-zero-copy-buffers-for-thread-workers)
|
|
913
|
-
for the full `BufferReference` constraints, which apply unchanged to a
|
|
914
|
-
`BufferReference` body.
|
|
1086
|
+
The worker attaches to the arena once, in a bootstrap module, and then reads
|
|
1087
|
+
every body the same way whatever transport it took:
|
|
915
1088
|
|
|
916
|
-
|
|
917
|
-
|
|
1089
|
+
```ts
|
|
1090
|
+
// bootstrap.ts — runs once per worker, before any task module loads.
|
|
1091
|
+
import {
|
|
1092
|
+
createBodyReader,
|
|
1093
|
+
type KnittingBodyWire,
|
|
1094
|
+
type KnittingTransport,
|
|
1095
|
+
} from "knitting/shared-memory";
|
|
918
1096
|
|
|
919
|
-
|
|
1097
|
+
let reader: ((wire: KnittingBodyWire) => Uint8Array) | undefined;
|
|
1098
|
+
|
|
1099
|
+
export const setup = (transport: KnittingTransport) => {
|
|
1100
|
+
reader = createBodyReader(transport);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
export const openBody = (wire: KnittingBodyWire): Uint8Array => {
|
|
1104
|
+
if (reader === undefined) throw new Error("body reader not attached");
|
|
1105
|
+
return reader(wire);
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// tasks.ts — one signature, whichever way the body arrived.
|
|
1109
|
+
import { openBody } from "./bootstrap.ts";
|
|
1110
|
+
|
|
1111
|
+
export const processBody = task<KnittingBodyWire, number>({
|
|
1112
|
+
f: (wire) => digest(openBody(wire)),
|
|
1113
|
+
});
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
Pass the transport when the pool is built:
|
|
1117
|
+
|
|
1118
|
+
```ts
|
|
1119
|
+
const pool = createPool({
|
|
1120
|
+
threads: 4,
|
|
1121
|
+
worker: {
|
|
1122
|
+
bootstrap: {
|
|
1123
|
+
href: "./bootstrap.ts",
|
|
1124
|
+
name: "setup",
|
|
1125
|
+
data: allocator.transport(),
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
})({ processBody });
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
The host owns the body for the whole call and the worker only borrows it, so
|
|
1132
|
+
there is exactly one release and it is the `using` scope. That holds for both
|
|
1133
|
+
transports, and it holds even if the scope exits early: sending `body.wire`
|
|
1134
|
+
takes a hold on the body for the duration of the call, so a handler that lets
|
|
1135
|
+
go before its call settles defers the release rather than freeing memory a
|
|
1136
|
+
worker is still reading. Nothing needs to call `reconcile()`: the registry
|
|
1137
|
+
reclaims identities when the next allocation wants them. The bytes a worker
|
|
1138
|
+
reads are valid only for the duration of the call — to keep them, copy.
|
|
1139
|
+
|
|
1140
|
+
`maxByteLength` is required, and enforced at runtime rather than only by the
|
|
1141
|
+
types, because a declared `Content-Length` is a claim by the client and the
|
|
1142
|
+
memory is committed on the strength of that claim before a byte arrives. A body
|
|
1143
|
+
with no declared length is read against the same cap rather than buffered whole
|
|
1144
|
+
and measured afterwards. Omitting the bound raises a `RangeError` before
|
|
1145
|
+
anything is read, since this path deliberately allocates outside the arena and
|
|
1146
|
+
so has no ceiling to fall back on.
|
|
1147
|
+
|
|
1148
|
+
`allocOrRefer()` also handles chunked requests without `Content-Length`: it
|
|
1149
|
+
materializes the body only long enough to choose the representation by its
|
|
1150
|
+
actual size. `BufferReference` is for same-process thread workers; use
|
|
1151
|
+
`ProcessSharedBuffer` for process workers.
|
|
1152
|
+
|
|
1153
|
+
If you would rather make the choice yourself, the exported
|
|
1154
|
+
`readBodyOrRefer(request, allocator, options)` returns the underlying
|
|
1155
|
+
`KnittingSharedBuffer` or `BufferReference` directly and leaves the lifetime to
|
|
1156
|
+
you — including the hold that `allocOrRefer()` takes for you, so an early
|
|
1157
|
+
release there really does free the bytes. Sending a region by hand means
|
|
1158
|
+
picking an ownership rule: `moveTo()` hands the identity to the consumer, which
|
|
1159
|
+
then releases it, while `describe()` keeps it here and the consumer must adopt
|
|
1160
|
+
with `{ borrow: true }`. Doing both — describing a region and releasing it here
|
|
1161
|
+
while the consumer also releases — gives one identity two releasers, which is
|
|
1162
|
+
the one way to hand a live region's bytes out twice.
|
|
920
1163
|
|
|
921
1164
|
`ProcessSharedBuffer` is the lower-level building block for process-safe shared
|
|
922
1165
|
memory. Use it when two workers or processes need to see the same bytes without
|
|
@@ -1088,122 +1331,196 @@ bytes, but it is not a network transport and it deliberately shares IPC with the
|
|
|
1088
1331
|
container. Use names like capabilities: generate them, keep them private, and
|
|
1089
1332
|
unlink them when the shared memory is no longer needed.
|
|
1090
1333
|
|
|
1091
|
-
###
|
|
1092
|
-
|
|
1093
|
-
`
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1334
|
+
### Large binary values in thread workers
|
|
1335
|
+
|
|
1336
|
+
Return an ordinary top-level `Uint8Array` or `ArrayBuffer`; there is no wrapper,
|
|
1337
|
+
manual release, or borrow window. At **256 KiB** and above knitting uses an
|
|
1338
|
+
ownership move on thread workers:
|
|
1339
|
+
|
|
1340
|
+
| Runtime | Result ownership |
|
|
1341
|
+
| --- | --- |
|
|
1342
|
+
| Node 22/24 with the addon | The host co-owns the V8 backing store: zero byte copies. Under the strict permission policy this needs `permission.node.allowAddons`; otherwise it takes the one-private-copy fallback. |
|
|
1343
|
+
| Deno and Bun | The host makes one private copy before the worker releases its pin. |
|
|
1344
|
+
| Older Node backend | The same one-private-copy fallback. |
|
|
1345
|
+
|
|
1346
|
+
The worker-side source is detached as it is returned. The host result is private
|
|
1347
|
+
and stays valid across later calls and pool shutdown. Process workers and small
|
|
1348
|
+
or non-movable values use the normal payload-copy path.
|
|
1349
|
+
|
|
1350
|
+
```ts
|
|
1351
|
+
export const render = task<number, Uint8Array>({
|
|
1352
|
+
f: (size) => {
|
|
1353
|
+
const out = new Uint8Array(size);
|
|
1354
|
+
out.fill(7);
|
|
1355
|
+
return out; // >= 256 KiB: ownership moves automatically
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
```
|
|
1359
|
+
|
|
1360
|
+
`BufferReference` remains an advanced, thread-only move handle in
|
|
1361
|
+
`knitting/unsafe`. Its most useful case is moving an already-owned large
|
|
1362
|
+
`ArrayBuffer`/typed array into a worker without the host-to-worker copy. Its
|
|
1363
|
+
source is detached immediately; worker views are valid only for the task call.
|
|
1364
|
+
Do not accept its metadata from untrusted code. You do **not** need it for
|
|
1365
|
+
ordinary large return values. See
|
|
1366
|
+
`docs/buffer-reference-ownership-move.md` for the low-level protocol.
|
|
1367
|
+
|
|
1368
|
+
### Experimental zero-copy returns with `sharedBytes` (opt-in)
|
|
1369
|
+
|
|
1370
|
+
For an ordinary large return, use the ownership move above. `sharedBytes(n)` is
|
|
1371
|
+
the explicit alternative when a worker can write directly into the shared arena:
|
|
1372
|
+
returning it sends only an offset and length, with no copy. In exchange, the
|
|
1373
|
+
host receives a short-lived borrowed view rather than an owned result.
|
|
1374
|
+
|
|
1375
|
+
The borrowed-return path is disabled by default. Enable it explicitly with
|
|
1376
|
+
`unsafe: { SharedBytes: true }` when creating the pool:
|
|
1119
1377
|
|
|
1120
1378
|
```ts
|
|
1121
1379
|
import { createPool, isMain, task } from "knitting";
|
|
1122
|
-
import {
|
|
1380
|
+
import { sharedBytes } from "knitting/unsafe";
|
|
1123
1381
|
|
|
1124
|
-
export const
|
|
1125
|
-
f: (
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
return new BufferReference(out); // move the result back to the host
|
|
1382
|
+
export const render = task<number, Uint8Array>({
|
|
1383
|
+
f: (size) => {
|
|
1384
|
+
const out = sharedBytes(size); // a region of the return arena
|
|
1385
|
+
for (let i = 0; i < out.length; i++) out[i] = i & 0xff;
|
|
1386
|
+
return out; // returned by reference, not copied
|
|
1130
1387
|
},
|
|
1131
1388
|
});
|
|
1132
1389
|
|
|
1133
1390
|
if (isMain) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const
|
|
1139
|
-
console.log(
|
|
1391
|
+
using pool = createPool({
|
|
1392
|
+
threads: 4,
|
|
1393
|
+
unsafe: { SharedBytes: true },
|
|
1394
|
+
})({ render });
|
|
1395
|
+
const pixels = await pool.call.render(1024 * 1024);
|
|
1396
|
+
console.log(pixels.byteLength);
|
|
1140
1397
|
}
|
|
1141
1398
|
```
|
|
1142
1399
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
`unsafe: { BufferReferenceReturn: "copy" }` — the safe single copy described
|
|
1166
|
-
above. Set it to `"borrow"` to skip that copy by borrowing the worker's
|
|
1167
|
-
backing store until the returned `BufferReference` is released. Call
|
|
1168
|
-
`ref.release()` or use `using`. Releasing **revokes** the borrow: the
|
|
1169
|
-
reference and every view taken from it are detached first, so later reads see
|
|
1170
|
-
empty views or throw instead of touching freed memory. A reference that is
|
|
1171
|
-
never released drops its borrow only once it and all of its views are
|
|
1172
|
-
unreachable. Pool shutdown revokes outstanding borrows too — references you
|
|
1173
|
-
still hold survive on a private copy, while stale views read as empty. If the
|
|
1174
|
-
bytes escape into HTTP responses, streams, timers, callbacks, or caches, copy
|
|
1175
|
-
them before the borrowed reference is released, or they will read as empty
|
|
1176
|
-
afterward.
|
|
1177
|
-
- **Unsafe escape hatch.** This is not a security boundary. Forged metadata or
|
|
1178
|
-
unsynchronized host/worker mutation can still be unsafe.
|
|
1179
|
-
- **Node backend depends on the Node line.** Node 22 and 24 use the
|
|
1180
|
-
`knitting_buffer_pointer` addon. Node 26 uses `node:ffi` and therefore needs
|
|
1181
|
-
`--experimental-ffi`.
|
|
1182
|
-
|
|
1183
|
-
Borrowed returns, end to end (this opts Node 26, Deno, and Bun in):
|
|
1400
|
+
It is an ordinary `Uint8Array` on both sides — no wrapper type, and nothing
|
|
1401
|
+
changes about how the task is declared or called.
|
|
1402
|
+
|
|
1403
|
+
#### One rule
|
|
1404
|
+
|
|
1405
|
+
**Neither side may keep it.** The region is recycled by the worker that lent it.
|
|
1406
|
+
The worker must not hold it past the return, and the host must copy anything it
|
|
1407
|
+
needs beyond the next 32 large results on that lane:
|
|
1408
|
+
|
|
1409
|
+
```ts
|
|
1410
|
+
const view = await pool.call.render(size);
|
|
1411
|
+
const keep = view.slice(); // copy if it outlives the next batch of calls
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
That rule is what makes this fast, and 32 is not an arbitrary number: it is one
|
|
1415
|
+
full lane of in-flight results, the narrowest window that cannot recycle a
|
|
1416
|
+
region while its own call is still unread.
|
|
1417
|
+
|
|
1418
|
+
#### The region is uninitialized
|
|
1419
|
+
|
|
1420
|
+
Like `Buffer.allocUnsafe`, `sharedBytes(n)` hands back memory that still holds
|
|
1421
|
+
whichever of this worker's earlier returns last used it. You own all `n` bytes:
|
|
1184
1422
|
|
|
1185
1423
|
```ts
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1424
|
+
const out = sharedBytes(size);
|
|
1425
|
+
const written = encodeInto(out); // may be less than `size`
|
|
1426
|
+
return out.subarray(0, written); // the tail is never sent
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
Returning a prefix is the cheap way to be safe — a `subarray` of a borrowed
|
|
1430
|
+
region is still borrowed, so it costs nothing. `sharedBytes(n, true)` zeroes the
|
|
1431
|
+
region first if you would rather not think about it, but that is a second full
|
|
1432
|
+
pass over shared memory, and on V8 that pass alone is most of what the feature
|
|
1433
|
+
saves: at 1 MiB on node it is the difference between 0.9x and 3.2x.
|
|
1434
|
+
|
|
1435
|
+
Only `sharedBytes` has this rule. It is deliberately still an unsafe, explicit
|
|
1436
|
+
arena loan.
|
|
1437
|
+
|
|
1438
|
+
#### When it pays
|
|
1439
|
+
|
|
1440
|
+
`sharedBytes` is worthwhile only when the worker writes directly into it and
|
|
1441
|
+
the result is consumed immediately. There is no size threshold that settles it,
|
|
1442
|
+
because two things move the answer more than size does:
|
|
1443
|
+
|
|
1444
|
+
- **The engine matters as much as the size.** V8 has no fast path for byte
|
|
1445
|
+
stores into shared memory; JSC shows no difference between shared and heap at
|
|
1446
|
+
all. Building a result in shared memory means paying that penalty on V8, so
|
|
1447
|
+
the same code can be a solid win on bun and a wash on node. An element-wise
|
|
1448
|
+
producer is the worst case; asking for `zeroFill` doubles the exposure.
|
|
1449
|
+
- **Borrowing trades a copy for a working set.** Every outstanding region is live
|
|
1450
|
+
arena, and one lane of 1 MiB returns is 32 MiB of it. Past the point where
|
|
1451
|
+
that stops fitting in cache, the host copy you saved costs less than the cache
|
|
1452
|
+
misses you bought.
|
|
1453
|
+
|
|
1454
|
+
So measure it. `bench/shared-return.ts` interleaves all the arms in one process
|
|
1455
|
+
for exactly that reason.
|
|
1456
|
+
|
|
1457
|
+
#### Arguments, going the other way
|
|
1192
1458
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1459
|
+
`unsafe.SharedArgs` points the same machinery at the request lane:
|
|
1460
|
+
`pool.sharedArgBytes(n)` gives the host a region of the submit arena to build a
|
|
1461
|
+
byte argument in, and the worker reads it in place.
|
|
1462
|
+
|
|
1463
|
+
```ts
|
|
1464
|
+
using pool = createPool({ threads: 4, unsafe: { SharedArgs: true } })({ render });
|
|
1465
|
+
|
|
1466
|
+
const frame = pool.sharedArgBytes(size);
|
|
1467
|
+
frame.set(await readChunk());
|
|
1468
|
+
await pool.call.render(frame);
|
|
1198
1469
|
```
|
|
1199
1470
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1471
|
+
Both borrowed arguments and borrowed returns are opt-in. The asymmetry is the
|
|
1472
|
+
point: a return is read by the host the moment it arrives, while an argument is read by
|
|
1473
|
+
task code that may hold it across an `await` — and the region is recycled after
|
|
1474
|
+
32 further large arguments. **Only turn this on if your tasks finish with their
|
|
1475
|
+
byte arguments before their first suspension point.**
|
|
1476
|
+
|
|
1477
|
+
It also needs the shared submit queue, which is the stealing dispatcher. With a
|
|
1478
|
+
per-worker dispatcher there is no single arena for the host to build into, so
|
|
1479
|
+
`sharedArgBytes` returns a plain `Uint8Array` and the call takes the copy path.
|
|
1480
|
+
That makes it always safe to call, and worth checking `buffer instanceof
|
|
1481
|
+
SharedArrayBuffer` if you want to know which you got.
|
|
1482
|
+
|
|
1483
|
+
Measured at 2 threads with 16 calls in flight, against allocating a fresh buffer
|
|
1484
|
+
per call: 2.0x at 8 KiB, 13x–27x at 64–256 KiB, and ~100x at 1 MiB, because the
|
|
1485
|
+
host stops allocating entirely. With a producer that writes every byte
|
|
1486
|
+
element-wise the win narrows to 2.5x–3.5x on bun and disappears on Node, for the
|
|
1487
|
+
shared-memory-write reason above.
|
|
1205
1488
|
|
|
1206
|
-
|
|
1489
|
+
#### Keeping it off
|
|
1490
|
+
|
|
1491
|
+
This is the default. `unsafe.SharedBytes: false` can be used to state the choice
|
|
1492
|
+
explicitly; borrowed returns stay out of the picture and `sharedBytes` degrades
|
|
1493
|
+
to a plain `Uint8Array`. Large top-level thread returns still use the safe
|
|
1494
|
+
ownership path above (zero-copy on the owning Node backend, one host copy on
|
|
1495
|
+
Deno/Bun); all other results use the normal private-copy path:
|
|
1496
|
+
|
|
1497
|
+
```ts
|
|
1498
|
+
using pool = createPool({
|
|
1499
|
+
threads: 4,
|
|
1500
|
+
unsafe: { SharedBytes: false },
|
|
1501
|
+
})({ render });
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
Reach for it when results must stay valid for unbounded time, or to rule the
|
|
1505
|
+
path out while chasing a bug.
|
|
1506
|
+
|
|
1507
|
+
#### Constraints
|
|
1508
|
+
|
|
1509
|
+
- **Needs a `SharedArrayBuffer`.** That is the only requirement: no native
|
|
1510
|
+
addon, no FFI, and — unlike the pointer payloads — process workers are fine,
|
|
1511
|
+
because the arena is mapped in both processes.
|
|
1512
|
+
- **Bounded by the arena.** A lane has 64 region identities and grows to
|
|
1513
|
+
`payload.payloadMaxByteLength` (64 MiB by default). When either runs out,
|
|
1514
|
+
returns quietly take the copy path rather than growing without limit.
|
|
1515
|
+
- **Not a security boundary.** Like everything in `knitting/unsafe`, this hands
|
|
1516
|
+
the host a window into a buffer the worker writes. Do not use it as an
|
|
1517
|
+
isolation mechanism.
|
|
1518
|
+
- **A view outlives its worker.** The region lives in the payload arena, which
|
|
1519
|
+
the host holds a reference to, so a view read after the worker dies still
|
|
1520
|
+
returns the bytes that were there — it does not throw and does not read freed
|
|
1521
|
+
memory. It is a snapshot, not a live channel.
|
|
1522
|
+
|
|
1523
|
+
## Platform and native support
|
|
1207
1524
|
|
|
1208
1525
|
Knitting supports Node.js 22+, Deno 2+, and Bun 1+ on Linux, macOS, and Windows.
|
|
1209
1526
|
|
|
@@ -1241,42 +1558,6 @@ bun run build:native
|
|
|
1241
1558
|
For Deno projects with permissions enabled, allow FFI when using process workers
|
|
1242
1559
|
or `ProcessSharedBuffer`.
|
|
1243
1560
|
|
|
1244
|
-
## Runtime Safety
|
|
1245
|
-
|
|
1246
|
-
Knitting aims to make the safer path the default:
|
|
1247
|
-
|
|
1248
|
-
- Strict worker permissions are the default.
|
|
1249
|
-
- Anonymous shared memory is the default.
|
|
1250
|
-
- Named shared memory requires an explicit `mode`.
|
|
1251
|
-
- Payload sizes are bounded.
|
|
1252
|
-
- Abort-aware tasks reserve shared abort slots.
|
|
1253
|
-
- Workers can be guarded with `worker.hardTimeoutMs`.
|
|
1254
|
-
- Shutdown can stop immediately or wait for submitted work with
|
|
1255
|
-
`worker.resolveAfterFinishingAll`.
|
|
1256
|
-
|
|
1257
|
-
That said, workers still run code. If you treat tasks like plugins, keep
|
|
1258
|
-
permissions tight, keep named shared-memory names hard to guess, and avoid
|
|
1259
|
-
passing broad capabilities into worker code.
|
|
1260
|
-
|
|
1261
|
-
## Scheduling and Tuning
|
|
1262
|
-
|
|
1263
|
-
Choose a balancer based on the shape of your work:
|
|
1264
|
-
|
|
1265
|
-
- `"roundRobin"` is simple and works well for similarly sized tasks.
|
|
1266
|
-
- `"firstIdle"` helps when task durations vary.
|
|
1267
|
-
- `"randomLane"` is useful for simple spreading and experiments.
|
|
1268
|
-
- `"firstIdleOrRandom"` prefers an idle worker, then falls back to random.
|
|
1269
|
-
- `"robinRound"` is kept as a legacy alias of `"roundRobin"`.
|
|
1270
|
-
|
|
1271
|
-
Useful tuning options:
|
|
1272
|
-
|
|
1273
|
-
- Increase `threads` for parallel CPU-heavy work.
|
|
1274
|
-
- Increase `payload.payloadMaxByteLength` only when the transport buffer needs
|
|
1275
|
-
more room.
|
|
1276
|
-
- Increase `payload.maxPayloadBytes` only when individual calls genuinely need
|
|
1277
|
-
larger payloads.
|
|
1278
|
-
- Use process workers when isolation matters more than startup cost.
|
|
1279
|
-
|
|
1280
1561
|
## Benchmarks
|
|
1281
1562
|
|
|
1282
1563
|
```bash
|