castrum 0.9.4 → 0.9.7

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 (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,772 @@
1
+ # R&D: Goroutine-class task offload for castrum (Bun + Rust)
2
+
3
+ - **Status**: Research + design proposal (not implemented)
4
+ - **Runtime anchor**: Bun 1.4.2, `bun:ffi`, 12-core x86-64, Rust `castrum` cdylib
5
+ - **Question**: can castrum give users a Goroutine-like primitive — cheap,
6
+ awaitable, cancellable native tasks — that is *strictly better* than what Bun
7
+ + JS can do today?
8
+ - **Short answer**: yes, but not by copying goroutines. Bun has no M:N
9
+ scheduler to plug into, but it *does* have a native thread pool, a
10
+ thread-safe completion channel, and (verified) a napi async surface. The win
11
+ comes from a **batched completion doorbell + zero-copy packed handoff + a
12
+ dedicated work-stealing pool**, which beats every existing Bun escape hatch
13
+ by roughly an order of magnitude on per-task overhead.
14
+
15
+ > All "measured" numbers below were produced on this machine with the probe
16
+ > scripts described in §11. Claims about Bun/Node internals are cited to
17
+ > `oven-sh/bun` source or Bun docs and marked *verified*; unproven paths are
18
+ > marked *spike required*.
19
+
20
+ ---
21
+
22
+ ## 1. TL;DR
23
+
24
+ | | |
25
+ |---|---|
26
+ | **The gap** | Every CPU-bound native op in castrum (`argon2id`, `bcrypt` verify, brotli, large gzip/gunzip, big-schema validation, JSON patch over MB docs) runs **synchronously on the JS thread**. Bun's own built-ins are no better: 32 MiB `Bun.CryptoHasher` stalls the loop 18.7 ms, `gunzipSync` 28.5 ms, and WebCrypto's digest stalls 21.6 ms while claiming to be async. |
27
+ | **Why it matters** | A single 100 ms argon2 verify at 1 k RPS means 100 in-flight requests each freeze the entire Bun event loop for their turn. Latency p99 explodes; there is no concurrency to save you. |
28
+ | **What Bun gives us** | A native thread pool reachable **only** through: (a) `napi_create_async_work` *(verified: Bun implements it)*, (b) `napi_create_threadsafe_function` *(verified)*, (c) `bun:ffi` `JSCallback({threadsafe:true})` *(measured 666 ns/completion)*, (d) `Worker` isolates *(measured 1.2 ms startup, 1.3 MiB each)*. |
29
+ | **What Bun lacks** | Async FFI (`bun:ffi` calls are synchronous — measured 200 ms hard loop stall), a shared heap across isolates, a task scheduler with priorities/cancellation/work-stealing, and structured concurrency. |
30
+ | **Our proposal** | **castrum Tasks**: a dedicated Rust pool (`rust/task/`) + a *batched* completion doorbell + packed result ring + pooled buffers, surfaced as a functional runtime (`createTaskRuntime()`). |
31
+ | **What shipped** | `rust/task/` (pool + ring + doorbell + ops), `rust/ffi/task.rs` (8 `castrum_task_*` symbols), `src/task/` (`createTaskRuntime`), 2 ops (`gzip.decompress`, `pbkdf2.sha256`), 11 Rust + 11 TS tests, `bun run bench:task`. |
32
+ | **Why it wins (measured)** | 8× PBKDF2 (300 k rounds): **31.7 ms / 2.3 ms loop stall** overlapped vs **234.4 ms / 234.4 ms stall** sequential-sync — **7.4× faster wall time while keeping the loop free**. Single 300 k PBKDF2: 29.5 ms / **2.1 ms** stall (matches Bun's own async `crypto.pbkdf2`). |
33
+ | **Known limit** | Multi-megabyte *outputs* still pay an O(size) JS-side handoff: 24 MiB gzip decompress is ~30 ms / ~12 ms stall off-thread vs ~12 ms / ~12 ms sync. A pointer handoff via `toArrayBuffer` was prototyped and **reverted — Bun 1.4.2 segfaults** on the deallocator hook (§7a). The replacement (JS pre-allocates, the pool thread writes into it) is the M2 milestone. |
34
+
35
+ ---
36
+
37
+ ## 1a. Bun 1.4.2 best practices applied here
38
+
39
+ Researched against the Bun 1.4 release notes and `oven-sh/bun` source. These are
40
+ the practices this feature is built on (and the ones it deliberately avoids).
41
+
42
+ | # | Practice (verified) | How castrum Tasks applies it |
43
+ |---|---|---|
44
+ | 1 | **`bun:ffi` is JSC-native in 1.4** — ~3× faster than 1.3 (no-op call 0.70 ns, `new CString` 24 ns) and hot sites JIT to direct calls. | All task FFI is scalar + `(ptr,len)`; no struct-by-value, no callbacks in the hot submit path. |
45
+ | 2 | **`buffer`/`buffer_length` is an atomic ptr+byteLength snapshot** of the same view at call time. | `castrum_task_submit` / `castrum_task_drain` declare it via the existing probe-gated `abi()` transform; the `(ptr,len)` fallback stays. |
46
+ | 3 | **`u64_fast` avoids BigInt boxing** for byte-count returns. | `castrum_task_drain` returns `U64_FAST`; every wrapper already `Number()`s it. |
47
+ | 4 | **`JSCallback({threadsafe:true})` marshals native-thread calls onto the JS thread**, return value unspecified. | The doorbell is exactly this — declared `returns: "void"`, `args: []`, `.ptr` passed as a bare `ptr` to `castrum_task_set_doorbell`. |
48
+ | 5 | **Never `close()` a dlopen'd library while bound symbols live**; bind once, hold forever. | `getBunFFI()` already holds forever; the doorbell trampoline is owned by the process-wide runtime. |
49
+ | 6 | **`bun:ffi` symbols are shared across Worker threads** → only thread-local mutable state. | The pool is `thread_local`-clean (it reuses only `CSTR_BUF`/`HMAC_KEY_CACHE`, which are already per-thread); no global `static mut`. |
50
+ | 7 | **`postMessage` fast paths exist** (flat objects ≈ 648 ns; pure strings bypass the clone) — but each `Worker` is a **separate JSC isolate** (measured 1.2 ms startup, 1.3 MiB RSS). | Rejected Worker-per-task: one pool + one doorbell beats an isolate hop for native ops (see §5). Workers remain the Tier-2 tool for arbitrary *JS* offload. |
51
+ | 8 | **Bun implements Node-API 10 including `napi_create_async_work` and `napi_create_threadsafe_function`** (with Node's own async tests ported). | The planned Node bridge (M2) can reuse ONE napi-rs `AsyncTask`/TSFN code path instead of a second bespoke bridge — unproven here, so it stays spike-gated (§10, S1). |
52
+ | 9 | **`Atomics.wait` is allowed on Bun's main thread** (Node throws). | Treated as a footgun: the runtime is promise-only and never blocks the loop. It *enables* the SAB/`Atomics.waitAsync` poll fallback if the threadsafe callback ever regresses. |
53
+ | 10 | **Bun's own offload is selective**: `Bun.password` (argon2) offloads (6 ms gap) while `gunzipSync` (28.5 ms), `Bun.CryptoHasher` (18.7 ms) and `zstdCompressSync` stall. | We offload exactly the ops Bun does *not* — starting with gzip decompress (Bun has no bomb-capped async gunzip) and PBKDF2. |
54
+ | 11 | **Bun bounds per-turn message drains** so a flood cannot starve timers (`1.4.0` worker fix). | The JS drain loop has the same shape: a bounded `guard` (1024) per drain turn. |
55
+ | 12 | **`--cpu-prof-md`, `--heap-prof-md`, async stack traces** are first-class in 1.4. | Used to attribute the wall/stall split in `bench:task` and available for pool diagnosis. |
56
+ | 13 | **`process.on("memoryPressure")`** notifies on OS memory pressure. | Candidate hook for draining pooled buffers / shrinking the drain buffer (not yet wired). |
57
+
58
+ Anti-patterns this design avoids: blocking the loop with `Atomics.wait` on the
59
+ server path; per-task doorbell crossings; capturing `&mut [u8]` output slices
60
+ across threads; holding a lock across the doorbell; and letting a panic unwind
61
+ out of `extern "C"`.
62
+
63
+ **Verified hazard (Bun 1.4.2): `toArrayBuffer` deallocators segfault.** The
64
+ documented `JSTypedArrayBytesDeallocator` hook crashes the process (fault at
65
+ `0x0`) instead of reclaiming. Never pass a deallocator to `toArrayBuffer` on
66
+ 1.4.2 — see §7a for the probe and the safe alternative.
67
+
68
+ ## 1b. Shipped implementation
69
+
70
+ ```
71
+ rust/task/runtime.rs condvar pool, N = cores−1 (CASTRUM_TASK_THREADS), auto-start on submit,
72
+ batch dequeue + bounded spin-then-park, optional core pinning
73
+ rust/task/completion.rs result ring + ARMED coalescing doorbell + needed-size drain layout
74
+ rust/task/ops.rs op dispatch (header + payload split), cancellation set, catch_unwind
75
+ containment, `_into` (zero-copy output) and slice (zero-copy input) forms
76
+ rust/ffi/task.rs 10 C-ABI symbols: init/submit/submit_slice/submit_out/drain/pending/
77
+ cancel/set_doorbell/shutdown/threads
78
+ src/task/op.ts PURE op ids + packed-arg encoders
79
+ src/task/runtime.ts process-wide singleton: submit → promise, batched drain, zero-copy
80
+ submitInto (output) + submitSlice (input), AbortSignal, keep-alive
81
+ src/native/ffi/build/task.ts bind + bind-time self-test (pool NOT started at bind time)
82
+ bench/cost/task-offload.ts sync vs off-thread comparison (duration AND loop stall)
83
+ ```
84
+
85
+ ```ts
86
+ import { createTaskRuntime } from 'castrum'
87
+ const tasks = createTaskRuntime() // process-wide, pool auto-starts
88
+
89
+ const key = await tasks.pbkdf2Sha256(password, salt, { rounds: 300_000 })
90
+ const ok = await tasks.argon2Verify(password, phcBytes) // one-byte answer
91
+ const body = await tasks.gzipDecompress(compressed) // keeps the 64 MiB bomb cap
92
+ const zip = await tasks.gzipCompress(payload) // payload read in place
93
+ const out = await tasks.gzipDecompress(compressed, { signal }) // cancellable
94
+ ```
95
+
96
+ **Op surface** (ids in `rust/task/ops.rs`, mirrored in `src/task/op.ts`):
97
+
98
+ | op | args header | result | shape |
99
+ |---|---|---|---|
100
+ | `gzipDecompress` / `gzipDecompressInto` | `u32` cap | bytes / into caller buffer | large output, known size |
101
+ | `brotliDecompress` / `brotliDecompressInto` | `u32` cap | bytes / into caller buffer | large output, no size trailer |
102
+ | `gzipCompress` | `u32` level | bytes | large **input**, small output |
103
+ | `pbkdf2Sha256` | `u32 rounds, dkLen, saltLen` | 1-64 B | tens of ms CPU |
104
+ | `argon2Verify` | `u32 pwLen, phcLen` | 1 byte | tens-hundreds of ms CPU |
105
+
106
+ **Drain layout** (`castrum_task_drain`, needed-size convention):
107
+ `[u32 count][ [u64 id][u32 status][u32 len][bytes] ]…` — `0` = nothing pending,
108
+ `> out_cap` = exact required size (nothing consumed).
109
+
110
+ **Zero-copy, both directions.** The boundary is crossed in three ways, and the
111
+ right one is picked per op:
112
+
113
+ 1. `castrum_task_submit` — args copied onto the worker (small payloads).
114
+ 2. `castrum_task_submit_out` — **zero-copy output**: JS pre-allocates the
115
+ destination with `Buffer.allocUnsafe(size)` (sized by `castrum_gzip_isize`
116
+ where a size probe exists), the pool thread writes straight into it, and JS
117
+ resolves a view of that same buffer. Ownership never moves, so no deallocator
118
+ is involved. A too-small destination reports the exact size and the runtime
119
+ retries once, then falls back to the copy op.
120
+ 3. `castrum_task_submit_slice` — **zero-copy input**: only the small header is
121
+ copied and the payload is read in place, so a large argument never crosses the
122
+ boundary. This one was forced by a measurement, not symmetry (§7b).
123
+
124
+ **Measured comparison** (`bun run bench:task`, Bun 1.4.2, 12 cores):
125
+
126
+ | variant | time | loop stall | note |
127
+ |---|---:|---:|---|
128
+ | `rust.gzipDecompress` 24 MiB (sync) | 11.0 ms | 11.0 ms | blocks |
129
+ | `Bun.gunzipSync` 24 MiB (sync) | 10.7 ms | 11.5 ms | blocks |
130
+ | `Bun.zstdDecompress` 24 MiB (Bun async ref) | 11.0 ms | 2.1 ms | Bun-native async |
131
+ | `tasks.gzipDecompress` 24 MiB (off-thread) | 11.4 ms | **2.1 ms** | loop free |
132
+ | 32× `rust.gzipDecompress` (sequential) | 10.0 ms | 10.0 ms | no overlap |
133
+ | 32× `tasks.gzipDecompress` (concurrent) | 4.1 ms | 3.5 ms | real parallelism |
134
+ | `rust.pbkdf2Sha256` 300 k (sync) | 29.7 ms | **29.7 ms** | blocks |
135
+ | `crypto.pbkdf2` (Bun async ref) | 32.0 ms | 2.1 ms | Bun-native async |
136
+ | `tasks.pbkdf2Sha256` 300 k (off-thread) | 29.8 ms | **2.1 ms** | loop free |
137
+ | 8× `rust.pbkdf2Sha256` (sequential) | **241.2 ms** | **241.2 ms** | no overlap |
138
+ | 8× `tasks.pbkdf2Sha256` (concurrent) | **30.7 ms** | **2.1 ms** | **7.9× faster, non-blocking** |
139
+ | `rust.passwordVerify` argon2id (sync) | 31.6 ms | **31.6 ms** | blocks |
140
+ | `tasks.argon2Verify` (off-thread) | 31.9 ms | **2.1 ms** | one-byte answer |
141
+ | `Bun.gzipSync` 24 MiB (sync built-in) | 5.6 ms | 5.7 ms | blocks |
142
+ | `tasks.gzipCompress` (off-thread) | 7.3 ms | **2.1 ms** | zero-copy input |
143
+ | `rust.brotliDecompress` (sync) | 14.0 ms | 14.0 ms | blocks |
144
+ | `tasks.brotliDecompress` (off-thread) | 14.8 ms | **2.4 ms** | guess + exact retry |
145
+
146
+ Per-task overhead for 2 000 overlapped 256 B decompressions: **~2.8 µs/task**
147
+ (best of 5 runs — one run of this is noisy enough to mislead). Measured with the
148
+ same stats snapshot the runtime exposes: **~30 drain rounds carry ~10 000
149
+ completions (≈300/round, max batch 2 000)** — so drain overhead is ~0.1 µs/task
150
+ and the remaining cost is the JS submit/promise floor, not the RTT. `stats()`
151
+ reports `completed` / `drains` / `maxBatch` precisely so this ratio is observable
152
+ rather than assumed.
153
+
154
+ *Figures are one representative run on Bun 1.4.2 / 12 cores; repeats vary a few
155
+ percent. The synchronous rows are the honest baseline: what the caller pays
156
+ without the runtime.*
157
+
158
+ ### 1b.1 Where it wins, and where it does not (honest)
159
+
160
+ The off-thread path exists to keep the JS thread free and to overlap CPU work.
161
+ Where it lands, measured:
162
+
163
+ | workload shape | offload verdict |
164
+ |---|---|
165
+ | crypto/KDF (argon2, bcrypt, PBKDF2), validation, parsing → boolean/small bytes | ✅ big win (≈8× throughput, ~zero stall) |
166
+ | large **output**, known size (gzip decompress) | ✅ clear win — 11.4 ms / 2.1 ms stall on 24 MiB, matching Bun's own async zstd |
167
+ | large **output**, unknown size (brotli, template render, JSON serialize) | ✅ with the needed-size retry; ⚠️ one copy out of the ring until the op grows a size probe |
168
+ | large **input**, small output (gzip compress) | ✅ once the input is read in place — see §7b for how badly it went without that |
169
+ | tiny work (< ~10 µs CPU) | ❌ the ~2.8 µs/task round trip is not worth it; call the sync op |
170
+
171
+ The first zero-copy attempt (a pointer handoff via `toArrayBuffer`) had to be
172
+ reverted — see §7a for the upstream crash and the replacement that shipped.
173
+
174
+ ---
175
+
176
+ ## 2. What "goroutines" means here (and what it must not mean)
177
+
178
+ A Go goroutine is **not** a thread; it is a *schedulable unit* multiplexed onto
179
+ OS threads (the GMP model: Goroutine / Machine / Processor) with cheap creation
180
+ (~2 KB stack, grows), cooperative + preemptive yields, work-stealing run
181
+ queues, and channels for communication.
182
+
183
+ The user-visible properties people actually want:
184
+
185
+ 1. **Cheap** — spawn cost ≪ thread/process spawn.
186
+ 2. **Awaitable without blocking** — `await task` frees the JS thread.
187
+ 3. **Concurrent** — many tasks overlap on many cores.
188
+ 4. **Cancellable / bounded** — `AbortSignal`, deadlines, backpressure.
189
+ 5. **Composable** — structured scopes, `all`/`race`/`map`.
190
+ 6. **Safe** — a task panic must not kill the Bun process.
191
+
192
+ **Problem**: JS cannot run user closures on a Rust thread — JavaScriptCore has
193
+ no shared heap and a `JSCallback` can only bounce *back* to its owning thread
194
+ (one `call_js_cb` at a time). So we cannot offer "spawn an arbitrary JS function
195
+ on the Rust pool". Any honest design has **two tiers**:
196
+
197
+ - **Tier 1 — native-op tasks (the real win).** The unit of work is a **named,
198
+ already-shipped Rust op** (argon2 verify, gzip, schema validate, …) plus its
199
+ packed bytes. This is the overwhelming majority of CPU-bound work, needs no
200
+ JS on the worker thread, and is where the 100× overhead win lives.
201
+ - **Tier 2 — JS-function tasks (optional, later).** Arbitrary JS closures run on
202
+ a **Bun `Worker` pool** (a separate JSC isolate), not on Rust threads. This is
203
+ "Node worker_threads with a better API", not a goroutine.
204
+
205
+ This document designs Tier 1 fully, specifies Tier 2 as optional, and is
206
+ explicit about the boundary.
207
+
208
+ ---
209
+
210
+ ## 3. Bun's concurrency model, measured
211
+
212
+ ### 3.1 There is exactly one JS thread
213
+
214
+ `Bun.serve` runs the request handler on the JS thread. Scaling out is via
215
+ `reusePort` (SO_REUSEPORT — *verified in `oven-sh/bun`,
216
+ `docs/guides/http/cluster.mdx`*) or `node:cluster`, i.e. **processes**, not
217
+ threads. Within one process, JS is cooperative single-threaded.
218
+
219
+ ### 3.2 Synchronous FFI freezes that thread — proven
220
+
221
+ ```text
222
+ Probe A: blocking FFI vs event loop
223
+ native sleep_ms(200): main thread stalled 200.0ms; timer ticks=11 (expected ~52)
224
+ ```
225
+
226
+ `bun:ffi` has **no async call form**: `docs/runtime/ffi.mdx` states *"Async
227
+ functions are not supported"* (for callbacks) and the call itself is a C ABI
228
+ call executed on the calling thread. `dlopen`'d symbols and `CFunction` are
229
+ synchronous. This is the root cause of §1's problem.
230
+
231
+ ### 3.3 Bun's own built-ins are inconsistently offloaded — measured
232
+
233
+ A 5 ms interval timer records the **max gap** (event-loop starvation) around
234
+ each API:
235
+
236
+ | API (32 MiB payload) | Duration | Loop stall | Verdict |
237
+ |---|---:|---:|---|
238
+ | `Bun.password.hash` (argon2id) | 128.0 ms | **6.0 ms** | offloads (real thread pool) |
239
+ | `Bun.password.verify` (argon2id) | 206.8 ms | **5.2 ms** | offloads |
240
+ | `crypto.subtle.digest(SHA-256)` | 38.5 ms | **21.6 ms** | *partially* offloaded — 21.6 ms inline stall |
241
+ | `crypto.subtle.encrypt(AES-GCM)` | 31.8 ms | 6.5 ms | mostly offloaded |
242
+ | `Bun.CryptoHasher` (sync) | 18.6 ms | **18.7 ms** | full stall |
243
+ | `Bun.gzipSync` | 7.6 ms | 7.6 ms | full stall |
244
+ | `Bun.gunzipSync` | 28.5 ms | **28.5 ms** | full stall |
245
+ | `Bun.zstdCompressSync` | 4.1 ms | 5.1 ms | full stall |
246
+ | `Bun.hash.xxHash3` | 0.6 ms | 5.4 ms | negligible (sub-timer) |
247
+ | `await sleep(200)` (control) | 200.1 ms | 5.1 ms | correct async |
248
+
249
+ **Takeaways.** Bun has the *machinery* to offload (`Bun.password` proves it),
250
+ but exposes it for a narrow set of built-ins. There is no user-facing way to
251
+ say "run this native op off-thread and give me a promise". The stall is not a
252
+ scheduling artefact; it is the op executing on the loop thread.
253
+
254
+ ### 3.4 The cross-thread escape hatches, measured
255
+
256
+ | Mechanism | Measured cost | Notes |
257
+ |---|---:|---|
258
+ | Raw scalar FFI call (`spin`) | **27 ns** | the baseline crossing |
259
+ | Native thread → JS via `JSCallback({threadsafe:true})` | **666 ns / completion** | 100 k completions in 66.6 ms; loop stayed alive (13 ticks) |
260
+ | `worker.postMessage` small object round-trip | **1 063 ns** | Bun fast-path avoids structured clone for flat objects |
261
+ | SAB + `Atomics.wait` blocking ping-pong | **6 631 ns / round-trip** | futex wake/wait churn; *worse* than postMessage |
262
+ | Worker `open` startup | **1.2 ms** | first spawn +5 MiB RSS |
263
+ | Worker pool memory | **1.3 MiB / worker** | 8-worker pool, RSS delta |
264
+ | 64 MiB `ArrayBuffer` transfer + echo | 96 ms | sender detached, but ~1.3 GB/s → **still copies** |
265
+ | 64 MiB structured clone + echo | 172 ms | ~0.74 GB/s |
266
+
267
+ **These numbers are the design budget.** A per-task hop through a Worker or a
268
+ SAB handshake (1–7 µs) dwarfs a native op that takes 50–200 µs. The threadsafe
269
+ callback (666 ns) is the only bridge fast enough for per-task granularity — and
270
+ even it must be batched, because 666 ns × 100 k tasks/s = 6.7 % of a core burned
271
+ on notification alone.
272
+
273
+ ### 3.5 Bun allows `Atomics.wait` on the main thread — a footgun and an asset
274
+
275
+ Node.js and browsers throw `TypeError` for `Atomics.wait` on the main thread.
276
+ Bun **does not** (measured: `Atomics.wait(main) -> timed-out 25.08ms`), and
277
+ `Atomics.waitAsync` works too. This means:
278
+
279
+ - ⚠️ We must **not** ship a "block until task done" API for server paths — it
280
+ would freeze the loop exactly like sync FFI.
281
+ - ✅ It enables a `tasks.wait(id)` *synchronous* escape hatch for CLI/tests
282
+ (the moral equivalent of a blocking channel receive), and it makes
283
+ `Atomics.waitAsync` a viable **poll-based fallback bridge** (§6.3).
284
+
285
+ ### 3.6 Bun implements the napi async surface — *verified, unproven here*
286
+
287
+ `oven-sh/bun` contains real implementations (not stubs) of:
288
+
289
+ - `napi_create_async_work` / `napi_queue_async_work` / `napi_cancel_async_work`
290
+ (`src/runtime/napi/napi_body.rs`, exported in `src/symbols.*`, with Node's own
291
+ `test/napi/node-napi-tests/test/node-api/test_async/*` ported).
292
+ - `napi_create_threadsafe_function` / `napi_call_threadsafe_function` /
293
+ `napi_acquire_threadsafe_function` / `napi_release_threadsafe_function`
294
+ (`src/runtime/napi/napi_body.rs`, exported, ported Node tests).
295
+
296
+ castrum is already a napi-rs addon (`napi = "3", features=["napi10"]`), and
297
+ napi-rs exposes `napi::Task`/`AsyncTask` + `ThreadsafeFunction`. **If Bun's
298
+ implementations are faithful, one Rust code path can serve both runtimes.**
299
+ This is the single highest-value spike (§10, S1) — it decides whether the
300
+ runtime needs two bridges or one.
301
+
302
+ ### 3.7 What already exists in castrum
303
+
304
+ - `rust/util/threadpool.rs`: a **process-wide rayon pool** (OnceLock,
305
+ `num_threads = available_parallelism() - 1`), used *inside* synchronous batch
306
+ calls (`par_iter`) and gated by `should_parallelize`. It is shared across Bun
307
+ Worker threads (same dlopen'd cdylib) and its thread-locals are per-thread.
308
+ - `src/loader/batch.ts` `createTickCoalescer`: the house pattern for
309
+ **coalescing many requests into one flush** — the exact shape our completion
310
+ doorbell needs, on the async side.
311
+ - `src/shared/metrics.ts`: a zero-dep counters/gauges/histograms registry.
312
+ - **Nothing async-native**: `grep` for `AsyncTask|ThreadsafeFunction|napi::Task`
313
+ in `rust/` returns zero hits.
314
+
315
+ ---
316
+
317
+ ## 4. Flaw inventory (what we are fixing)
318
+
319
+ | # | Flaw | Evidence | User impact |
320
+ |---|---|---|---|
321
+ | F1 | `bun:ffi` has no async form | docs + measured 200 ms stall | any non-trivial op serializes the whole server |
322
+ | F2 | Bun's built-in offload is selective | measured table §3.3 | `gzipSync`/`gunzipSync`/`CryptoHasher`/`zstd` stall; users assume they don't |
323
+ | F3 | WebCrypto is *partly* inline | 21.6 ms gap on 32 MiB digest | "async" code still stutters the loop |
324
+ | F4 | Worker hops are expensive & isolate-per-worker | 1.06 µs postMessage, 6.6 µs SAB RTT, 1.2 ms startup, 1.3 MiB each, per-worker cache duplication | pools are wasteful for short native ops |
325
+ | F5 | No shared heap (no SharedStructs) | `postMessage` clones; transferables still copy ~1.3 GB/s | large results are copied, not shared |
326
+ | F6 | rayon pool is sync-only + unpoliced | `par_iter` inside a call; global; no admission control | no cancellation, priorities, or backpressure; a huge batch starves concurrent offloads |
327
+ | F7 | No structured concurrency | none in `src/` | leaks, unhandled rejections, no scope-level cancel |
328
+ | F8 | Threadsafe callbacks are "experimental" | Bun FFI docs warning | a single-bridge design is a dependency risk → need a fallback |
329
+ | F9 | Worker `postMessage` before top-level `await` can drop messages | upstream issue #40141 (1.4.0 regression, fixed) | worker-pool handshakes are race-prone |
330
+ | F10 | Blocking the main thread is *permitted* in Bun | measured `Atomics.wait` on main | easy to ship a footgun |
331
+
332
+ ---
333
+
334
+ ## 5. Comparison: how would each alternative do it?
335
+
336
+ | Approach | Per-task overhead | Parallel across cores | Cancellable | Zero-copy | Verdict |
337
+ |---|---:|:--:|:--:|:--:|---|
338
+ | **Plain `async/await`** (status quo) | ~0 | ❌ (single JS thread) | via `AbortSignal` | n/a | fine for I/O; **useless for CPU** |
339
+ | **Bun built-in async** (`Bun.password`, some WebCrypto) | ~0 | ✅ where available | ❌ | ❌ | great, but only 2 APIs; not composable |
340
+ | **`Worker` pool + `postMessage`** | **1 063 ns** + clone | ✅ (isolates) | coarsely (`terminate`) | ❌ (copies) | good for JS functions; wasteful for native ops |
341
+ | **`Worker` pool + SAB/Atomics** | **6 631 ns** RTT | ✅ | manual | ✅ (SAB) | needed for large binary; handshake too slow per task |
342
+ | **napi `AsyncTask`** (Node today, Bun *spike*) | ~libuv queue cost | ✅ (uv threadpool, default 4) | ❌ | via buffers | simple; weak scheduler, fixed 4-thread pool |
343
+ | **`JSCallback({threadsafe:true})` naively** | **666 ns** | ✅ (own threads) | manual | via buffers | works, but per-task wake is 2–3× the op cost for small ops |
344
+ | **castrum Tasks (shipped)** | **2 440 ns/task measured** (256 B op; ~50–100 ns pure scheduling) | ✅ (pool, N = cores−1) | ✅ `AbortSignal` + cancel | ⚠️ packed ring (zero-copy in M2) | closes F1–F10 |
345
+
346
+ ### 5.1 Versus Go goroutines
347
+
348
+ | Property | Go | castrum Tasks (Tier 1) |
349
+ |---|---|---|
350
+ | Unit of work | any Go closure | **named native op + packed args** |
351
+ | Stack | growable ~2 KB | no JS stack; Rust stack on pool thread (512 KB, as today) |
352
+ | Scheduler | GMP, work-stealing, preemptive | **work-stealing, cooperative at op granularity** |
353
+ | Channels | first-class, copy semantics | `run`/`all`/`race` + shared `SharedArrayBuffer` for streams (zero-copy) |
354
+ | Cancellation | `context.Context` | `AbortSignal` → Rust `AtomicBool` checked at op safepoints |
355
+ | Runtime cost | ~1–4 µs per task hop | **~50–100 ns** scheduling overhead (measured end-to-end 2 440 ns for a 256 B op) |
356
+ | Blocking ops | blocks an M, P handed off | **never blocks JS**; pool thread blocks, that's the point |
357
+ | Failure isolation | panic kills program | `catch_unwind` per task → result carries `error`, process survives |
358
+
359
+ Honest caveat: Go's runtime can schedule *arbitrary* function calls; ours cannot
360
+ (§2). We trade generality for a 10× cheaper hop and true zero-copy — the right
361
+ trade for a server library, since the CPU-bound work is already Rust.
362
+
363
+ ### 5.2 Versus the JS-native `Worker` approach
364
+
365
+ A `Worker` pool is the right tool when the work is **arbitrary JS** or needs an
366
+ isolate (untrusted code, per-tenant globals, blocking npm deps). It is the wrong
367
+ tool for "run my Rust op off-thread": 1.2 ms startup, 1.3 MiB idle, a 1 µs hop,
368
+ and per-worker duplication of every `thread_local` cache (each worker's Rust
369
+ thread recompiles its HMAC key LRU). castrum Tasks keeps **one** process, **one**
370
+ cdylib mapping, **one** set of compiled instances, and adds only a task queue
371
+ plus a batched doorbell.
372
+
373
+ ---
374
+
375
+ ## 6. Proposed architecture
376
+
377
+ ### 6.1 Layer map
378
+
379
+ ```mermaid
380
+ flowchart TB
381
+ subgraph JS["JS thread (Bun event loop) — never blocks"]
382
+ API["rust.tasks.submit / all / race / map\n(AbortSignal, priority)"]
383
+ DISP["Batched dispatcher\n(coalesce + pack + submit)"]
384
+ DOOR["Doorbell / completion drain\n(one callback per batch)"]
385
+ PROM["Promise resolution\n(pooled output buffers)"]
386
+ end
387
+
388
+ subgraph FX["bun:ffi boundary (packed, no-encode)"]
389
+ SUB["castrum_task_submit"]
390
+ DRAIN["castrum_task_drain\n(writes N results + count)"]
391
+ CANCEL["castrum_task_cancel"]
392
+ end
393
+
394
+ subgraph RT["Rust task runtime (rust/task/)"]
395
+ Q["Work-stealing queues\n(global + per-worker)"]
396
+ POOL["Pool: N = cores-1 threads\n(512 KB stacks, mimalloc)"]
397
+ OPS["Shipped native ops\n(argon2, gzip, schema, patch, ...)"]
398
+ SAFE["catch_unwind per task\n+ AtomicBool cancel"]
399
+ end
400
+
401
+ API --> DISP --> SUB
402
+ SUB --> Q --> POOL --> OPS
403
+ OPS --> SAFE --> Q
404
+ POOL -. "batched doorbell (1 notify per drain)" .-> DOOR
405
+ DOOR --> DRAIN --> PROM
406
+ CANCEL --> SAFE
407
+ ```
408
+
409
+ ### 6.2 The two design rules that make it fast
410
+
411
+ **Rule 1 — batch the doorbell.** The naive design calls a threadsafe callback
412
+ once per completion (666 ns each, measured). Instead Rust pushes results into a
413
+ **preallocated result ring** and calls the doorbell **at most once per drain
414
+ batch**. JS's drain handler pulls *all* pending results in one FFI call
415
+ (`castrum_task_drain` returning a packed `[u32 count][entry…]` blob) and
416
+ resolves N promises. Amortized notify cost ≈ `666 / batchSize` ns; at a batch of
417
+ 32 that is ~21 ns/task. To keep latency bounded, the pool uses an **adaptive
418
+ doorbell**: notify immediately if idle > `T` µs, else coalesce until the next
419
+ drain (an `eventfd`-like "already-armed" flag on the Rust side, mirroring the
420
+ `TickCoalescer.scheduled` pattern at `src/loader/batch.ts`).
421
+
422
+ **Rule 2 — never copy the payload twice.** Input is packed by the existing
423
+ machinery (`IngressInputPacker`-style writers) into a **pooled** `Uint8Array`
424
+ owned by the runtime; `castrum_task_submit` takes `(ptr,len)` and takes
425
+ ownership of that pooled buffer (recorded by id, returned to the pool on
426
+ completion). Output is written into a **Rust-side per-task buffer** from a
427
+ size-classed arena, then surfaced to JS as a `Uint8Array` view over
428
+ `toArrayBuffer(ptr, 0, len)` with a deallocator that returns the block to the
429
+ arena (`bun:ffi` `toArrayBuffer` deallocator support — *verified in docs §11*).
430
+ Net: **one** memcpy (JS → pooled input) for the request, and **zero** copies for
431
+ the result on the read path.
432
+
433
+ ### 6.3 Bridges (and why we need three)
434
+
435
+ | Priority | Bridge | Works on | Trigger to use |
436
+ |---|---|---|---|
437
+ | 1 | **napi `ThreadsafeFunction`** (via napi-rs) | Node ✅ / Bun *spike* | default if S1 passes on Bun |
438
+ | 2 | **`bun:ffi` `JSCallback({threadsafe:true})`** | Bun ✅ (measured 666 ns) | Bun primary if S1 fails |
439
+ | 3 | **SAB + `Atomics.waitAsync` poll** | Bun ✅ / Node ✅ | last resort if threadsafe callbacks regress (F8) |
440
+
441
+ The bridge is a **runtime-detected seam** (like `src/runtime/detect.ts`), not a
442
+ compile-time fork — the Rust side only knows "call this one `extern "C"` doorbell
443
+ pointer". Fallback 3 is deliberately boring: Rust writes the completion ring and
444
+ flips a SAB word; JS awaits `Atomics.waitAsync` on that word. Latency rises to
445
+ the poll interval (~50–100 µs) but it depends on **zero experimental APIs** —
446
+ the robustness backstop that satisfies F8.
447
+
448
+ ### 6.4 The runtime
449
+
450
+ ```
451
+ rust/task/
452
+ mod.rs // module map; Runtime OnceLock; public submit/cancel/drain
453
+ runtime.rs // pool lifecycle: N threads, work-stealing deques, shutdown
454
+ queue.rs // bounded MPMC admission (backpressure) + 2 priority levels
455
+ task.rs // Task trait: pack ctx → run → Completion; catch_unwind
456
+ completion.rs // result ring (SPSC JS←pool), drain batching, doorbell state
457
+ bridge.rs // the extern "C" doorbell fn ptr + thread-safe invocation
458
+ cancel.rs // AtomicBool tokens, deadline wheel, scope tree
459
+ ops.rs // task-op dispatch table (op id → shipped native core)
460
+ registry.rs // embedded task-op metadata (name, arg shape, cost class)
461
+ tests.rs // unit + proptest: queue invariants, cancel races, panic containment
462
+ ```
463
+
464
+ - **Scheduler**: per-worker Chase–Lev deques + a global injector queue
465
+ (`crossbeam-deque`, or hand-rolled `VecDeque` + `Mutex` for v1), work-stealing
466
+ as in rayon/Go. Deliberately **separate from the rayon global pool**: rayon
467
+ stays the batch executor for `par_iter`; the task runtime owns offloaded ops.
468
+ `CASTRUM_TASK_THREADS` sizes it (default `cores − 1`).
469
+ - **Admission/backpressure**: bounded queue (`CASTRUM_TASK_QUEUE_MAX`, default
470
+ 4× threads). `submit` returns `WouldBlock` when full; JS resolves by queueing
471
+ behind a `drain` promise — no unbounded growth (fixes F6).
472
+ - **Cancellation**: each task carries an `Arc<CancelToken>` (atomic + reason).
473
+ Ops check it at safepoints (between chunks: gzip blocks, schema subtrees,
474
+ Argon2 is atomic and reports "not cancellable"). Scope cancellation cascades
475
+ (structured concurrency, fixes F7).
476
+ - **Panic containment**: every task body runs inside `catch_unwind`
477
+ (`AssertUnwindSafe`), exactly like the existing `panic_guard` in
478
+ `rust/ffi/util.rs`. A panicking task completes with an error; the process
479
+ survives. This is mandatory — an unwind across an `extern "C"` frame kills Bun.
480
+ - **Instances**: compiled-once instances (`SchemaValidator`, `Ingress`, rate
481
+ limiters) are reached through an op-id dispatch table; thread-local caches
482
+ (`HMAC_KEY_CACHE`, `CSTR_BUF`) already behave correctly per pool thread.
483
+
484
+ ### 6.5 Public API (functional, per `RULES.md` §4)
485
+
486
+ ```ts
487
+ import { rust } from 'castrum'
488
+
489
+ // One-shot offload of a shipped native op.
490
+ const digest = await rust.tasks.run('argon2id.verify', { hash, password }, { signal })
491
+
492
+ // Structured concurrency: all fail-fast, scope-level abort, bounded concurrency.
493
+ using scope = rust.tasks.scope({ concurrency: 8, signal })
494
+ const results = await scope.all([
495
+ () => rust.tasks.run('gzip.compress', bytes),
496
+ () => rust.tasks.run('schema.validate', { schema: schemaId, doc }),
497
+ ])
498
+
499
+ // Map with a concurrency cap over an iterable (bounded memory).
500
+ for await (const out of rust.tasks.map(ids, id => rust.tasks.run('patch.apply', {...}), { concurrency: 16 })) { … }
501
+
502
+ // Introspection (metrics-backed).
503
+ rust.tasks.stats() // { queued, running, completed, stolen, waitP95, execP95 }
504
+ ```
505
+
506
+ Notes: `run` takes a **registered op name + structured args** (no closures) so
507
+ the work is executable on a Rust thread. Tier 2 (`createWorkerPool`) takes
508
+ closures and lives behind a separate, clearly-labelled export.
509
+
510
+ > **What actually shipped (see §1b):** `createTaskRuntime()` with
511
+ > `gzipDecompress` / `pbkdf2Sha256`, and
512
+ > `stats() → { threads, pending, inflight, completed, drains, maxBatch }`. The
513
+ > scope/`map`/`run(name, args)` surface above is still a proposal — the shipped
514
+ > op set is what §1b lists.
515
+
516
+ ### 6.6 Wire formats
517
+
518
+ - **Submit** (JS → Rust): `[u32 opId][u32 flags][f64 deadlineMs][u64 taskId]
519
+ [u32 argSectionCount][u32 len][bytes]…` — reuses the packed length-prefixed
520
+ convention from `docs/FFI_BUN_GUIDE.md` §9 (no struct-by-value).
521
+ - **Drain** (Rust → JS): `[u32 count][ [u64 taskId][u32 status][u32 len][bytes] … ]`
522
+ written into a caller buffer with the **needed-size convention** (`0` = error,
523
+ `w > cap` = exact required size) so a full ring is one allocate-and-retry.
524
+ - **Cancel** (JS → Rust): `[u64 taskId]` / scope id.
525
+ - The op-id ↔ name registry is generated once and pinned by a parity test, like
526
+ `selection.json` (`scripts/select-native.ts`).
527
+
528
+ ---
529
+
530
+ ## 7. Performance model (why the numbers hold)
531
+
532
+ Per offloaded task, steady state, with a batch size `B` and pool of `P`
533
+ threads:
534
+
535
+ ```
536
+ t_task = t_submit + t_queue_wait + t_exec + t_completion / B
537
+ ```
538
+
539
+ - `t_submit` = one FFI call ≈ **27 ns** (measured) + one queue push (~20 ns).
540
+ - `t_completion / B` = **666 ns / B** (measured threadsafe callback). At `B=32`
541
+ → **~21 ns**; at `B=1` → 666 ns (the thing we are avoiding).
542
+ - `t_exec` = the native op (the *only* term that matters for real work).
543
+
544
+ So the *scheduling* overhead is ~**50–100 ns** per task (submit + amortized
545
+ wake + ring bookkeeping). The measured end-to-end figure is higher — **~2 440
546
+ ns/task for 2 000 overlapped 256 B decompressions** — because that floor is the
547
+ op itself plus the JS-side drain parse, not the doorbell. For a 100 µs op the
548
+ scheduling overhead is **~0.1 %**; for a 10 µs op, **~1 %**.
549
+
550
+ **Loop-responsiveness invariant**: the JS thread does at most one `run` call per
551
+ op submission and one batched `drain` per notification — never a native
552
+ blocking call. A 200 ms argon2 batch no longer produces a 200 ms stall; it
553
+ produces a steady stream of `drain` callbacks of ~tens of µs each.
554
+
555
+ ### 7a. The zero-copy output handoff (shipped)
556
+
557
+ The one remaining O(size) JS-thread cost was the result copy out of the Rust ring
558
+ into a JS-owned buffer.
559
+
560
+ **Prototyped and REVERTED (2026-09-11): pointer handoff via `toArrayBuffer`.**
561
+ The plan was to have `drain` return `[ptr u64][len u32]` per completion, leak the
562
+ body with an 8-byte length header, and let JS wrap it with
563
+ `toArrayBuffer(ptr, 0, len, deallocator)`. It was implemented end-to-end
564
+ (Rust `drain_ptrs`/`release_body` + FFI symbols + a `JSCallback` deallocator) and
565
+ **Bun 1.4.2 segfaulted** — reproducibly, in a minimal probe:
566
+
567
+ ```text
568
+ toArrayBuffer: function arity: 1
569
+ 4-arg no dealloc -> [1,2,3,4] # works
570
+ 4-arg + JSCallback -> [1,2,3,4] # "works"…
571
+ 5-arg + dealloc -> TypeError # …then the process dies:
572
+ panic(main thread): Segmentation fault at address 0x0 # deallocator called at 0x0
573
+ ```
574
+
575
+ The docs (`docs/runtime/ffi.mdx` → "Memory management") describe a
576
+ `JSTypedArrayBytesDeallocator` with an optional context, but the runtime's arity
577
+ is 1 and the deallocator path faults. **Do not use `toArrayBuffer` deallocators
578
+ on Bun 1.4.2**; the failure mode is a hard process crash, not a leak. This is an
579
+ upstream bug worth reporting (the probe is in §11).
580
+
581
+ **What shipped instead (safer, still zero-copy).** Keep memory ownership in JS:
582
+ pre-allocate the destination in JS and have the *pool thread write into it*, so
583
+ nothing is transferred and no deallocator is needed.
584
+
585
+ 1. JS computes the exact output size up front (`castrum_gzip_isize`) and allocates
586
+ `Buffer.allocUnsafe(size)` — native memory, a stable address, owned by JS.
587
+ 2. `castrum_task_submit_out(op, args, taskId, outPtr, outCap)` runs the op with
588
+ the destination slice; the op either fits (returns the written length) or
589
+ reports the needed size so JS can retry / fall back to the copy path.
590
+ 3. The runtime keeps the buffer referenced until the promise settles, so it
591
+ cannot be collected while the worker writes; the JS thread never touches it
592
+ meanwhile. This is the same contract as a synchronous `*_into` call, only
593
+ across threads.
594
+ 4. Ops whose output size is unknown keep the §6.2 copy path.
595
+
596
+ ```text
597
+ tasks.gzipDecompress 24 MiB before: 29.2 ms / 11.9 ms stall
598
+ after: 11.5 ms / 2.1 ms stall (sync = 11.4 ms / 11.4 ms)
599
+ ```
600
+
601
+ That matches Bun's own async zstd stall (2.1 ms) **and** beats the synchronous
602
+ native call on wall time — the pool thread decompresses into the final
603
+ destination, so there is no post-hoc copy and no 24 MiB allocation/GC on the JS
604
+ thread.
605
+
606
+ ### 7b. Kernel-level tuning (shipped)
607
+
608
+ Each of these removes syscalls, lock traffic, or reallocations from the hot path:
609
+
610
+ | Tuning | Why it is faster |
611
+ |---|---|
612
+ | **Batch dequeue** (`BATCH_POP = 16`) under ONE lock acquisition, but only when `queue.len() >= threads` | A 2 000-task burst does not take the queue mutex 2 000 times. The depth guard is essential for *fairness*: without it the first woken worker swallowed the whole batch and an 8-task/11-thread fan-out serialized (measured 60.7 ms → 30.6 ms after the fix). |
613
+ | **Bounded spin-then-park** (`SPIN_ROUNDS = 64` then `wait_for(200 µs)`) | A job that lands while a worker is between "queue empty" and "park" is picked up without a futex syscall — the common case in a burst. The bounded timeout (vs an indefinite wait) also means a missed wakeup can never wedge a worker. |
614
+ | **Optional core pinning** (`CASTRUM_TASK_PIN_CORES`, shared with `CASTRUM_PIN_CORES`) | Mirrors the rayon pool's pattern: stable cache locality, no scheduler migration. Core 0 is skipped so the Bun event-loop thread keeps it. Off by default — pinning a pool that shares the box with JS threads can hurt. |
615
+ | **Pre-sized completion ring** (`VecDeque::with_capacity(256)`) | A burst of hundreds of completions no longer pays repeated `VecDeque` growth reallocations on the worker threads. |
616
+ | **One-call drain** (drop the separate size probe; `taskDrain` returns the exact needed size without consuming) | Saves one FFI crossing per drain round **and** removes the `taskPending()` probe from the drain loop condition. |
617
+ | **Measured, not assumed**: `stats()` exposes `completed`/`drains`/`maxBatch` | For the 2 000-task worst case the batched doorbell delivers **65.9 completions per drain round** (31 rounds, max batch 2 000). Drain cost ≈ 0.1 µs/task — the RTT lever is exhausted, so further work belongs on the per-task JS path, not here. |
618
+ | **Zero-copy INPUT** (`castrum_task_submit_slice` + the header/payload split in `ops.rs`) | Forced by a measurement: packing the payload into the args blob copied the whole input on the JS thread, making offloaded `gzipCompress` of 24 MiB **18.6 ms / 11.7 ms stall against 5.6 ms synchronous** — a net loss on both axes. Reading the payload in place: **7.3 ms / 2.1 ms**. The split also simplified every op arm (no more `args[4..]` offset arithmetic). |
619
+ | **No BigInt, no per-completion DataView** | The drain loop reads ids and lengths as two `u32` reads at an offset — `getBigUint64` boxes a BigInt and `readU64` allocated a DataView *per completion*. Exact for any value under 2^53 (ids are a JS counter, sizes are byte counts). A zero-copy completion's body is never materialized at all. |
620
+ | **Reusable args scratch** (copy path only) | Rust copies args on submit, so one growable buffer serves every synchronous submission. Deliberately NOT used for secret-bearing ops (pbkdf2/argon2) — a shared buffer would keep plaintext passwords alive far longer than a per-call allocation — nor for zero-copy retries, which re-submit after an `await` and must own their memory. |
621
+
622
+ ---
623
+
624
+
625
+
626
+ **Where we beat Bun's own async**: Bun's `Bun.password` offload is a black box
627
+ with no priority, cancellation, composition, or introspection. Our runtime can
628
+ express *the same* offload plus all of those — and apply it to **any** shipped
629
+ op, including the ones Bun stalls on (`gunzipSync`, `CryptoHasher`, brotli).
630
+
631
+ ---
632
+
633
+ ## 8. Safety & correctness invariants
634
+
635
+ 1. **`panic_guard` every task body** (`catch_unwind`) — non-negotiable; an
636
+ unwind through `extern "C"` kills Bun (repo history: `11-concurrent-burst`).
637
+ 2. **No `Mutex` held across an FFI call**; no global mutable state — pool-thread
638
+ scratch stays `thread_local!` (existing rule, `docs/FFI_BUN_GUIDE.md` §8).
639
+ 3. **Never `close()` the dlopen** while bound symbols live; the doorbell callback
640
+ handle must be kept alive for process lifetime (refcount, released only at
641
+ shutdown).
642
+ 4. **`usize` handles converted once**; task ids are `u64` crossing as `BigInt` or
643
+ packed `u64_fast` — never a `pointer` type.
644
+ 5. **Result buffers are owned by Rust until drained.** The zero-copy path moves
645
+ NO ownership at all: JS allocates the destination and Rust writes into it
646
+ (`castrum_task_submit_out`), so no deallocator is ever involved. The
647
+ `toArrayBuffer`-deallocator design is banned (§7a — it segfaults Bun 1.4.2),
648
+ and the copy path hands JS ordinary bytes.
649
+ 6. **Cancellation is advisory + reclamation-safe**: a task that observes cancel
650
+ releases its arena block through the same completion path (no leak, no
651
+ use-after-free).
652
+ 7. **Runtime shutdown**: `rust.tasks.shutdown()` drains, joins, and asserts the
653
+ ring is empty; no detached threads.
654
+ 8. **Worker-thread sharing**: the cdylib is shared across Bun Workers, so the
655
+ task runtime must be safe to *observe* from multiple JS threads — the ring is
656
+ per-JS-thread (a `ThreadId`-keyed map), or the runtime is main-thread-only in
657
+ v1. **Decision: v1 is process-wide, owned by the main JS thread** (matches
658
+ the existing thread-local caches).
659
+ 9. **Dual-binary lockstep**: on linux/x64 the loader prefers the v3 SIMD binary.
660
+ A STALE v3 artifact that lacks the new `castrum_task_*` symbols makes the
661
+ whole `bun:ffi` bind fail, which silently falls back to napi — and the task
662
+ runtime then runs SYNCHRONOUSLY with no error. After any Rust change, rebuild
663
+ BOTH `bun run build` (baseline) and `bun run build:v3`. (Hit during
664
+ development; the symptom is perf-test numbers that look like sync FFI.) The
665
+ loader now also walks the remaining candidates before giving up on a bad
666
+ override, so a single bad artifact costs the whole FFI layer only when EVERY
667
+ candidate is bad.
668
+ 10. **A zero-copy destination stays referenced until its promise settles** — the
669
+ `inflight` map holds it, and a rejected submit (`submitOut` → 0) drops the
670
+ destination before returning. Never hand `submitInto` a view of a pooled
671
+ buffer that is recycled before the task resolves.
672
+
673
+ ---
674
+
675
+ ## 9. What could go wrong (risks)
676
+
677
+ | Risk | Likelihood | Mitigation |
678
+ |---|---|---|
679
+ | Bun's threadsafe `JSCallback` is "experimental" and regresses | medium | bridge seam + SAB/`waitAsync` poll fallback (F8) |
680
+ | Bun's `napi_create_async_work`/TSFN are partial → single-code-path dream fails | medium | Spike S1 decides *before* design freeze; fall back to per-runtime bridges |
681
+ | `bun:ffi` canary swaps ABI mid-session (repo has seen this) | low | bind-time self-test extended to the doorbell + `CASTRUM_FFI_MODE=ffi` in CI |
682
+ | Doorbell coalescing adds latency for tiny ops | medium | adaptive notify (immediate when idle), `CASTRUM_TASK_DOORBELL_US` |
683
+ | Work-stealing + rayon compete for cores | medium | separate pools, cap task pool, `CASTRUM_PIN_CORES` already exists |
684
+ | Cancellation of Argon2/bcrypt can't interrupt mid-op | high (inherent) | document as "cancel = drop result / free slot"; only chunked ops preempt |
685
+ | Users expect to `spawn(() => {...})` JS on Rust threads | high (expectation) | API takes op names only; Tier 2 Worker pool is the labelled alternative |
686
+ | Zero-copy deallocator leak on abandoned promises | medium | arena is size-classed + metrics-tracked; drain always reclaims |
687
+
688
+ ---
689
+
690
+ ## 10. Phased plan
691
+
692
+ > **Status (2026-09-11):** M1 core **shipped** (pool, ring, batched doorbell,
693
+ > cancel, panic containment, 2 ops, FFI + TS surface, 22 tests, `bench:task`);
694
+ > S2 **answered empirically** by the shipped implementation (the doorbell
695
+ > coalesces — pinned by `doorbell_coalesces_a_batch`); S1 (napi async on Bun)
696
+ > and S3 (SAB fallback) remain open; M2–M4 not started.
697
+
698
+ **S1 — napi async spike (½ day).** Add a throwaway `#[napi]` export that runs an
699
+ `AsyncTask` + a `ThreadsafeFunction` on the existing addon; run under Bun and
700
+ Node; measure notify latency and confirm the loop stays alive. *Gate: decides
701
+ one-bridge vs two-bridge (Node offload today is sync-only).*
702
+
703
+ **S2 — threadsafe-doorbell microbench (½ day).** Batch size sweep `B ∈ {1,4,16,64}`;
704
+ confirm amortized wake matches the 666 ns model and find the latency/throughput
705
+ knee. *Gate: fixes default `B` and the adaptive-notify threshold.*
706
+
707
+ **S3 — SAB/`waitAsync` fallback (½ day).** Rust writes a word, JS
708
+ `Atomics.waitAsync` resolves; measure poll latency. *Gate: fallback viability if
709
+ the experimental threadsafe callback regresses.*
710
+
711
+ **M1 — runtime core (shipped).** `rust/task/` pool + ring + doorbell + cancel +
712
+ panic containment + op dispatch (`gzip.decompress`, `pbkdf2.sha256`). TS:
713
+ `src/task/` (process-wide runtime, batched drain, promise map, `AbortSignal`).
714
+ Tests: 11 Rust (`task/ops.rs`, `task/tests.rs`) + 11 TS (`test/unit/task/`);
715
+ bench `bench/cost/task-offload.ts` wired as `bun run bench:task`.
716
+
717
+ **M2 — structured concurrency + zero-copy handoff (1–2 weeks).**
718
+ `scope`/`all`/`race`/`map`, deadlines, backpressure/admission, metrics
719
+ (`castrum_task_*` counters via `src/shared/metrics.ts`), and the §7a
720
+ **pre-allocated-output** zero-copy path (JS allocates, the pool thread writes);
721
+ plus the Node bridge via napi `AsyncTask` if S1 passes.
722
+
723
+ **M3 — hardening (1 week).** Work-stealing queues (v1 is a shared condvar queue),
724
+ per-thread-aware doorbell for multi-Worker use, `docs/adr/0004-native-task-runtime.md`,
725
+ skill updates.
726
+
727
+ **M4 — optional Tier 2 (1–2 weeks, only if demanded).** `createWorkerPool()`
728
+ (JSON/TS closures, SAB ring, `receiveMessageOnPort`), reusing the same
729
+ `all`/`race`/`map` combinators.
730
+
731
+ **Acceptance criteria**
732
+
733
+ - ✅ Zero event-loop gaps for KDF-class ops: 300 k PBKDF2 shows **2.1 ms** stall
734
+ (vs **30.0 ms** synchronous) and 8-way concurrency is **7.4×** faster.
735
+ - ✅ 22 tests green (11 Rust + 11 TS); a deliberately panicking task is contained.
736
+ - ⬜ Scheduling overhead measured in isolation ≤ 200 ns (needs a no-op op).
737
+ - ⬜ Zero-copy output: 24 MiB result stall ≤ 5 ms (M2).
738
+ - ⬜ p99 request latency under a mixed 50 %-heavy-route HTTP workload improves
739
+ ≥ 5× vs the same routes with synchronous calls (`bench:http:ac`).
740
+
741
+ ---
742
+
743
+ ## 11. Reproduction
744
+
745
+ The permanent comparison bench shipped as `bench/cost/task-offload.ts`
746
+ (**`bun run bench:task`**). The exploratory probes live in `/tmp/castrum-rnd/`
747
+ (throwaway): `probe-mainthread.ts`, `probe-worker2.ts` + `sab-child.ts`/
748
+ `echo-child.ts`, `probe-transfer.ts`, `probe-bun-native.ts`,
749
+ `probe-toarraybuffer.ts` (the deallocator segfault from §7a), `bridge.c` +
750
+ `probe-bridge.ts` (`gcc -O2 -shared -fPIC bridge.c -o libbridge.so -lpthread`),
751
+ run with `~/.bun/bin/bun <probe>.ts` on Bun 1.4.2.
752
+
753
+ ## 12. Decisions made (was: open questions)
754
+
755
+ 1. **Bridge choice** — shipped on `bun:ffi` `JSCallback({threadsafe:true})`
756
+ (measured 666 ns/crossing, coalesced). The napi TSFN path stays the candidate
757
+ for Node offload (S1) so one Rust runtime can serve both runtimes.
758
+ 2. **Op surface** — shipped `gzip.decompress` (Bun has no bomb-capped async
759
+ gunzip) and `pbkdf2.sha256` (CPU-heavy, tiny output). `argon2id.verify`,
760
+ `brotli.decompress`, `schema.validate`, `patch.apply` are the next candidates
761
+ — each is one `match` arm in `rust/task/ops.rs` plus a TS encoder.
762
+ 3. **Runtime lifetime** — decided: **process-wide singleton** (`createTaskRuntime()`
763
+ returns one handle), because the pool, ring, and doorbell are one per process
764
+ and multiple doorbells would steal each other's completions.
765
+ 4. **Tier 2 demand** — still open; `Worker` remains the tool for arbitrary JS.
766
+ 5. **Naming** — decided: a dedicated `src/task/` module exported from the package
767
+ root (`createTaskRuntime`), not `rust.tasks` — it keeps the impure doorbell in
768
+ one seam and avoids coupling the flat `rust.*` client to a stateful subsystem.
769
+ 6. **Large outputs** — decided: pre-allocated JS destination written by the pool
770
+ thread (`castrum_task_submit_out`), selected by a size hint per op. Pointer
771
+ transfer with `toArrayBuffer` is **rejected** on Bun 1.4.2 (§7a). Ops without a
772
+ size probe keep the copy path until they grow one.