knitting 0.1.62 → 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.
Files changed (84) hide show
  1. package/README.md +525 -335
  2. package/knitting.browser.js +1 -1
  3. package/map.md +0 -6
  4. package/package.json +3 -3
  5. package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
  6. package/prebuilds/darwin-arm64-node-127/knitting_doorbell.node +0 -0
  7. package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
  8. package/prebuilds/darwin-arm64-node-137/knitting_doorbell.node +0 -0
  9. package/prebuilds/darwin-x64-node-127/knitting_buffer_pointer.node +0 -0
  10. package/prebuilds/darwin-x64-node-127/knitting_doorbell.node +0 -0
  11. package/prebuilds/darwin-x64-node-137/knitting_buffer_pointer.node +0 -0
  12. package/prebuilds/darwin-x64-node-137/knitting_doorbell.node +0 -0
  13. package/prebuilds/linux-x64-node-127/knitting_buffer_pointer.node +0 -0
  14. package/prebuilds/linux-x64-node-127/knitting_doorbell.node +0 -0
  15. package/prebuilds/linux-x64-node-137/knitting_buffer_pointer.node +0 -0
  16. package/prebuilds/linux-x64-node-137/knitting_doorbell.node +0 -0
  17. package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
  18. package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
  19. package/prebuilds/win32-x64-node-127/knitting_doorbell.node +0 -0
  20. package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
  21. package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
  22. package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
  23. package/prebuilds/win32-x64-node-137/knitting_doorbell.node +0 -0
  24. package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
  25. package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
  26. package/scripts/build-native-addons.ts +5 -0
  27. package/shared-memory.d.ts +3 -0
  28. package/shared-memory.js +3 -0
  29. package/src/api.js +109 -42
  30. package/src/common/with-resolvers.js +2 -5
  31. package/src/common/worker-runtime.d.ts +7 -0
  32. package/src/common/worker-runtime.js +7 -0
  33. package/src/connections/buffer-reference.d.ts +10 -36
  34. package/src/connections/buffer-reference.js +15 -170
  35. package/src/connections/node-addons.d.ts +1 -1
  36. package/src/connections/shared-array-buffer-payload.d.ts +7 -0
  37. package/src/connections/shared-array-buffer-payload.js +27 -11
  38. package/src/ipc/transport/shared-memory.d.ts +9 -1
  39. package/src/ipc/transport/shared-memory.js +13 -1
  40. package/src/knitting_buffer_pointer.cc +57 -2
  41. package/src/knitting_doorbell.cc +220 -0
  42. package/src/memory/knitting-body.d.ts +44 -0
  43. package/src/memory/knitting-body.js +51 -0
  44. package/src/memory/knitting-buffer-http.d.ts +116 -0
  45. package/src/memory/knitting-buffer-http.js +255 -0
  46. package/src/memory/knitting-buffer.d.ts +250 -0
  47. package/src/memory/knitting-buffer.js +695 -0
  48. package/src/memory/lazy-region-registry.d.ts +83 -0
  49. package/src/memory/lazy-region-registry.js +355 -0
  50. package/src/memory/lock.d.ts +38 -15
  51. package/src/memory/lock.js +227 -109
  52. package/src/memory/payloadCodec.d.ts +18 -2
  53. package/src/memory/payloadCodec.js +309 -65
  54. package/src/memory/regionRegistry.d.ts +6 -0
  55. package/src/memory/regionRegistry.js +125 -240
  56. package/src/memory/shared-buffer-io.d.ts +7 -0
  57. package/src/memory/shared-buffer-io.js +34 -8
  58. package/src/runtime/deno-doorbell.d.ts +26 -0
  59. package/src/runtime/deno-doorbell.js +117 -0
  60. package/src/runtime/dispatcher.d.ts +8 -6
  61. package/src/runtime/dispatcher.js +80 -58
  62. package/src/runtime/host-arg-arena.d.ts +3 -0
  63. package/src/runtime/host-arg-arena.js +16 -0
  64. package/src/runtime/node-doorbell.d.ts +14 -0
  65. package/src/runtime/node-doorbell.js +84 -0
  66. package/src/runtime/pool.d.ts +27 -15
  67. package/src/runtime/pool.js +138 -116
  68. package/src/runtime/process-worker.d.ts +9 -0
  69. package/src/runtime/process-worker.js +22 -2
  70. package/src/runtime/tx-queue.d.ts +2 -5
  71. package/src/runtime/tx-queue.js +52 -48
  72. package/src/runtime/worker-common.d.ts +2 -1
  73. package/src/runtime/worker-common.js +19 -5
  74. package/src/types.d.ts +36 -70
  75. package/src/worker/loop.js +95 -62
  76. package/src/worker/rx-queue.d.ts +2 -3
  77. package/src/worker/rx-queue.js +34 -40
  78. package/src/worker/shared-return.d.ts +9 -0
  79. package/src/worker/shared-return.js +22 -0
  80. package/src/worker/task-loader.js +1 -2
  81. package/src/worker/timers.d.ts +2 -6
  82. package/src/worker/timers.js +14 -19
  83. package/unsafe.d.ts +2 -1
  84. 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
- TypeScript 5.2+ can compile this pattern for runtimes that do not parse `using`
154
- syntax directly. Use `await pool.shutdown()` when you need to wait for shutdown
155
- or pass a shutdown delay.
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 that cannot preserve fd 0. |
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. The one
441
- important detail is that process workers receive their shared-memory handle on
442
- stdin, which is file descriptor 0. Wrappers that leave stdin alone usually work;
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 memory for the
594
- process-worker control channel. You do not need to set
595
- `processSharedMemory: "named"` yourself — the runtime detects Windows and forces
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
- ## Payloads
873
+ ## Runtime Safety
801
874
 
802
- Worker calls can carry the following values across the shared-memory transport:
875
+ Knitting aims to make the safer path the default:
803
876
 
804
- - `string`, `number`, `boolean`, `bigint`, `null`, and `undefined`.
805
- - Plain objects and arrays made from supported values.
806
- - `ArrayBuffer`, Node `Buffer`, `DataView`, and supported typed arrays.
807
- - `ProcessSharedBuffer`.
808
- - `BufferReference` from `knitting/unsafe` for experimental zero-copy buffers to
809
- thread workers (same process only; see below).
810
- - `Envelope` for a JSON header plus a binary body (`ArrayBuffer`,
811
- `SharedArrayBuffer`, `ProcessSharedBuffer`, or `BufferReference`).
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
- If it isn't on that list, assume it isn't portable. Some things don't (or
818
- shouldn't) cross the boundary:
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
- - DOM objects and platform handles.
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
- ### Envelope
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
- `Envelope` pairs a JSON-serializable header with a binary body. Use it when a
831
- call needs both structured metadata and raw bytes in a single argument — the
832
- transport carries one special binary value per call, so an envelope is the way
833
- to attach a header to one.
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, Envelope, isMain, task } from "knitting";
909
+ import { createPool, isMain, setModuleUrl, task } from "knitting/browser";
837
910
 
838
- export const processImage = task<
839
- Envelope<{ format: string }>,
840
- Envelope<{ width: number; height: number }>
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: 2 })({ processImage });
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
- try {
853
- const buffer = new ArrayBuffer(1024);
854
- const result = await pool.call.processImage(
855
- new Envelope({ format: "png" }, buffer),
856
- );
857
- console.log(result.header); // { width: 800, height: 600 }
858
- } finally {
859
- await pool.shutdown();
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
- #### Body types
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 body is generic — `Envelope<Header, Body>` — and accepts any of the binary
867
- shapes the transport understands:
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
- | Body | Copy? | Workers | Notes |
870
- | --------------------- | ----------------- | ---------------- | ------------------------------------------------------------------- |
871
- | `ArrayBuffer` | copied | thread + process | The default body; works everywhere. |
872
- | `SharedArrayBuffer` | zero-copy, shared | thread only | Shared by reference; process workers reject it. |
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
- The header keeps its fast paths regardless of the body: a small header is
877
- written inline, and only large headers spill to the dynamic payload region. A
878
- zero-copy body keeps its own semantics — a `SharedArrayBuffer` stays shared by
879
- reference, and a `BufferReference` body is still moved (its source is detached)
880
- and joins the same borrow/copy/release flow it follows on its own.
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 { createPool, Envelope, isMain, task } from "knitting";
884
- import { BufferReference } from "knitting/unsafe";
980
+ import { createKnittingAllocator } from "knitting/shared-memory";
885
981
 
886
- export const invert = task<
887
- Envelope<{ op: string }, BufferReference>,
888
- Envelope<{ op: string }, BufferReference>
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
- if (isMain) {
899
- using pool = createPool({ threads: 1 })({ invert });
900
- const pixels = new Uint8Array([0, 64, 128, 192, 255]);
987
+ using body = await allocator.allocOrRefer(request, {
988
+ referenceAboveBytes: 2 * 1024 * 1024,
989
+ maxByteLength: 8 * 1024 * 1024,
990
+ });
901
991
 
902
- using result = await pool.call.invert(
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
- `Envelope` is disposable: disposing it (via `using` or `Symbol.dispose`)
910
- disposes a disposable body such as a `BufferReference`, and is a harmless no-op
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
- If a payload is large, set `payload.maxPayloadBytes` deliberately and prefer
917
- binary/shared-memory shapes over deeply nested objects.
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
- ## Shared Memory Channels
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
- ### Experimental zero-copy buffers for thread workers
1092
-
1093
- `BufferReference` lives in `knitting/unsafe`. It is experimental and may be
1094
- changed or removed if its safety tradeoffs are not acceptable. It **moves** a
1095
- buffer's ownership to a **thread** worker: constructing one detaches the source,
1096
- so the bytes travel to the worker without being serialized through the
1097
- transport. Send a result back the same way — return a `BufferReference` from the
1098
- worker. It is the same-process counterpart to `ProcessSharedBuffer`: reach for
1099
- it when you hold a large `ArrayBuffer` or typed array and the copy cost to a
1100
- thread worker actually matters.
1101
-
1102
- The ownership and revocation paths are hardened against ordinary
1103
- use-after-free: sources are detached on move, aliases are detached before a
1104
- borrow is released, and outstanding borrowed returns are revoked during pool
1105
- shutdown. This is still an **unsafe capability**, not a memory-safety or
1106
- security boundary. Do not accept `BufferReferenceMetadata` or raw pointer data
1107
- from untrusted code, and do not mutate the same bytes concurrently without
1108
- your own synchronization.
1109
-
1110
- | Path | Node 22/24 owning addon | Node 26 FFI | Deno/Bun FFI |
1111
- | --- | --- | --- | --- |
1112
- | Forward input | Moved, zero-copy | Moved, zero-copy alias | Moved, zero-copy alias |
1113
- | Default returned reference | Co-owned, zero-copy | One safe copy | One safe copy |
1114
- | `BufferReferenceReturn: "borrow"` | Revocable borrow | Revocable borrow | Revocable borrow |
1115
- | Worker shutdown | Outstanding borrows revoked | Copy while readable, then revoke | Copy while readable, then revoke |
1116
-
1117
- Older Node addons without the owning primitives use the non-owning fallback and
1118
- therefore follow the copy/borrow rules rather than the owning-addon row.
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 { BufferReference } from "knitting/unsafe";
1289
+ import { sharedBytes } from "knitting/unsafe";
1123
1290
 
1124
- export const invert = task<BufferReference, BufferReference>({
1125
- f: (ref) => {
1126
- const pixels = ref.toUint8Array(); // the moved bytes, no copy
1127
- const out = new Uint8Array(pixels.length);
1128
- for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i];
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
- const pixels = new Uint8Array([0, 64, 128, 192, 255]);
1135
- using pool = createPool({ threads: 1 })({ invert });
1136
-
1137
- // `pixels` is detached by the move; the result comes back as a BufferReference.
1138
- const result = await pool.call.invert(new BufferReference(pixels));
1139
- console.log([...result.toUint8Array()]); // [255, 191, 127, 63, 0]
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
- Read these constraints before reaching for it:
1144
-
1145
- - **Thread workers only.** The handle is a process-local pointer. Process
1146
- workers do not share it, so a `BufferReference` sent to a process worker
1147
- throws. For cross-process sharing use `ProcessSharedBuffer`.
1148
- - **ArrayBuffer only.** `SharedArrayBuffer` is already shareable and cannot be
1149
- detached, so `BufferReference` rejects SAB sources and SAB-backed typed-array
1150
- views.
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
- using pool = createPool({
1187
- threads: 1,
1188
- unsafe: {
1189
- BufferReferenceReturn: "borrow",
1190
- },
1191
- })({ invert });
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
- using result = await pool.call.invert(new BufferReference(pixels));
1195
- const out = result.toUint8Array(); // borrowed — valid only while `result` lives
1196
- console.log([...out]);
1197
- } // `using` releases the borrow here; `out` is detached and reads as empty now
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
- When in doubt, a plain `ArrayBuffer` or typed-array payload — which knitting
1201
- copies through the shared transport — is simpler and works for both thread and
1202
- process workers. Reach for `BufferReference` only when the copy cost of a large
1203
- buffer to a thread worker actually matters: below roughly 256 KiB the per-call
1204
- pointer setup tends to cost more than just copying, so the plain transport wins.
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
- ### Current support
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