knitting 0.1.63 → 0.1.70
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 +525 -335
- package/knitting.browser.js +1 -1
- package/map.md +0 -6
- package/package.json +3 -3
- 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 +105 -42
- 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/shared-array-buffer-payload.d.ts +7 -0
- package/src/connections/shared-array-buffer-payload.js +27 -11
- 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 +38 -15
- package/src/memory/lock.js +205 -79
- package/src/memory/payloadCodec.d.ts +18 -2
- package/src/memory/payloadCodec.js +309 -65
- 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/runtime/deno-doorbell.d.ts +26 -0
- package/src/runtime/deno-doorbell.js +117 -0
- package/src/runtime/dispatcher.d.ts +8 -6
- package/src/runtime/dispatcher.js +80 -58
- package/src/runtime/host-arg-arena.d.ts +3 -0
- package/src/runtime/host-arg-arena.js +16 -0
- package/src/runtime/node-doorbell.d.ts +14 -0
- package/src/runtime/node-doorbell.js +84 -0
- package/src/runtime/pool.d.ts +21 -15
- package/src/runtime/pool.js +104 -116
- package/src/runtime/process-worker.d.ts +9 -0
- package/src/runtime/process-worker.js +22 -2
- package/src/runtime/tx-queue.d.ts +2 -5
- package/src/runtime/tx-queue.js +52 -48
- package/src/types.d.ts +35 -71
- package/src/worker/loop.js +79 -57
- package/src/worker/rx-queue.d.ts +2 -3
- package/src/worker/rx-queue.js +34 -40
- 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 +14 -19
- 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:
|
|
@@ -149,10 +152,16 @@ if (isMain) {
|
|
|
149
152
|
}
|
|
150
153
|
```
|
|
151
154
|
|
|
152
|
-
`using` starts pool shutdown when the scope exits and does not wait for it.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
`using` starts pool shutdown when the scope exits and does not wait for it. Use
|
|
156
|
+
`await pool.shutdown()` when you need to wait for shutdown or pass a shutdown
|
|
157
|
+
delay.
|
|
158
|
+
|
|
159
|
+
Deno 2+, Bun 1+, and Node.js 24+ parse `using` natively. Node.js 22 does not: it
|
|
160
|
+
has `Symbol.dispose`, but the declaration itself is a `SyntaxError`, and Node's
|
|
161
|
+
own type stripping (`--experimental-strip-types` /
|
|
162
|
+
`--experimental-transform-types`) does not downlevel it, so a `.ts` file with
|
|
163
|
+
`using` fails there too. For Node 22, compile the file with TypeScript 5.2+
|
|
164
|
+
(`target: "es2022"`), or call `await pool.shutdown()` instead.
|
|
156
165
|
|
|
157
166
|
For simple tasks that do not need timeout or abort metadata, exported functions
|
|
158
167
|
can be used directly:
|
|
@@ -314,6 +323,125 @@ if (isMain) {
|
|
|
314
323
|
}
|
|
315
324
|
```
|
|
316
325
|
|
|
326
|
+
## Payloads
|
|
327
|
+
|
|
328
|
+
Worker calls can carry the following values across the shared-memory transport:
|
|
329
|
+
|
|
330
|
+
- `string`, `number`, `boolean`, `bigint`, `null`, and `undefined`.
|
|
331
|
+
- Plain objects and arrays made from supported values.
|
|
332
|
+
- `ArrayBuffer`, Node `Buffer`, `DataView`, and supported typed arrays.
|
|
333
|
+
- `ProcessSharedBuffer`.
|
|
334
|
+
- `BufferReference` from `knitting/unsafe` for experimental zero-copy buffers to
|
|
335
|
+
thread workers (same process only; see below).
|
|
336
|
+
- `Envelope` for a JSON header plus a binary body (`ArrayBuffer`,
|
|
337
|
+
`SharedArrayBuffer`, `ProcessSharedBuffer`, or `BufferReference`).
|
|
338
|
+
- `Error`, `Date`, and global symbols created with `Symbol.for(...)`.
|
|
339
|
+
- Native `Promise<supported-value>` inputs. The promise is awaited before
|
|
340
|
+
dispatch.
|
|
341
|
+
- Thenables are not awaited by the transport.
|
|
342
|
+
|
|
343
|
+
If it isn't on that list, assume it isn't portable. Some things don't (or
|
|
344
|
+
shouldn't) cross the boundary:
|
|
345
|
+
|
|
346
|
+
- DOM objects and platform handles.
|
|
347
|
+
- Functions, unless they are exported pool tasks or part of a `task` or
|
|
348
|
+
`importTask` definition.
|
|
349
|
+
- Cyclic object graphs.
|
|
350
|
+
- `Map`, `Set`, `WeakMap`, and non-global symbols.
|
|
351
|
+
- Objects with behavior that depends on prototypes, getters, setters, or hidden
|
|
352
|
+
process-local state.
|
|
353
|
+
|
|
354
|
+
### Envelope
|
|
355
|
+
|
|
356
|
+
`Envelope` pairs a JSON-serializable header with a binary body. Use it when a
|
|
357
|
+
call needs both structured metadata and raw bytes in a single argument — the
|
|
358
|
+
transport carries one special binary value per call, so an envelope is the way
|
|
359
|
+
to attach a header to one.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import { createPool, Envelope, isMain, task } from "knitting";
|
|
363
|
+
|
|
364
|
+
export const processImage = task<
|
|
365
|
+
Envelope<{ format: string }>,
|
|
366
|
+
Envelope<{ width: number; height: number }>
|
|
367
|
+
>({
|
|
368
|
+
f: (envelope) => {
|
|
369
|
+
const pixels = new Uint8Array(envelope.payload);
|
|
370
|
+
// ... process pixels
|
|
371
|
+
return new Envelope({ width: 800, height: 600 }, pixels.buffer);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (isMain) {
|
|
376
|
+
const pool = createPool({ threads: 2 })({ processImage });
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const buffer = new ArrayBuffer(1024);
|
|
380
|
+
const result = await pool.call.processImage(
|
|
381
|
+
new Envelope({ format: "png" }, buffer),
|
|
382
|
+
);
|
|
383
|
+
console.log(result.header); // { width: 800, height: 600 }
|
|
384
|
+
} finally {
|
|
385
|
+
await pool.shutdown();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
#### Body types
|
|
391
|
+
|
|
392
|
+
The body is generic — `Envelope<Header, Body>` — and accepts any of the binary
|
|
393
|
+
shapes the transport understands:
|
|
394
|
+
|
|
395
|
+
| Body | Copy? | Workers | Notes |
|
|
396
|
+
| --------------------- | ----------------- | ---------------- | ------------------------------------------------------------------- |
|
|
397
|
+
| `ArrayBuffer` | copied | thread + process | The default body; works everywhere. |
|
|
398
|
+
| `SharedArrayBuffer` | zero-copy, shared | thread only | Shared by reference; process workers reject it. |
|
|
399
|
+
| `ProcessSharedBuffer` | zero-copy, shared | thread + process | Cross-process shared memory. |
|
|
400
|
+
| `BufferReference` | zero-copy, moved | thread only | From `knitting/unsafe`; same constraints as bare `BufferReference`. |
|
|
401
|
+
|
|
402
|
+
The header keeps its fast paths regardless of the body: a small header is
|
|
403
|
+
written inline, and only large headers spill to the dynamic payload region. A
|
|
404
|
+
zero-copy body keeps its own semantics — a `SharedArrayBuffer` stays shared by
|
|
405
|
+
reference, and a `BufferReference` body is still moved (its source is detached)
|
|
406
|
+
and joins the same borrow/copy/release flow it follows on its own.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
import { createPool, Envelope, isMain, task } from "knitting";
|
|
410
|
+
import { BufferReference } from "knitting/unsafe";
|
|
411
|
+
|
|
412
|
+
export const invert = task<
|
|
413
|
+
Envelope<{ op: string }, BufferReference>,
|
|
414
|
+
Envelope<{ op: string }, BufferReference>
|
|
415
|
+
>({
|
|
416
|
+
f: (envelope) => {
|
|
417
|
+
const pixels = envelope.payload.toUint8Array();
|
|
418
|
+
const out = new Uint8Array(pixels.length);
|
|
419
|
+
for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i];
|
|
420
|
+
return new Envelope({ op: "inverted" }, new BufferReference(out));
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (isMain) {
|
|
425
|
+
using pool = createPool({ threads: 1 })({ invert });
|
|
426
|
+
const pixels = new Uint8Array([0, 64, 128, 192, 255]);
|
|
427
|
+
|
|
428
|
+
using result = await pool.call.invert(
|
|
429
|
+
new Envelope({ op: "invert" }, new BufferReference(pixels)),
|
|
430
|
+
);
|
|
431
|
+
console.log(result.header, [...result.payload.toUint8Array()]);
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`Envelope` is disposable: disposing it (via `using` or `Symbol.dispose`)
|
|
436
|
+
disposes a disposable body such as a `BufferReference`, and is a harmless no-op
|
|
437
|
+
for `ArrayBuffer` / `SharedArrayBuffer` bodies. See
|
|
438
|
+
[Large binary values in thread workers](#large-binary-values-in-thread-workers)
|
|
439
|
+
for the full `BufferReference` constraints, which apply unchanged to a
|
|
440
|
+
`BufferReference` body.
|
|
441
|
+
|
|
442
|
+
If a payload is large, set `payload.maxPayloadBytes` deliberately and prefer
|
|
443
|
+
binary/shared-memory shapes over deeply nested objects.
|
|
444
|
+
|
|
317
445
|
## Creating Pools
|
|
318
446
|
|
|
319
447
|
You typically create one pool per set of tasks and reuse it.
|
|
@@ -342,29 +470,13 @@ Common options you might tweak:
|
|
|
342
470
|
| `worker.hardTimeoutMs` | Force pool shutdown when a task exceeds this many milliseconds. |
|
|
343
471
|
| `worker.runtime` | Choose `"thread"`, `"process"`, or experimental `"compiled"` workers. |
|
|
344
472
|
| `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
|
|
473
|
+
| `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
474
|
| `permission` | Runtime permission policy for workers. |
|
|
347
475
|
| `host.dispatcher` | Experimental host dispatcher topology: `"per-thread"` or `"serial-channel"`. |
|
|
348
476
|
| `host.steal` | Shared-submit work stealing for compatible multi-worker thread/process pools; enabled by default. Set `false` to use private submit lanes. |
|
|
349
477
|
| `debug` | Enable diagnostics (`host`, `globals`, `signals`, `imports`, `lifecycle`) or use `KNITTING_DEBUG`. |
|
|
350
478
|
| `source` | Worker source override for advanced runtimes. |
|
|
351
479
|
|
|
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
480
|
### Worker bootstrap
|
|
369
481
|
|
|
370
482
|
Use `worker.bootstrap` when a worker needs privileged setup before task modules
|
|
@@ -388,6 +500,45 @@ good place to remove environment variables, install runtime guards, open shared
|
|
|
388
500
|
memory metadata, or prepare globals that task modules should see at import time.
|
|
389
501
|
Bootstrap is worker-only and cannot be combined with the inline host lane.
|
|
390
502
|
|
|
503
|
+
## Scheduling and Tuning
|
|
504
|
+
|
|
505
|
+
### Choosing a balancer
|
|
506
|
+
|
|
507
|
+
Choose a balancer based on the shape of your work:
|
|
508
|
+
|
|
509
|
+
- `"roundRobin"` is simple and works well for similarly sized tasks.
|
|
510
|
+
- `"firstIdle"` helps when task durations vary.
|
|
511
|
+
- `"randomLane"` is useful for simple spreading and experiments.
|
|
512
|
+
- `"firstIdleOrRandom"` prefers an idle worker, then falls back to random.
|
|
513
|
+
- `"robinRound"` is kept as a legacy alias of `"roundRobin"`.
|
|
514
|
+
|
|
515
|
+
### Dispatcher and work stealing
|
|
516
|
+
|
|
517
|
+
Most users can leave `host.dispatcher` alone. It selects the dispatcher for
|
|
518
|
+
private-lane pools: Bun and single-worker pools default to `"per-thread"`, while
|
|
519
|
+
multi-worker Node/Deno pools use `"serial-channel"`. Selecting a dispatcher or
|
|
520
|
+
balancer explicitly preserves that private-lane topology unless
|
|
521
|
+
`host.steal: true` is also explicit.
|
|
522
|
+
|
|
523
|
+
Ordinary multi-worker thread and process pools use shared-submit work stealing
|
|
524
|
+
by default. It is not used by one-worker pools, the inliner, compiled/Porffor
|
|
525
|
+
workers, or pools with an explicit balancer/dispatcher, so those modes retain
|
|
526
|
+
their existing transport. Process workers use one process-shared submit region
|
|
527
|
+
and one private return region per process. Pools above the current 31-claimant
|
|
528
|
+
protocol limit also fall back. Set `host: { steal: false }` or
|
|
529
|
+
`KNITTING_STEAL=0` to opt out for uniformly cheap, low-concurrency workloads
|
|
530
|
+
where arbitration has nothing to rebalance. `host: { steal: true }` or
|
|
531
|
+
`KNITTING_STEAL=1` forces it for an otherwise compatible pool.
|
|
532
|
+
|
|
533
|
+
### Useful tuning options
|
|
534
|
+
|
|
535
|
+
- Increase `threads` for parallel CPU-heavy work.
|
|
536
|
+
- Increase `payload.payloadMaxByteLength` only when the transport buffer needs
|
|
537
|
+
more room.
|
|
538
|
+
- Increase `payload.maxPayloadBytes` only when individual calls genuinely need
|
|
539
|
+
larger payloads.
|
|
540
|
+
- Use process workers when isolation matters more than startup cost.
|
|
541
|
+
|
|
391
542
|
## Worker Runtimes
|
|
392
543
|
|
|
393
544
|
By default, workers use runtime-local threads where possible (the lowest
|
|
@@ -437,11 +588,12 @@ using pool = createPool({
|
|
|
437
588
|
You can also provide a `processCommandPrefix` when workers need to be launched
|
|
438
589
|
through a wrapper such as a package manager, container command, or runtime shim.
|
|
439
590
|
|
|
440
|
-
That prefix is also useful for sandbox and resource-control tools.
|
|
441
|
-
|
|
442
|
-
|
|
591
|
+
That prefix is also useful for sandbox and resource-control tools. On Node and
|
|
592
|
+
Bun POSIX hosts, process workers receive their shared-memory handle on stdin,
|
|
593
|
+
which is file descriptor 0. Wrappers that leave stdin alone usually work;
|
|
443
594
|
wrappers that replace, close, or proxy stdin without passing the fd through will
|
|
444
|
-
stop the worker from booting.
|
|
595
|
+
stop the worker from booting. Deno-hosted and Windows pools use named mappings
|
|
596
|
+
instead.
|
|
445
597
|
|
|
446
598
|
For wrappers that cannot preserve fd 0, use named process-worker memory instead.
|
|
447
599
|
The worker process must share the same OS IPC namespace as the host so it can
|
|
@@ -588,12 +740,11 @@ hooks, permission policies, and host inlining still fail during pool creation or
|
|
|
588
740
|
invocation; `worker.hardTimeoutMs` remains available because the host enforces
|
|
589
741
|
it.
|
|
590
742
|
|
|
591
|
-
### Windows process workers
|
|
743
|
+
### Deno and Windows process workers
|
|
592
744
|
|
|
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.
|
|
745
|
+
On Windows and when the host is Deno, Knitting automatically uses named shared
|
|
746
|
+
memory for the process-worker control channel. You do not need to set
|
|
747
|
+
`processSharedMemory: "named"` yourself — the runtime selects it automatically.
|
|
597
748
|
|
|
598
749
|
```ts
|
|
599
750
|
// Works on Windows without extra options.
|
|
@@ -642,84 +793,6 @@ const pool = createPool({
|
|
|
642
793
|
})({ add });
|
|
643
794
|
```
|
|
644
795
|
|
|
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
796
|
## Permissions
|
|
724
797
|
|
|
725
798
|
Knitting defaults to a strict worker permission policy:
|
|
@@ -797,126 +870,205 @@ process workers likewise need `--allow-ffi`. Those capabilities apply to the
|
|
|
797
870
|
entire worker process, including task code, so explicit native-code denial fails
|
|
798
871
|
closed. Use an OS sandbox when task code is hostile.
|
|
799
872
|
|
|
800
|
-
##
|
|
873
|
+
## Runtime Safety
|
|
801
874
|
|
|
802
|
-
|
|
875
|
+
Knitting aims to make the safer path the default:
|
|
803
876
|
|
|
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.
|
|
877
|
+
- Strict worker permissions are the default.
|
|
878
|
+
- Anonymous shared memory is the default.
|
|
879
|
+
- Named shared memory requires an explicit `mode`.
|
|
880
|
+
- Payload sizes are bounded.
|
|
881
|
+
- Abort-aware tasks reserve shared abort slots.
|
|
882
|
+
- Workers can be guarded with `worker.hardTimeoutMs`.
|
|
883
|
+
- Shutdown can stop immediately or wait for submitted work with
|
|
884
|
+
`worker.resolveAfterFinishingAll`.
|
|
816
885
|
|
|
817
|
-
|
|
818
|
-
|
|
886
|
+
That said, workers still run code. If you treat tasks like plugins, keep
|
|
887
|
+
permissions tight, keep named shared-memory names hard to guess, and avoid
|
|
888
|
+
passing broad capabilities into worker code.
|
|
819
889
|
|
|
820
|
-
|
|
821
|
-
- Functions, unless they are exported pool tasks or part of a `task` or
|
|
822
|
-
`importTask` definition.
|
|
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.
|
|
890
|
+
## Browsers
|
|
827
891
|
|
|
828
|
-
|
|
892
|
+
Knitting also runs in the browser, where the pool spawns web workers over
|
|
893
|
+
`SharedArrayBuffer` instead of threads. Two rules apply there and nowhere else.
|
|
829
894
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
895
|
+
**The page must be cross-origin isolated.** Browsers hand out
|
|
896
|
+
`SharedArrayBuffer` only under these two response headers, and `createPool`
|
|
897
|
+
fails with a clear error when they are missing:
|
|
898
|
+
|
|
899
|
+
```
|
|
900
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
901
|
+
Cross-Origin-Embedder-Policy: require-corp
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**Task modules must declare their own URL.** On Node, Deno, and Bun a task
|
|
905
|
+
finds its module by walking the stack; a bundler erases the paths that depends
|
|
906
|
+
on, so call `setModuleUrl(import.meta.url)` in the module that exports tasks:
|
|
834
907
|
|
|
835
908
|
```ts
|
|
836
|
-
import { createPool,
|
|
909
|
+
import { createPool, isMain, setModuleUrl, task } from "knitting/browser";
|
|
837
910
|
|
|
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
|
-
});
|
|
911
|
+
setModuleUrl(import.meta.url);
|
|
912
|
+
|
|
913
|
+
export const square = task({ f: (value: number) => value * value });
|
|
848
914
|
|
|
849
915
|
if (isMain) {
|
|
850
|
-
const pool = createPool({ threads:
|
|
916
|
+
const pool = createPool({ threads: 4 })({ square });
|
|
917
|
+
console.log(await pool.call.square(7)); // 49
|
|
918
|
+
await pool.shutdown();
|
|
919
|
+
}
|
|
920
|
+
```
|
|
851
921
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
922
|
+
Bundle that module with any browser-targeting bundler. The result is
|
|
923
|
+
self-hosting: the page loads it, and every worker the pool spawns loads the
|
|
924
|
+
same file, which is why both sides agree on the module URL.
|
|
925
|
+
|
|
926
|
+
`knitting/browser` ships as one self-contained file, so it also works without a
|
|
927
|
+
bundler at all — serve it next to a plain task module:
|
|
928
|
+
|
|
929
|
+
```html
|
|
930
|
+
<script type="module" src="./tasks.js"></script>
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
```js
|
|
934
|
+
// tasks.js
|
|
935
|
+
import { createPool, isMain, setModuleUrl, task } from "./knitting.browser.js";
|
|
936
|
+
|
|
937
|
+
setModuleUrl(import.meta.url);
|
|
938
|
+
|
|
939
|
+
export const square = task({ f: (value) => value * value });
|
|
940
|
+
|
|
941
|
+
if (isMain) {
|
|
942
|
+
const pool = createPool({ threads: 2 })({ square });
|
|
943
|
+
console.log(await pool.call.square(7)); // 49
|
|
944
|
+
await pool.shutdown();
|
|
861
945
|
}
|
|
862
946
|
```
|
|
863
947
|
|
|
864
|
-
|
|
948
|
+
It is the same API as the main entry without the compiled worker (Porffor)
|
|
949
|
+
helpers, which need a filesystem. Process workers, compiled workers, native
|
|
950
|
+
addons, FFI, `BufferReference`, and `ProcessSharedBuffer` are all unavailable in
|
|
951
|
+
a page, and permissions are skipped rather than enforced — there is no
|
|
952
|
+
filesystem or process to police. [BROWSER.md](BROWSER.md) documents every one of
|
|
953
|
+
those, with the error each raises.
|
|
865
954
|
|
|
866
|
-
The
|
|
867
|
-
|
|
955
|
+
The published file is bundled and minified, with the Node-only subsystems
|
|
956
|
+
(process workers, compiled workers, native addons, FFI, permissions) replaced
|
|
957
|
+
by stubs that keep their browser behaviour — roughly 94 KB, 32 KB over gzip.
|
|
958
|
+
Both layouts above are covered by the browser test lane:
|
|
868
959
|
|
|
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`. |
|
|
960
|
+
```bash
|
|
961
|
+
npm run build:browser # build/knitting.browser.js and .min.js
|
|
962
|
+
npm run test:browser # end-to-end checks in headless Chromium
|
|
963
|
+
```
|
|
875
964
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
965
|
+
## Shared Memory Channels
|
|
966
|
+
|
|
967
|
+
### Choosing a region or a moved request body
|
|
968
|
+
|
|
969
|
+
For thread-worker request handlers, `createKnittingAllocator()` can choose the
|
|
970
|
+
cheaper representation for each body. Bodies below the HTTP default of 2 MiB
|
|
971
|
+
are written into the allocator arena; larger bodies are moved into a
|
|
972
|
+
`BufferReference`. The threshold is configurable because the best crossover
|
|
973
|
+
depends on request concurrency and the runtime.
|
|
974
|
+
|
|
975
|
+
`allocOrRefer()` makes that choice without making you handle it. It returns one
|
|
976
|
+
disposable handle: send `body.wire` to a task, and the host owns the bytes
|
|
977
|
+
until the handle is disposed.
|
|
881
978
|
|
|
882
979
|
```ts
|
|
883
|
-
import {
|
|
884
|
-
import { BufferReference } from "knitting/unsafe";
|
|
980
|
+
import { createKnittingAllocator } from "knitting/shared-memory";
|
|
885
981
|
|
|
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
|
-
},
|
|
982
|
+
const allocator = createKnittingAllocator({
|
|
983
|
+
// Size this for the number of small bodies that may be live at once.
|
|
984
|
+
arenaByteLength: 8 * 1024 * 1024,
|
|
896
985
|
});
|
|
897
986
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
987
|
+
using body = await allocator.allocOrRefer(request, {
|
|
988
|
+
referenceAboveBytes: 2 * 1024 * 1024,
|
|
989
|
+
maxByteLength: 8 * 1024 * 1024,
|
|
990
|
+
});
|
|
901
991
|
|
|
902
|
-
|
|
903
|
-
new Envelope({ op: "invert" }, new BufferReference(pixels)),
|
|
904
|
-
);
|
|
905
|
-
console.log(result.header, [...result.payload.toUint8Array()]);
|
|
906
|
-
}
|
|
992
|
+
await pool.call.processBody(body.wire);
|
|
907
993
|
```
|
|
908
994
|
|
|
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.
|
|
995
|
+
The worker attaches to the arena once, in a bootstrap module, and then reads
|
|
996
|
+
every body the same way whatever transport it took:
|
|
915
997
|
|
|
916
|
-
|
|
917
|
-
|
|
998
|
+
```ts
|
|
999
|
+
// bootstrap.ts — runs once per worker, before any task module loads.
|
|
1000
|
+
import {
|
|
1001
|
+
createBodyReader,
|
|
1002
|
+
type KnittingBodyWire,
|
|
1003
|
+
type KnittingTransport,
|
|
1004
|
+
} from "knitting/shared-memory";
|
|
918
1005
|
|
|
919
|
-
|
|
1006
|
+
let reader: ((wire: KnittingBodyWire) => Uint8Array) | undefined;
|
|
1007
|
+
|
|
1008
|
+
export const setup = (transport: KnittingTransport) => {
|
|
1009
|
+
reader = createBodyReader(transport);
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
export const openBody = (wire: KnittingBodyWire): Uint8Array => {
|
|
1013
|
+
if (reader === undefined) throw new Error("body reader not attached");
|
|
1014
|
+
return reader(wire);
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// tasks.ts — one signature, whichever way the body arrived.
|
|
1018
|
+
import { openBody } from "./bootstrap.ts";
|
|
1019
|
+
|
|
1020
|
+
export const processBody = task<KnittingBodyWire, number>({
|
|
1021
|
+
f: (wire) => digest(openBody(wire)),
|
|
1022
|
+
});
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
Pass the transport when the pool is built:
|
|
1026
|
+
|
|
1027
|
+
```ts
|
|
1028
|
+
const pool = createPool({
|
|
1029
|
+
threads: 4,
|
|
1030
|
+
worker: {
|
|
1031
|
+
bootstrap: {
|
|
1032
|
+
href: "./bootstrap.ts",
|
|
1033
|
+
name: "setup",
|
|
1034
|
+
data: allocator.transport(),
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
})({ processBody });
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
The host owns the body for the whole call and the worker only borrows it, so
|
|
1041
|
+
there is exactly one release and it is the `using` scope. That holds for both
|
|
1042
|
+
transports, and it holds even if the scope exits early: sending `body.wire`
|
|
1043
|
+
takes a hold on the body for the duration of the call, so a handler that lets
|
|
1044
|
+
go before its call settles defers the release rather than freeing memory a
|
|
1045
|
+
worker is still reading. Nothing needs to call `reconcile()`: the registry
|
|
1046
|
+
reclaims identities when the next allocation wants them. The bytes a worker
|
|
1047
|
+
reads are valid only for the duration of the call — to keep them, copy.
|
|
1048
|
+
|
|
1049
|
+
`maxByteLength` is required, and enforced at runtime rather than only by the
|
|
1050
|
+
types, because a declared `Content-Length` is a claim by the client and the
|
|
1051
|
+
memory is committed on the strength of that claim before a byte arrives. A body
|
|
1052
|
+
with no declared length is read against the same cap rather than buffered whole
|
|
1053
|
+
and measured afterwards. Omitting the bound raises a `RangeError` before
|
|
1054
|
+
anything is read, since this path deliberately allocates outside the arena and
|
|
1055
|
+
so has no ceiling to fall back on.
|
|
1056
|
+
|
|
1057
|
+
`allocOrRefer()` also handles chunked requests without `Content-Length`: it
|
|
1058
|
+
materializes the body only long enough to choose the representation by its
|
|
1059
|
+
actual size. `BufferReference` is for same-process thread workers; use
|
|
1060
|
+
`ProcessSharedBuffer` for process workers.
|
|
1061
|
+
|
|
1062
|
+
If you would rather make the choice yourself, the exported
|
|
1063
|
+
`readBodyOrRefer(request, allocator, options)` returns the underlying
|
|
1064
|
+
`KnittingSharedBuffer` or `BufferReference` directly and leaves the lifetime to
|
|
1065
|
+
you — including the hold that `allocOrRefer()` takes for you, so an early
|
|
1066
|
+
release there really does free the bytes. Sending a region by hand means
|
|
1067
|
+
picking an ownership rule: `moveTo()` hands the identity to the consumer, which
|
|
1068
|
+
then releases it, while `describe()` keeps it here and the consumer must adopt
|
|
1069
|
+
with `{ borrow: true }`. Doing both — describing a region and releasing it here
|
|
1070
|
+
while the consumer also releases — gives one identity two releasers, which is
|
|
1071
|
+
the one way to hand a live region's bytes out twice.
|
|
920
1072
|
|
|
921
1073
|
`ProcessSharedBuffer` is the lower-level building block for process-safe shared
|
|
922
1074
|
memory. Use it when two workers or processes need to see the same bytes without
|
|
@@ -1088,122 +1240,196 @@ bytes, but it is not a network transport and it deliberately shares IPC with the
|
|
|
1088
1240
|
container. Use names like capabilities: generate them, keep them private, and
|
|
1089
1241
|
unlink them when the shared memory is no longer needed.
|
|
1090
1242
|
|
|
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
|
-
|
|
1243
|
+
### Large binary values in thread workers
|
|
1244
|
+
|
|
1245
|
+
Return an ordinary top-level `Uint8Array` or `ArrayBuffer`; there is no wrapper,
|
|
1246
|
+
manual release, or borrow window. At **256 KiB** and above knitting uses an
|
|
1247
|
+
ownership move on thread workers:
|
|
1248
|
+
|
|
1249
|
+
| Runtime | Result ownership |
|
|
1250
|
+
| --- | --- |
|
|
1251
|
+
| Node 22/24 with the addon | The host co-owns the V8 backing store: zero byte copies. |
|
|
1252
|
+
| Deno and Bun | The host makes one private copy before the worker releases its pin. |
|
|
1253
|
+
| Older Node backend | The same one-private-copy fallback. |
|
|
1254
|
+
|
|
1255
|
+
The worker-side source is detached as it is returned. The host result is private
|
|
1256
|
+
and stays valid across later calls and pool shutdown. Process workers and small
|
|
1257
|
+
or non-movable values use the normal payload-copy path.
|
|
1258
|
+
|
|
1259
|
+
```ts
|
|
1260
|
+
export const render = task<number, Uint8Array>({
|
|
1261
|
+
f: (size) => {
|
|
1262
|
+
const out = new Uint8Array(size);
|
|
1263
|
+
out.fill(7);
|
|
1264
|
+
return out; // >= 256 KiB: ownership moves automatically
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
`BufferReference` remains an advanced, thread-only move handle in
|
|
1270
|
+
`knitting/unsafe`. Its most useful case is moving an already-owned large
|
|
1271
|
+
`ArrayBuffer`/typed array into a worker without the host-to-worker copy. Its
|
|
1272
|
+
source is detached immediately; worker views are valid only for the task call.
|
|
1273
|
+
Do not accept its metadata from untrusted code. You do **not** need it for
|
|
1274
|
+
ordinary large return values. See
|
|
1275
|
+
`docs/buffer-reference-ownership-move.md` for the low-level protocol.
|
|
1276
|
+
|
|
1277
|
+
### Experimental zero-copy returns with `sharedBytes` (opt-in)
|
|
1278
|
+
|
|
1279
|
+
For an ordinary large return, use the ownership move above. `sharedBytes(n)` is
|
|
1280
|
+
the explicit alternative when a worker can write directly into the shared arena:
|
|
1281
|
+
returning it sends only an offset and length, with no copy. In exchange, the
|
|
1282
|
+
host receives a short-lived borrowed view rather than an owned result.
|
|
1283
|
+
|
|
1284
|
+
The borrowed-return path is disabled by default. Enable it explicitly with
|
|
1285
|
+
`unsafe: { SharedBytes: true }` when creating the pool:
|
|
1119
1286
|
|
|
1120
1287
|
```ts
|
|
1121
1288
|
import { createPool, isMain, task } from "knitting";
|
|
1122
|
-
import {
|
|
1289
|
+
import { sharedBytes } from "knitting/unsafe";
|
|
1123
1290
|
|
|
1124
|
-
export const
|
|
1125
|
-
f: (
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
return new BufferReference(out); // move the result back to the host
|
|
1291
|
+
export const render = task<number, Uint8Array>({
|
|
1292
|
+
f: (size) => {
|
|
1293
|
+
const out = sharedBytes(size); // a region of the return arena
|
|
1294
|
+
for (let i = 0; i < out.length; i++) out[i] = i & 0xff;
|
|
1295
|
+
return out; // returned by reference, not copied
|
|
1130
1296
|
},
|
|
1131
1297
|
});
|
|
1132
1298
|
|
|
1133
1299
|
if (isMain) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const
|
|
1139
|
-
console.log(
|
|
1300
|
+
using pool = createPool({
|
|
1301
|
+
threads: 4,
|
|
1302
|
+
unsafe: { SharedBytes: true },
|
|
1303
|
+
})({ render });
|
|
1304
|
+
const pixels = await pool.call.render(1024 * 1024);
|
|
1305
|
+
console.log(pixels.byteLength);
|
|
1140
1306
|
}
|
|
1141
1307
|
```
|
|
1142
1308
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
- **Move semantics.** Constructing a `BufferReference` detaches its source — the
|
|
1152
|
-
original buffer is empty afterward, and reads/writes through it are gone. The
|
|
1153
|
-
bytes now belong to the reference; to get a result back, the worker returns
|
|
1154
|
-
its own `BufferReference`. Each source ownership transfer is one-shot, but an
|
|
1155
|
-
active reference may be read more than once. Forward inputs the worker
|
|
1156
|
-
materializes with `.toArrayBuffer()`/`.toUint8Array()` are borrowed for the
|
|
1157
|
-
duration of the call and detached once it settles; do not keep using them from
|
|
1158
|
-
fire-and-forget work after the task returns.
|
|
1159
|
-
- **Forward is zero-copy everywhere.** Sending a buffer to the worker never
|
|
1160
|
-
copies. Node 22 and 24 with the owning addon also return the buffer without
|
|
1161
|
-
copying because the addon co-owns the V8 backing store. Node 26, Deno, and Bun
|
|
1162
|
-
take one safe copy on return because their FFI aliases cannot own the worker's
|
|
1163
|
-
backing store.
|
|
1164
|
-
- **Borrowed FFI returns are opt-in.** The default on Node 26, Deno, and Bun is
|
|
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):
|
|
1309
|
+
It is an ordinary `Uint8Array` on both sides — no wrapper type, and nothing
|
|
1310
|
+
changes about how the task is declared or called.
|
|
1311
|
+
|
|
1312
|
+
#### One rule
|
|
1313
|
+
|
|
1314
|
+
**Neither side may keep it.** The region is recycled by the worker that lent it.
|
|
1315
|
+
The worker must not hold it past the return, and the host must copy anything it
|
|
1316
|
+
needs beyond the next 32 large results on that lane:
|
|
1184
1317
|
|
|
1185
1318
|
```ts
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1319
|
+
const view = await pool.call.render(size);
|
|
1320
|
+
const keep = view.slice(); // copy if it outlives the next batch of calls
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
That rule is what makes this fast, and 32 is not an arbitrary number: it is one
|
|
1324
|
+
full lane of in-flight results, the narrowest window that cannot recycle a
|
|
1325
|
+
region while its own call is still unread.
|
|
1326
|
+
|
|
1327
|
+
#### The region is uninitialized
|
|
1192
1328
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1329
|
+
Like `Buffer.allocUnsafe`, `sharedBytes(n)` hands back memory that still holds
|
|
1330
|
+
whichever of this worker's earlier returns last used it. You own all `n` bytes:
|
|
1331
|
+
|
|
1332
|
+
```ts
|
|
1333
|
+
const out = sharedBytes(size);
|
|
1334
|
+
const written = encodeInto(out); // may be less than `size`
|
|
1335
|
+
return out.subarray(0, written); // the tail is never sent
|
|
1198
1336
|
```
|
|
1199
1337
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1338
|
+
Returning a prefix is the cheap way to be safe — a `subarray` of a borrowed
|
|
1339
|
+
region is still borrowed, so it costs nothing. `sharedBytes(n, true)` zeroes the
|
|
1340
|
+
region first if you would rather not think about it, but that is a second full
|
|
1341
|
+
pass over shared memory, and on V8 that pass alone is most of what the feature
|
|
1342
|
+
saves: at 1 MiB on node it is the difference between 0.9x and 3.2x.
|
|
1343
|
+
|
|
1344
|
+
Only `sharedBytes` has this rule. It is deliberately still an unsafe, explicit
|
|
1345
|
+
arena loan.
|
|
1346
|
+
|
|
1347
|
+
#### When it pays
|
|
1205
1348
|
|
|
1206
|
-
|
|
1349
|
+
`sharedBytes` is worthwhile only when the worker writes directly into it and
|
|
1350
|
+
the result is consumed immediately. There is no size threshold that settles it,
|
|
1351
|
+
because two things move the answer more than size does:
|
|
1352
|
+
|
|
1353
|
+
- **The engine matters as much as the size.** V8 has no fast path for byte
|
|
1354
|
+
stores into shared memory; JSC shows no difference between shared and heap at
|
|
1355
|
+
all. Building a result in shared memory means paying that penalty on V8, so
|
|
1356
|
+
the same code can be a solid win on bun and a wash on node. An element-wise
|
|
1357
|
+
producer is the worst case; asking for `zeroFill` doubles the exposure.
|
|
1358
|
+
- **Borrowing trades a copy for a working set.** Every outstanding region is live
|
|
1359
|
+
arena, and one lane of 1 MiB returns is 32 MiB of it. Past the point where
|
|
1360
|
+
that stops fitting in cache, the host copy you saved costs less than the cache
|
|
1361
|
+
misses you bought.
|
|
1362
|
+
|
|
1363
|
+
So measure it. `bench/shared-return.ts` interleaves all the arms in one process
|
|
1364
|
+
for exactly that reason.
|
|
1365
|
+
|
|
1366
|
+
#### Arguments, going the other way
|
|
1367
|
+
|
|
1368
|
+
`unsafe.SharedArgs` points the same machinery at the request lane:
|
|
1369
|
+
`pool.sharedArgBytes(n)` gives the host a region of the submit arena to build a
|
|
1370
|
+
byte argument in, and the worker reads it in place.
|
|
1371
|
+
|
|
1372
|
+
```ts
|
|
1373
|
+
using pool = createPool({ threads: 4, unsafe: { SharedArgs: true } })({ render });
|
|
1374
|
+
|
|
1375
|
+
const frame = pool.sharedArgBytes(size);
|
|
1376
|
+
frame.set(await readChunk());
|
|
1377
|
+
await pool.call.render(frame);
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
Both borrowed arguments and borrowed returns are opt-in. The asymmetry is the
|
|
1381
|
+
point: a return is read by the host the moment it arrives, while an argument is read by
|
|
1382
|
+
task code that may hold it across an `await` — and the region is recycled after
|
|
1383
|
+
32 further large arguments. **Only turn this on if your tasks finish with their
|
|
1384
|
+
byte arguments before their first suspension point.**
|
|
1385
|
+
|
|
1386
|
+
It also needs the shared submit queue, which is the stealing dispatcher. With a
|
|
1387
|
+
per-worker dispatcher there is no single arena for the host to build into, so
|
|
1388
|
+
`sharedArgBytes` returns a plain `Uint8Array` and the call takes the copy path.
|
|
1389
|
+
That makes it always safe to call, and worth checking `buffer instanceof
|
|
1390
|
+
SharedArrayBuffer` if you want to know which you got.
|
|
1391
|
+
|
|
1392
|
+
Measured at 2 threads with 16 calls in flight, against allocating a fresh buffer
|
|
1393
|
+
per call: 2.0x at 8 KiB, 13x–27x at 64–256 KiB, and ~100x at 1 MiB, because the
|
|
1394
|
+
host stops allocating entirely. With a producer that writes every byte
|
|
1395
|
+
element-wise the win narrows to 2.5x–3.5x on bun and disappears on Node, for the
|
|
1396
|
+
shared-memory-write reason above.
|
|
1397
|
+
|
|
1398
|
+
#### Keeping it off
|
|
1399
|
+
|
|
1400
|
+
This is the default. `unsafe.SharedBytes: false` can be used to state the choice
|
|
1401
|
+
explicitly; borrowed returns stay out of the picture and `sharedBytes` degrades
|
|
1402
|
+
to a plain `Uint8Array`. Large top-level thread returns still use the safe
|
|
1403
|
+
ownership path above (zero-copy on the owning Node backend, one host copy on
|
|
1404
|
+
Deno/Bun); all other results use the normal private-copy path:
|
|
1405
|
+
|
|
1406
|
+
```ts
|
|
1407
|
+
using pool = createPool({
|
|
1408
|
+
threads: 4,
|
|
1409
|
+
unsafe: { SharedBytes: false },
|
|
1410
|
+
})({ render });
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
Reach for it when results must stay valid for unbounded time, or to rule the
|
|
1414
|
+
path out while chasing a bug.
|
|
1415
|
+
|
|
1416
|
+
#### Constraints
|
|
1417
|
+
|
|
1418
|
+
- **Needs a `SharedArrayBuffer`.** That is the only requirement: no native
|
|
1419
|
+
addon, no FFI, and — unlike the pointer payloads — process workers are fine,
|
|
1420
|
+
because the arena is mapped in both processes.
|
|
1421
|
+
- **Bounded by the arena.** A lane has 64 region identities and grows to
|
|
1422
|
+
`payload.payloadMaxByteLength` (64 MiB by default). When either runs out,
|
|
1423
|
+
returns quietly take the copy path rather than growing without limit.
|
|
1424
|
+
- **Not a security boundary.** Like everything in `knitting/unsafe`, this hands
|
|
1425
|
+
the host a window into a buffer the worker writes. Do not use it as an
|
|
1426
|
+
isolation mechanism.
|
|
1427
|
+
- **A view outlives its worker.** The region lives in the payload arena, which
|
|
1428
|
+
the host holds a reference to, so a view read after the worker dies still
|
|
1429
|
+
returns the bytes that were there — it does not throw and does not read freed
|
|
1430
|
+
memory. It is a snapshot, not a live channel.
|
|
1431
|
+
|
|
1432
|
+
## Platform and native support
|
|
1207
1433
|
|
|
1208
1434
|
Knitting supports Node.js 22+, Deno 2+, and Bun 1+ on Linux, macOS, and Windows.
|
|
1209
1435
|
|
|
@@ -1241,42 +1467,6 @@ bun run build:native
|
|
|
1241
1467
|
For Deno projects with permissions enabled, allow FFI when using process workers
|
|
1242
1468
|
or `ProcessSharedBuffer`.
|
|
1243
1469
|
|
|
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
1470
|
## Benchmarks
|
|
1281
1471
|
|
|
1282
1472
|
```bash
|