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.
- package/Cargo.lock +28 -44
- package/Cargo.toml +21 -4
- package/README.md +8 -6
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +4460 -3648
- package/docs/API.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CASE_STUDY.md +4 -2
- package/docs/ENVIRONMENT.md +9 -2
- package/docs/FFI_BUN_GUIDE.md +166 -9
- package/docs/HOW_TO_ADD_AN_OP.md +34 -0
- package/docs/REPO_MAP.md +16 -5
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +5 -0
- package/package.json +172 -163
- package/rust/crypto/pbkdf2.rs +9 -0
- package/rust/crypto/session.rs +164 -7
- package/rust/ffi/crypto.rs +119 -29
- package/rust/ffi/mod.rs +1 -0
- package/rust/ffi/task.rs +172 -0
- package/rust/ffi/tests.rs +173 -0
- package/rust/http/form.rs +6 -2
- package/rust/http/query_parser.rs +53 -22
- package/rust/ingress/native_route.rs +131 -140
- package/rust/ingress/pipeline.rs +3 -3
- package/rust/ingress/tests.rs +5 -4
- package/rust/json/json_ser.rs +13 -11
- package/rust/lib.rs +1 -0
- package/rust/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -0
- package/rust/util/bytes.rs +238 -51
- package/src/ingress/health.ts +1 -2
- package/src/native/ffi/build/codecs.ts +29 -7
- package/src/native/ffi/build/compress.ts +8 -0
- package/src/native/ffi/build/instances.ts +54 -2
- package/src/native/ffi/build/metrics.ts +23 -7
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +18 -0
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +3 -1
- package/src/native/ffi/types.ts +57 -0
- package/src/native/ffi.ts +85 -14
- package/src/native/loader.ts +53 -3
- package/src/rust-ffi/scalar/http.ts +54 -16
- package/src/rust-ffi/text.ts +25 -3
- package/src/shared/bytes.ts +19 -0
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- 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.
|