castrum 0.9.3 → 0.9.6
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 +2242 -0
- package/Cargo.toml +155 -0
- package/README.md +14 -6
- package/build.rs +5 -0
- 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 +1508 -1291
- package/dist/index.js +3391 -2069
- package/docs/API.md +2 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +5 -3
- package/docs/ENVIRONMENT.md +25 -2
- package/docs/FFI_BUN_GUIDE.md +242 -14
- package/docs/HOW_TO_ADD_AN_OP.md +10 -0
- package/docs/REPO_MAP.md +17 -6
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +24 -25
- package/package.json +173 -156
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +105 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +69 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/task.rs +172 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +136 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +211 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +770 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +567 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +883 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +73 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -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/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +594 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/health.ts +1 -2
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +130 -12
- package/src/native/ffi/build/compress.ts +22 -10
- package/src/native/ffi/build/instances.ts +46 -4
- package/src/native/ffi/build/metrics.ts +247 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +32 -1
- package/src/native/ffi/build.ts +4 -0
- package/src/native/ffi/selftest.ts +12 -3
- package/src/native/ffi/types.ts +151 -9
- package/src/native/ffi.ts +160 -15
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +55 -5
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +71 -27
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/rust-ffi/text.ts +25 -3
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +24 -4
- package/src/shared/codec.ts +18 -3
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- package/src/task/runtime.ts +568 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// src/task/runtime.ts — Bun-first off-thread task runtime (impure boundary).
|
|
2
|
+
//
|
|
3
|
+
// Submits a named native op to the Rust task pool and resolves a JS promise
|
|
4
|
+
// when it finishes. The JS thread NEVER blocks: the pool pushes completions
|
|
5
|
+
// into a native ring and rings ONE thread-safe doorbell per batch, so a burst
|
|
6
|
+
// of tasks costs one crossing instead of one per task.
|
|
7
|
+
//
|
|
8
|
+
// Bun is the primary transport (`bun:ffi` + `JSCallback({threadsafe:true})`).
|
|
9
|
+
// On Node — or when `CASTRUM_FFI_MODE=napi` disables the ffi layer — the op
|
|
10
|
+
// runs synchronously on the caller thread; true Node offload needs the napi
|
|
11
|
+
// `AsyncTask` bridge (see docs/RND-CONCURRENCY.md, milestone M2).
|
|
12
|
+
//
|
|
13
|
+
// Side effects (doorbell registration, pooled drain buffer, in-flight map) are
|
|
14
|
+
// deliberately contained to this module, matching the repo's purity policy.
|
|
15
|
+
|
|
16
|
+
import { decodeUtf8 } from '../shared/codec'
|
|
17
|
+
import { isBun } from '../shared/runtime'
|
|
18
|
+
import { getBunFFI } from '../native/ffi'
|
|
19
|
+
import {
|
|
20
|
+
TASK_OP,
|
|
21
|
+
TASK_STATUS_CANCELLED,
|
|
22
|
+
TASK_STATUS_OK,
|
|
23
|
+
TASK_STATUS_TOO_SMALL,
|
|
24
|
+
TASK_STATUS_UNSUPPORTED,
|
|
25
|
+
brotliCapacityGuess,
|
|
26
|
+
encodeArgon2VerifyArgs,
|
|
27
|
+
encodeBrotliDecompressArgs,
|
|
28
|
+
encodeGzipCompressHeader,
|
|
29
|
+
encodeGzipDecompressArgs,
|
|
30
|
+
encodePbkdf2Args,
|
|
31
|
+
} from './op'
|
|
32
|
+
|
|
33
|
+
/** Per-call options for an offloaded task. */
|
|
34
|
+
export interface TaskRunOptions {
|
|
35
|
+
/** Abort the task; it completes with a cancelled error. */
|
|
36
|
+
signal?: AbortSignal
|
|
37
|
+
/** Decompress ops: output cap in bytes (default native 64 MiB bomb cap). */
|
|
38
|
+
maxDecompressed?: number
|
|
39
|
+
/** `gzipCompress` only: deflate level `0`-`9` (default `6`). */
|
|
40
|
+
level?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options for {@link TaskRuntime.pbkdf2Sha256}. */
|
|
44
|
+
export interface Pbkdf2RunOptions extends TaskRunOptions {
|
|
45
|
+
/** Iteration count (clamped to >= 1 natively). */
|
|
46
|
+
rounds: number
|
|
47
|
+
/** Derived-key length in bytes (default 32). */
|
|
48
|
+
dkLen?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runtime introspection snapshot. */
|
|
52
|
+
export interface TaskStats {
|
|
53
|
+
/** Pool worker count (0 when the pool has not started). */
|
|
54
|
+
threads: number
|
|
55
|
+
/** Finished-but-undrained completions in the native ring. */
|
|
56
|
+
pending: number
|
|
57
|
+
/** JS promises currently awaiting a completion. */
|
|
58
|
+
inflight: number
|
|
59
|
+
/** Completions resolved since the runtime was created. */
|
|
60
|
+
completed: number
|
|
61
|
+
/** Drain rounds run on the JS thread (one per doorbell, plus coalesced work). */
|
|
62
|
+
drains: number
|
|
63
|
+
/** Largest number of completions carried by a single drain round. */
|
|
64
|
+
maxBatch: number
|
|
65
|
+
/** Zero-copy attempts that had to retry at the exact needed size. */
|
|
66
|
+
tooSmallRetries: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Off-thread task runtime handle. */
|
|
70
|
+
export interface TaskRuntime {
|
|
71
|
+
/** gzip-decompress `data` on a pool thread (keeps the 64 MiB bomb cap). */
|
|
72
|
+
gzipDecompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
|
|
73
|
+
/** brotli-decompress `data` on a pool thread (keeps the 64 MiB bomb cap). */
|
|
74
|
+
brotliDecompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
|
|
75
|
+
/** gzip-compress `data` on a pool thread. */
|
|
76
|
+
gzipCompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
|
|
77
|
+
/** Verify a password against a PHC string on a pool thread (10-200 ms CPU). */
|
|
78
|
+
argon2Verify(
|
|
79
|
+
password: Uint8Array,
|
|
80
|
+
phc: Uint8Array,
|
|
81
|
+
options?: TaskRunOptions,
|
|
82
|
+
): Promise<boolean>
|
|
83
|
+
/** PBKDF2-HMAC-SHA256 on a pool thread (10-200 ms CPU, 1-64 B output). */
|
|
84
|
+
pbkdf2Sha256(
|
|
85
|
+
password: Uint8Array,
|
|
86
|
+
salt: Uint8Array,
|
|
87
|
+
options: Pbkdf2RunOptions,
|
|
88
|
+
): Promise<Uint8Array>
|
|
89
|
+
/** Pool / ring / in-flight counters. */
|
|
90
|
+
stats(): TaskStats
|
|
91
|
+
/** Cancel everything in flight, stop the pool, release the doorbell. */
|
|
92
|
+
shutdown(): void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Options for {@link createTaskRuntime}. */
|
|
96
|
+
export interface TaskRuntimeOptions {
|
|
97
|
+
/** Pool worker count; `0`/omitted → native default (cores − 1). */
|
|
98
|
+
threads?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Deferred {
|
|
102
|
+
/** Set for zero-copy tasks: the JS-owned destination the pool thread writes. */
|
|
103
|
+
out: Uint8Array | null
|
|
104
|
+
/**
|
|
105
|
+
* Set for zero-copy-INPUT tasks: the payload the pool thread reads in place.
|
|
106
|
+
* Holding it here is what keeps it alive (and un-moved) until the completion
|
|
107
|
+
* drains — without it the GC could free it under a running worker.
|
|
108
|
+
*/
|
|
109
|
+
keep: Uint8Array | null
|
|
110
|
+
resolve: (value: Uint8Array) => void
|
|
111
|
+
reject: (reason: unknown) => void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a little-endian u64 at `off` as a JS number, without allocating.
|
|
116
|
+
* `getBigUint64` would box a BigInt per call; two u32 reads are exact for any
|
|
117
|
+
* value below 2^53, which covers ids (a JS counter) and byte counts.
|
|
118
|
+
*/
|
|
119
|
+
function readU64At(view: DataView, off: number): number {
|
|
120
|
+
return view.getUint32(off, true) + view.getUint32(off + 4, true) * 4294967296
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Materialize a completion body out of the drain buffer. A pooled buffer is
|
|
125
|
+
* reused, so its body must be COPIED; a fresh large buffer is not reused, so a
|
|
126
|
+
* view into it is free.
|
|
127
|
+
*/
|
|
128
|
+
function bodyOf(buf: Uint8Array, off: number, len: number, pooled: boolean): Uint8Array {
|
|
129
|
+
return pooled ? buf.slice(off, off + len) : buf.subarray(off, off + len)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The zero-copy destination was too small; `needed` is the exact size. */
|
|
133
|
+
type TooSmallError = Error & { needed: number }
|
|
134
|
+
|
|
135
|
+
function tooSmallError(needed: number): TooSmallError {
|
|
136
|
+
const err = new Error('task output buffer too small') as TooSmallError
|
|
137
|
+
err.name = 'TaskTooSmallError'
|
|
138
|
+
err.needed = needed
|
|
139
|
+
return err
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isTooSmall(err: unknown): err is TooSmallError {
|
|
143
|
+
return err instanceof Error && err.name === 'TaskTooSmallError'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The op has no zero-copy form (older addon) — use the copy path. */
|
|
147
|
+
function unsupportedError(): Error {
|
|
148
|
+
const err = new Error('task op has no zero-copy form')
|
|
149
|
+
err.name = 'TaskUnsupportedError'
|
|
150
|
+
return err
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cancelledError(): Error {
|
|
154
|
+
const err = new Error('task cancelled')
|
|
155
|
+
err.name = 'TaskCancelledError'
|
|
156
|
+
return err
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
|
|
160
|
+
interface Doorbell {
|
|
161
|
+
ptr: number | null
|
|
162
|
+
close(): void
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Process-wide singleton. The native pool, the completion ring, and the
|
|
167
|
+
* doorbell are ONE per process, so multiple runtimes would each register a
|
|
168
|
+
* doorbell and drain — and drop — each other's completions. `options` applies
|
|
169
|
+
* to the first call only; later calls return the same handle.
|
|
170
|
+
*/
|
|
171
|
+
let shared: TaskRuntime | null = null
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get the off-thread task runtime (created on first use, then shared). Binds
|
|
175
|
+
* the Bun ffi surface lazily.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* const tasks = createTaskRuntime()
|
|
180
|
+
* const out = await tasks.gzipDecompress(compressed)
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime {
|
|
184
|
+
if (shared) return shared
|
|
185
|
+
const boundFfi = isBun() ? getBunFFI() : null
|
|
186
|
+
|
|
187
|
+
if (!boundFfi) {
|
|
188
|
+
// Node / napi fallback: synchronous on the caller thread. Imported lazily
|
|
189
|
+
// so the Bun path never pulls the napi client into the hot module graph.
|
|
190
|
+
const fallback: TaskRuntime = {
|
|
191
|
+
async gzipDecompress(data, runOptions) {
|
|
192
|
+
const { rust } = await import('../rust-ffi')
|
|
193
|
+
return rust.gzipDecompress(data, runOptions?.maxDecompressed)
|
|
194
|
+
},
|
|
195
|
+
async pbkdf2Sha256(password, salt, runOptions) {
|
|
196
|
+
const { rust } = await import('../rust-ffi')
|
|
197
|
+
return rust.pbkdf2Sha256(password, salt, runOptions.rounds, runOptions.dkLen ?? 32)
|
|
198
|
+
},
|
|
199
|
+
async brotliDecompress(data, runOptions) {
|
|
200
|
+
const { rust } = await import('../rust-ffi')
|
|
201
|
+
return rust.brotliDecompress(data, runOptions?.maxDecompressed ?? null)
|
|
202
|
+
},
|
|
203
|
+
async gzipCompress(data, runOptions) {
|
|
204
|
+
const { rust } = await import('../rust-ffi')
|
|
205
|
+
return rust.gzipCompress(data, runOptions?.level ?? null)
|
|
206
|
+
},
|
|
207
|
+
async argon2Verify(password, phc) {
|
|
208
|
+
const { rust } = await import('../rust-ffi')
|
|
209
|
+
return rust.passwordVerify(password, phc)
|
|
210
|
+
},
|
|
211
|
+
stats: () => ({
|
|
212
|
+
threads: 0,
|
|
213
|
+
pending: 0,
|
|
214
|
+
inflight: 0,
|
|
215
|
+
completed: 0,
|
|
216
|
+
drains: 0,
|
|
217
|
+
maxBatch: 0,
|
|
218
|
+
tooSmallRetries: 0,
|
|
219
|
+
}),
|
|
220
|
+
shutdown: () => {},
|
|
221
|
+
}
|
|
222
|
+
shared = fallback
|
|
223
|
+
return fallback
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Bind to a non-null local so the nested closures keep the narrowing.
|
|
227
|
+
const ffi = boundFfi
|
|
228
|
+
ffi.taskInit(options.threads ?? 0)
|
|
229
|
+
|
|
230
|
+
const inflight = new Map<number, Deferred>()
|
|
231
|
+
let nextId = 1
|
|
232
|
+
let drainBuf = new Uint8Array(64 * 1024)
|
|
233
|
+
// A batch needing more than this grows the pooled buffer; anything larger
|
|
234
|
+
// gets a fresh exact-sized buffer handed out as subarray views (one JS-side
|
|
235
|
+
// copy instead of two + less GC churn).
|
|
236
|
+
const MAX_POOLED_DRAIN = 256 * 1024
|
|
237
|
+
// Outputs at least this big use the zero-copy (write-into-JS-buffer) path —
|
|
238
|
+
// below it the pooled copy path is cheaper than allocating a destination.
|
|
239
|
+
const ZERO_COPY_MIN = 64 * 1024
|
|
240
|
+
let scheduled = false
|
|
241
|
+
// Reusable packed-args destination for the SYNCHRONOUS copy path: native
|
|
242
|
+
// copies args into the job on submit, so one buffer serves every submission
|
|
243
|
+
// and the hot path allocates nothing per task. Deliberately NOT used for
|
|
244
|
+
// secret-bearing ops (pbkdf2/argon2) — a shared buffer would keep plaintext
|
|
245
|
+
// passwords alive far longer than a per-call allocation — nor for zero-copy
|
|
246
|
+
// args, which are re-submitted after an await and must own their memory.
|
|
247
|
+
let argScratch = new Uint8Array(256)
|
|
248
|
+
function scratch(need: number): Uint8Array {
|
|
249
|
+
if (argScratch.length < need) argScratch = new Uint8Array(need)
|
|
250
|
+
return argScratch
|
|
251
|
+
}
|
|
252
|
+
// Drain-side counters, exposed through `stats()`. They are what tells you
|
|
253
|
+
// whether a workload is RTT-bound (many tiny drains) or pool-bound (few big
|
|
254
|
+
// drains): `completed / drains` is the coalescing factor actually achieved.
|
|
255
|
+
let drains = 0
|
|
256
|
+
let completed = 0
|
|
257
|
+
let maxBatch = 0
|
|
258
|
+
let tooSmallRetries = 0
|
|
259
|
+
let doorbell: Doorbell | null = null
|
|
260
|
+
let keepAlive: ReturnType<typeof setInterval> | null = null
|
|
261
|
+
|
|
262
|
+
// Native pool threads are invisible to Bun's event loop, so an `await`ed task
|
|
263
|
+
// would not keep a CLI process alive on its own. Hold a ref'd interval while
|
|
264
|
+
// anything is in flight; it doubles as a polling safety net if a doorbell
|
|
265
|
+
// ever fails to arrive.
|
|
266
|
+
function retain(): void {
|
|
267
|
+
if (keepAlive === null) {
|
|
268
|
+
keepAlive = setInterval(() => {
|
|
269
|
+
if (inflight.size === 0) release()
|
|
270
|
+
}, 1000)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function release(): void {
|
|
274
|
+
if (keepAlive !== null) {
|
|
275
|
+
clearInterval(keepAlive)
|
|
276
|
+
keepAlive = null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function drainNow(): void {
|
|
281
|
+
scheduled = false
|
|
282
|
+
let guard = 0
|
|
283
|
+
// `taskDrain` returns the exact needed size, or 0/1 when nothing is pending,
|
|
284
|
+
// so the loop condition needs no separate `taskPending` probe — one fewer FFI
|
|
285
|
+
// crossing per drain round.
|
|
286
|
+
while (guard++ < 1024) {
|
|
287
|
+
// The ring buffer is only reused for batches that fit; a grow-initiated
|
|
288
|
+
// buffer must NOT persist across iterations, because the previous
|
|
289
|
+
// iteration's `subarray` bodies still reference it.
|
|
290
|
+
let buf: Uint8Array = drainBuf
|
|
291
|
+
let pooled = true
|
|
292
|
+
// `taskDrain` follows the needed-size convention: a buffer that is too
|
|
293
|
+
// small reports the exact required byte count WITHOUT consuming anything.
|
|
294
|
+
// So this single call covers both the common (fits) and grow cases — there
|
|
295
|
+
// is no separate probe round trip, and therefore one fewer FFI crossing
|
|
296
|
+
// per drain.
|
|
297
|
+
let written = ffi.taskDrain(buf)
|
|
298
|
+
if (written < 4) break
|
|
299
|
+
if (written > buf.length) {
|
|
300
|
+
// Too small: re-run into a bigger buffer. A modest batch grows the
|
|
301
|
+
// pooled buffer (so later drains are copy-only); a large one gets a
|
|
302
|
+
// fresh buffer and is handed out as `subarray` views, never reused.
|
|
303
|
+
if (written <= MAX_POOLED_DRAIN) {
|
|
304
|
+
drainBuf = new Uint8Array(written)
|
|
305
|
+
buf = drainBuf
|
|
306
|
+
pooled = true
|
|
307
|
+
} else {
|
|
308
|
+
buf = new Uint8Array(written)
|
|
309
|
+
pooled = false
|
|
310
|
+
}
|
|
311
|
+
written = ffi.taskDrain(buf)
|
|
312
|
+
if (written < 4) break
|
|
313
|
+
if (written > buf.length) continue // grew again — retry
|
|
314
|
+
}
|
|
315
|
+
const view = new DataView(buf.buffer, buf.byteOffset, written)
|
|
316
|
+
const count = view.getUint32(0, true)
|
|
317
|
+
drains++
|
|
318
|
+
if (count > maxBatch) maxBatch = count
|
|
319
|
+
let off = 4
|
|
320
|
+
for (let i = 0; i < count; i++) {
|
|
321
|
+
// Header reads come straight out of the drain view — no BigInt boxing
|
|
322
|
+
// and no per-completion DataView.
|
|
323
|
+
const id = readU64At(view, off)
|
|
324
|
+
const status = view.getUint32(off + 8, true)
|
|
325
|
+
const len = view.getUint32(off + 12, true)
|
|
326
|
+
const bodyOff = off + 16
|
|
327
|
+
off = bodyOff + len
|
|
328
|
+
const deferred = inflight.get(id)
|
|
329
|
+
if (!deferred) continue
|
|
330
|
+
inflight.delete(id)
|
|
331
|
+
if (status === TASK_STATUS_OK) {
|
|
332
|
+
// Zero-copy tasks complete with the written length in the body; the
|
|
333
|
+
// result itself is already in the buffer JS handed us, so no body
|
|
334
|
+
// bytes are touched at all. The copy path hands the payload over.
|
|
335
|
+
deferred.resolve(
|
|
336
|
+
deferred.out
|
|
337
|
+
? deferred.out.subarray(0, len >= 8 ? readU64At(view, bodyOff) : len)
|
|
338
|
+
: bodyOf(buf, bodyOff, len, pooled),
|
|
339
|
+
)
|
|
340
|
+
} else if (status === TASK_STATUS_TOO_SMALL) {
|
|
341
|
+
tooSmallRetries++
|
|
342
|
+
deferred.reject(tooSmallError(len >= 8 ? readU64At(view, bodyOff) : 0))
|
|
343
|
+
} else if (status === TASK_STATUS_UNSUPPORTED) {
|
|
344
|
+
deferred.reject(unsupportedError())
|
|
345
|
+
} else if (status === TASK_STATUS_CANCELLED) {
|
|
346
|
+
deferred.reject(cancelledError())
|
|
347
|
+
} else {
|
|
348
|
+
deferred.reject(
|
|
349
|
+
new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'),
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
completed++
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (inflight.size === 0) release()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function schedule(): void {
|
|
359
|
+
if (scheduled) return
|
|
360
|
+
scheduled = true
|
|
361
|
+
queueMicrotask(drainNow)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function submit(
|
|
365
|
+
op: number,
|
|
366
|
+
args: Uint8Array,
|
|
367
|
+
runOptions?: TaskRunOptions,
|
|
368
|
+
): Promise<Uint8Array> {
|
|
369
|
+
const id = nextId++
|
|
370
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
371
|
+
inflight.set(id, { out: null, keep: null, resolve, reject })
|
|
372
|
+
retain()
|
|
373
|
+
if (ffi.taskSubmit(op, args, id) === 0) {
|
|
374
|
+
inflight.delete(id)
|
|
375
|
+
release()
|
|
376
|
+
reject(new Error('task rejected by the native runtime'))
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const signal = runOptions?.signal
|
|
380
|
+
if (signal) {
|
|
381
|
+
if (signal.aborted) {
|
|
382
|
+
ffi.taskCancel(id)
|
|
383
|
+
} else {
|
|
384
|
+
signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Zero-copy submit: the op writes its result directly into `output` on a pool
|
|
392
|
+
* thread (the buffer is held by this runtime until the completion drains, so
|
|
393
|
+
* it cannot be collected or moved). Resolves a view of `output` — no copy.
|
|
394
|
+
*/
|
|
395
|
+
function submitInto(
|
|
396
|
+
op: number,
|
|
397
|
+
args: Uint8Array,
|
|
398
|
+
output: Uint8Array,
|
|
399
|
+
runOptions?: TaskRunOptions,
|
|
400
|
+
): Promise<Uint8Array> {
|
|
401
|
+
const id = nextId++
|
|
402
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
403
|
+
inflight.set(id, { out: output, keep: null, resolve, reject })
|
|
404
|
+
retain()
|
|
405
|
+
if (ffi.taskSubmitOut(op, args, id, output) === 0) {
|
|
406
|
+
inflight.delete(id)
|
|
407
|
+
release()
|
|
408
|
+
reject(new Error('task rejected by the native runtime'))
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
const signal = runOptions?.signal
|
|
412
|
+
if (signal) {
|
|
413
|
+
if (signal.aborted) {
|
|
414
|
+
ffi.taskCancel(id)
|
|
415
|
+
} else {
|
|
416
|
+
signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Zero-copy-INPUT submit: only the small `hdr` is copied; the pool thread
|
|
424
|
+
* reads `data` in place. The runtime holds `data` until the completion
|
|
425
|
+
* drains, so the caller may drop its own reference immediately — but must
|
|
426
|
+
* NOT mutate it or hand over a view of a recycled pooled buffer.
|
|
427
|
+
*/
|
|
428
|
+
function submitSlice(
|
|
429
|
+
op: number,
|
|
430
|
+
hdr: Uint8Array,
|
|
431
|
+
data: Uint8Array,
|
|
432
|
+
runOptions?: TaskRunOptions,
|
|
433
|
+
): Promise<Uint8Array> {
|
|
434
|
+
const id = nextId++
|
|
435
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
436
|
+
inflight.set(id, { out: null, keep: data, resolve, reject })
|
|
437
|
+
retain()
|
|
438
|
+
if (ffi.taskSubmitSlice(op, hdr, data, id) === 0) {
|
|
439
|
+
inflight.delete(id)
|
|
440
|
+
release()
|
|
441
|
+
reject(new Error('task rejected by the native runtime'))
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
const signal = runOptions?.signal
|
|
445
|
+
if (signal) {
|
|
446
|
+
if (signal.aborted) {
|
|
447
|
+
ffi.taskCancel(id)
|
|
448
|
+
} else {
|
|
449
|
+
signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// One process-wide trampoline; Bun marshals calls from pool threads onto the
|
|
456
|
+
// JS thread. Rust coalesces so a burst rings it once.
|
|
457
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
458
|
+
const { JSCallback } = require('bun:ffi') as typeof import('bun:ffi')
|
|
459
|
+
const cb = new JSCallback(
|
|
460
|
+
() => {
|
|
461
|
+
drainNow()
|
|
462
|
+
},
|
|
463
|
+
{ args: [], returns: 'void', threadsafe: true } as unknown as ConstructorParameters<
|
|
464
|
+
typeof JSCallback
|
|
465
|
+
>[1],
|
|
466
|
+
)
|
|
467
|
+
doorbell = cb
|
|
468
|
+
ffi.taskSetDoorbell(Number(cb.ptr))
|
|
469
|
+
|
|
470
|
+
// A zero-copy result handoff via `toArrayBuffer(ptr, 0, len, dealloc)` was
|
|
471
|
+
// prototyped here and REVERTED: Bun 1.4.2's deallocator hook segfaults
|
|
472
|
+
// (`docs/runtime/ffi.mdx` does not match the implementation). Zero-copy is
|
|
473
|
+
// instead done by pre-allocating the destination in JS and having the pool
|
|
474
|
+
// thread write into it (see docs/RND-CONCURRENCY.md §7a). If a completion
|
|
475
|
+
// landed between bind and registration, drain it now.
|
|
476
|
+
schedule()
|
|
477
|
+
|
|
478
|
+
const runtime: TaskRuntime = {
|
|
479
|
+
gzipDecompress(data, runOptions) {
|
|
480
|
+
// Pre-size from the gzip ISIZE trailer; unknown (0) → copy path.
|
|
481
|
+
const size = ffi.gzipIsize(data)
|
|
482
|
+
if (size < ZERO_COPY_MIN) {
|
|
483
|
+
// Copy path: consumed synchronously, so the scratch is safe to reuse.
|
|
484
|
+
return submit(
|
|
485
|
+
TASK_OP.gzipDecompress,
|
|
486
|
+
encodeGzipDecompressArgs(data, runOptions?.maxDecompressed, scratch(4 + data.length)),
|
|
487
|
+
runOptions,
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
// Zero-copy: the pool thread writes straight into a JS-owned buffer. On
|
|
491
|
+
// TOO_SMALL retry once with the exact size; on anything else (including
|
|
492
|
+
// UNSUPPORTED from an older addon) fall back, so behavior can't regress.
|
|
493
|
+
// Args are re-submitted on the retry step, so they own their memory.
|
|
494
|
+
const args = encodeGzipDecompressArgs(data, runOptions?.maxDecompressed)
|
|
495
|
+
const copyPath = (): Promise<Uint8Array> =>
|
|
496
|
+
submit(TASK_OP.gzipDecompress, args, runOptions)
|
|
497
|
+
const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
|
|
498
|
+
submitInto(TASK_OP.gzipDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
|
|
499
|
+
return zeroCopyOnce(size)
|
|
500
|
+
.catch((err: unknown) => (isTooSmall(err) ? zeroCopyOnce(err.needed) : copyPath()))
|
|
501
|
+
.catch(() => copyPath())
|
|
502
|
+
},
|
|
503
|
+
pbkdf2Sha256(password, salt, runOptions) {
|
|
504
|
+
return submit(
|
|
505
|
+
TASK_OP.pbkdf2Sha256,
|
|
506
|
+
encodePbkdf2Args(password, salt, runOptions.rounds, runOptions.dkLen),
|
|
507
|
+
runOptions,
|
|
508
|
+
)
|
|
509
|
+
},
|
|
510
|
+
gzipCompress(data, runOptions) {
|
|
511
|
+
// Zero-copy INPUT: the payload is read in place, so compressing a large
|
|
512
|
+
// buffer no longer pays an O(input) copy on the JS thread (which measured
|
|
513
|
+
// 3x the wall time of the synchronous built-in AND still stalled). The
|
|
514
|
+
// 4-byte header is copied by Rust, so the shared scratch is safe.
|
|
515
|
+
return submitSlice(
|
|
516
|
+
TASK_OP.gzipCompress,
|
|
517
|
+
encodeGzipCompressHeader(runOptions?.level ?? 6, scratch(4)),
|
|
518
|
+
data,
|
|
519
|
+
runOptions,
|
|
520
|
+
)
|
|
521
|
+
},
|
|
522
|
+
brotliDecompress(data, runOptions) {
|
|
523
|
+
const args = encodeBrotliDecompressArgs(data, runOptions?.maxDecompressed)
|
|
524
|
+
const copyPath = (): Promise<Uint8Array> =>
|
|
525
|
+
submit(TASK_OP.brotliDecompress, args, runOptions)
|
|
526
|
+
// Brotli has no ISIZE trailer, so there is no exact size hint: start from
|
|
527
|
+
// a ratio guess and let the needed-size retry land it exactly (at most
|
|
528
|
+
// one extra pass; on anything but TOO_SMALL, fall back to the copy op).
|
|
529
|
+
const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
|
|
530
|
+
submitInto(TASK_OP.brotliDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
|
|
531
|
+
return zeroCopyOnce(brotliCapacityGuess(data.length))
|
|
532
|
+
.catch((err: unknown) => (isTooSmall(err) ? zeroCopyOnce(err.needed) : copyPath()))
|
|
533
|
+
.catch(() => copyPath())
|
|
534
|
+
},
|
|
535
|
+
async argon2Verify(password, phc, runOptions) {
|
|
536
|
+
const out = await submit(
|
|
537
|
+
TASK_OP.argon2Verify,
|
|
538
|
+
encodeArgon2VerifyArgs(password, phc),
|
|
539
|
+
runOptions,
|
|
540
|
+
)
|
|
541
|
+
// The whole point of offloading argon2: a one-bit answer for tens of ms
|
|
542
|
+
// of CPU. A malformed PHC string is a `false`, never an error.
|
|
543
|
+
return out[0] === 1
|
|
544
|
+
},
|
|
545
|
+
stats: () => ({
|
|
546
|
+
threads: ffi.taskThreads(),
|
|
547
|
+
pending: ffi.taskPending(),
|
|
548
|
+
inflight: inflight.size,
|
|
549
|
+
completed,
|
|
550
|
+
drains,
|
|
551
|
+
maxBatch,
|
|
552
|
+
tooSmallRetries,
|
|
553
|
+
}),
|
|
554
|
+
shutdown() {
|
|
555
|
+
for (const [id, deferred] of inflight) {
|
|
556
|
+
ffi.taskCancel(id)
|
|
557
|
+
deferred.reject(cancelledError())
|
|
558
|
+
}
|
|
559
|
+
inflight.clear()
|
|
560
|
+
ffi.taskShutdown()
|
|
561
|
+
release()
|
|
562
|
+
doorbell?.close()
|
|
563
|
+
doorbell = null
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
shared = runtime
|
|
567
|
+
return runtime
|
|
568
|
+
}
|