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,79 @@
|
|
|
1
|
+
// src/native/ffi/build/task.ts — off-thread task runtime BunFFI methods.
|
|
2
|
+
//
|
|
3
|
+
// The thinnest possible binding: `rust/task/` owns the pool, the result ring,
|
|
4
|
+
// and the batched doorbell. This file only adapts the `(ptr,len)` ABI pair
|
|
5
|
+
// (probe-gated `buffer`/`buffer_length` vs explicit lengths) and converts the
|
|
6
|
+
// `u64_fast` byte counts to numbers.
|
|
7
|
+
//
|
|
8
|
+
// See docs/RND-CONCURRENCY.md for the design and the measured costs.
|
|
9
|
+
|
|
10
|
+
import type { BunFFI, Raw0, Raw2, Raw4, Raw6 } from '../types'
|
|
11
|
+
import type { BuildCtx } from './util'
|
|
12
|
+
|
|
13
|
+
/** Raw 1-arg C-ABI symbol signature (a lone scalar arg or a bare pointer). */
|
|
14
|
+
type Raw1 = (a: unknown) => number | bigint
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the off-thread task-runtime methods of the BunFFI surface. `ctx`
|
|
18
|
+
* supplies the probe-gated length adapter (`lenOrView`).
|
|
19
|
+
*/
|
|
20
|
+
export function buildTask(
|
|
21
|
+
sym: Record<string, (...a: unknown[]) => unknown>,
|
|
22
|
+
ctx: BuildCtx,
|
|
23
|
+
): Partial<BunFFI> {
|
|
24
|
+
const { lenOrView } = ctx
|
|
25
|
+
|
|
26
|
+
const init = sym.castrum_task_init as Raw1
|
|
27
|
+
const submit = sym.castrum_task_submit as Raw4
|
|
28
|
+
const submitOut = sym.castrum_task_submit_out as Raw6
|
|
29
|
+
const submitSlice = sym.castrum_task_submit_slice as Raw6
|
|
30
|
+
const drain = sym.castrum_task_drain as Raw2
|
|
31
|
+
const pending = sym.castrum_task_pending as Raw0
|
|
32
|
+
const cancel = sym.castrum_task_cancel as Raw1
|
|
33
|
+
const setDoorbell = sym.castrum_task_set_doorbell as Raw1
|
|
34
|
+
const shutdown = sym.castrum_task_shutdown as Raw0
|
|
35
|
+
const threads = sym.castrum_task_threads as Raw0
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
taskInit: (n) => Number(init(n)),
|
|
39
|
+
// `args` is copied onto the worker by Rust, so the caller's pooled buffer
|
|
40
|
+
// may be released as soon as this returns.
|
|
41
|
+
taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
|
|
42
|
+
taskSubmitOut: (op, args, taskId, output) =>
|
|
43
|
+
Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
|
|
44
|
+
// Zero-copy INPUT: the header is copied by Rust, the payload is read in
|
|
45
|
+
// place — which is why the runtime must hold a reference to `data` for the
|
|
46
|
+
// whole task (see `Deferred.keep` in src/task/runtime.ts).
|
|
47
|
+
taskSubmitSlice: (op, hdr, data, taskId) =>
|
|
48
|
+
Number(submitSlice(op, hdr, lenOrView(hdr), data, lenOrView(data), taskId)),
|
|
49
|
+
taskDrain: (output) => Number(drain(output, lenOrView(output))),
|
|
50
|
+
taskPending: () => Number(pending()),
|
|
51
|
+
taskCancel: (taskId) => Number(cancel(taskId)),
|
|
52
|
+
taskSetDoorbell: (ptr) => Number(setDoorbell(ptr)),
|
|
53
|
+
taskShutdown: () => Number(shutdown()),
|
|
54
|
+
taskThreads: () => Number(threads()),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Bind-time self-test for the task symbols. The pool is intentionally NOT
|
|
60
|
+
* started here (no threads at import time) and no completion is produced;
|
|
61
|
+
* the full submit→drain→doorbell path is exercised by `test/unit/task/`.
|
|
62
|
+
*/
|
|
63
|
+
export function selfTestTask(b: BunFFI): boolean {
|
|
64
|
+
try {
|
|
65
|
+
if (typeof b.taskPending() !== 'number') return false
|
|
66
|
+
if (typeof b.taskThreads() !== 'number' || b.taskThreads() < 0) return false
|
|
67
|
+
// `0` disables the doorbell — safe at bind time (nothing is in flight).
|
|
68
|
+
if (b.taskSetDoorbell(0) !== 1) return false
|
|
69
|
+
// An empty ring → 0; exercises the drain symbol without allocating anything.
|
|
70
|
+
if (typeof b.taskDrain(new Uint8Array(4)) !== 'number') return false
|
|
71
|
+
// NOT called: submitting here would start the pool threads at bind time.
|
|
72
|
+
// The full submit/submit_out path is exercised by test/unit/task/.
|
|
73
|
+
if (typeof b.taskSubmitOut !== 'function') return false
|
|
74
|
+
if (typeof b.taskSubmitSlice !== 'function') return false
|
|
75
|
+
return true
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -42,6 +42,24 @@ export interface BuildCtx {
|
|
|
42
42
|
/** Coerce an optional boolean to the C `u8` flag (1/0). */
|
|
43
43
|
export const flag = (v?: boolean): number => (v ? 1 : 0)
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Does this string contain a NUL (`U+0000`)?
|
|
47
|
+
*
|
|
48
|
+
* `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
|
|
49
|
+
* NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
|
|
50
|
+
* on the native side (Bun's docs describe the transcode; the truncation is the
|
|
51
|
+
* C convention it inherits). That is fine when the string is developer-supplied
|
|
52
|
+
* config or NUL-free by construction — and it is a real bug when the callee
|
|
53
|
+
* answers with a VERDICT (validators, query/cookie gates, batch validators: a
|
|
54
|
+
* truncated prefix passes where the full input fails) or when the exact bytes
|
|
55
|
+
* are the result (`regexEscape`, metric label values).
|
|
56
|
+
*
|
|
57
|
+
* Guard contract for every `cstring` ARG reachable from user input: either
|
|
58
|
+
* reject/short-circuit on NUL, or route to the `(ptr, usize)` byte form which
|
|
59
|
+
* carries the exact length. `test/unit/native/cstring-nul.test.ts` pins this.
|
|
60
|
+
*/
|
|
61
|
+
export { hasNul } from '../../../shared/bytes'
|
|
62
|
+
|
|
45
63
|
/**
|
|
46
64
|
* Output-buffer allocation for the FFI wrappers. The native write fills
|
|
47
65
|
* `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure
|
package/src/native/ffi/build.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { buildCompress } from './build/compress'
|
|
|
16
16
|
import { buildInstances } from './build/instances'
|
|
17
17
|
import { buildMetrics } from './build/metrics'
|
|
18
18
|
import { buildParse } from './build/parse'
|
|
19
|
+
import { buildTask } from './build/task'
|
|
19
20
|
import type { BuildCtx } from './build/util'
|
|
20
21
|
import type { BunFFI } from './types'
|
|
21
22
|
|
|
@@ -92,5 +93,6 @@ export function build(
|
|
|
92
93
|
...buildParse(sym, ctx),
|
|
93
94
|
...buildInstances(sym, ctx),
|
|
94
95
|
...buildMetrics(sym, ctx),
|
|
96
|
+
...buildTask(sym, ctx),
|
|
95
97
|
} as BunFFI
|
|
96
98
|
}
|
|
@@ -15,6 +15,7 @@ import { selfTestCompress } from './build/compress'
|
|
|
15
15
|
import { selfTestInstances } from './build/instances'
|
|
16
16
|
import { selfTestMetrics } from './build/metrics'
|
|
17
17
|
import { selfTestParse } from './build/parse'
|
|
18
|
+
import { selfTestTask } from './build/task'
|
|
18
19
|
import type { BunFFI } from './types'
|
|
19
20
|
|
|
20
21
|
/** Verify every bound function against known-good results; false disables ffi. */
|
|
@@ -24,6 +25,7 @@ export function selfTest(b: BunFFI): boolean {
|
|
|
24
25
|
selfTestCompress(b) &&
|
|
25
26
|
selfTestParse(b) &&
|
|
26
27
|
selfTestInstances(b) &&
|
|
27
|
-
selfTestMetrics(b)
|
|
28
|
+
selfTestMetrics(b) &&
|
|
29
|
+
selfTestTask(b)
|
|
28
30
|
)
|
|
29
31
|
}
|
package/src/native/ffi/types.ts
CHANGED
|
@@ -218,6 +218,12 @@ export interface BunFFI {
|
|
|
218
218
|
gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
|
|
219
219
|
/** gzip-decompress into `output`; returns bytes written (throws if too small). */
|
|
220
220
|
gzipDecompressInto(data: Uint8Array, output: Uint8Array, maxDecompressed?: number): number
|
|
221
|
+
/**
|
|
222
|
+
* gzip ISIZE trailer probe → the uncompressed size for a single-member gzip
|
|
223
|
+
* stream, or `0` when unknown (invalid stream / multi-member). Used to
|
|
224
|
+
* PRE-SIZE a destination buffer for the zero-copy task path.
|
|
225
|
+
*/
|
|
226
|
+
gzipIsize(data: Uint8Array): number
|
|
221
227
|
/** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
|
|
222
228
|
brotliCompress(data: Uint8Array, quality?: number): Uint8Array
|
|
223
229
|
/** brotli-compress into `output`; returns bytes written (throws if too small). */
|
|
@@ -465,6 +471,23 @@ export interface BunFFI {
|
|
|
465
471
|
* exact required size).
|
|
466
472
|
*/
|
|
467
473
|
sessionOpen(token: string, secret: string, output: Uint8Array): number
|
|
474
|
+
/**
|
|
475
|
+
* Byte-arg `{@link sessionSeal}`: same envelope + wire format, inputs as
|
|
476
|
+
* bytes. Preferred for user-derived `id`/`dataJson`, because a `cstring` ARG
|
|
477
|
+
* is engine-transcoded (a per-arg cost) and SILENTLY TRUNCATES at an
|
|
478
|
+
* embedded `U+0000` before Rust sees it. `null` = failure.
|
|
479
|
+
*/
|
|
480
|
+
sessionSealBytes(
|
|
481
|
+
id: Uint8Array,
|
|
482
|
+
dataJson: Uint8Array,
|
|
483
|
+
expSecs: number,
|
|
484
|
+
secret: Uint8Array,
|
|
485
|
+
): string | null
|
|
486
|
+
/**
|
|
487
|
+
* Byte-arg `{@link sessionOpen}`: same output layout and needed-size
|
|
488
|
+
* convention, `token`/`secret` as bytes (exact length, no transcode).
|
|
489
|
+
*/
|
|
490
|
+
sessionOpenBytes(token: Uint8Array, secret: Uint8Array, output: Uint8Array): number
|
|
468
491
|
|
|
469
492
|
// ── Batch fixed-width hex validation / RegExp escaping ─────────────
|
|
470
493
|
/**
|
|
@@ -488,6 +511,40 @@ export interface BunFFI {
|
|
|
488
511
|
* cstring return out (engine-cloned — zero JS decode). `null` = failure.
|
|
489
512
|
*/
|
|
490
513
|
regexEscapeStr(input: string): string | null
|
|
514
|
+
|
|
515
|
+
// ── Off-thread task runtime (castrum_task_*) ──────────────────────
|
|
516
|
+
/** Start the shared native task pool; 0 = started, 1 = already running. */
|
|
517
|
+
taskInit(threads: number): number
|
|
518
|
+
/** Submit a task op with packed `args` under `taskId`; 1 = accepted. */
|
|
519
|
+
taskSubmit(op: number, args: Uint8Array, taskId: number): number
|
|
520
|
+
/**
|
|
521
|
+
* Zero-copy submit: the op writes its result DIRECTLY into `output` on the
|
|
522
|
+
* pool thread. `output` must be a stable, caller-owned buffer that stays
|
|
523
|
+
* alive (and untouched) until the completion is drained — the runtime holds
|
|
524
|
+
* the reference. The completion body is the 8-byte LE written length.
|
|
525
|
+
*/
|
|
526
|
+
taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
|
|
527
|
+
/**
|
|
528
|
+
* Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
|
|
529
|
+
* thread, so the caller must keep it alive (and untouched) until completion.
|
|
530
|
+
*/
|
|
531
|
+
taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
|
|
532
|
+
/**
|
|
533
|
+
* Drain finished tasks into `output` (packed `[u32 count][entry…]`).
|
|
534
|
+
* Returns bytes written, `0` when nothing is pending, or the EXACT required
|
|
535
|
+
* size when `output` is too small (nothing is consumed in that case).
|
|
536
|
+
*/
|
|
537
|
+
taskDrain(output: Uint8Array): number
|
|
538
|
+
/** Finished-but-undrained completion count. */
|
|
539
|
+
taskPending(): number
|
|
540
|
+
/** Mark a task cancelled; 1 = newly marked, 0 = unknown/already finished. */
|
|
541
|
+
taskCancel(taskId: number): number
|
|
542
|
+
/** Register the thread-safe doorbell trampoline address (`0` disables it). */
|
|
543
|
+
taskSetDoorbell(ptr: number): number
|
|
544
|
+
/** Stop the task pool and drop pending completions. */
|
|
545
|
+
taskShutdown(): number
|
|
546
|
+
/** Configured task-pool worker count (0 before the pool starts). */
|
|
547
|
+
taskThreads(): number
|
|
491
548
|
}
|
|
492
549
|
|
|
493
550
|
/**
|
package/src/native/ffi.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { BunFFI, FfiMode } from './ffi/types'
|
|
|
34
34
|
// Static import: loader.ts has no side effects (path resolution only — no
|
|
35
35
|
// dlopen), so this adds zero import-time cost while letting `bind()` reuse the
|
|
36
36
|
// exact same resolved `.node` path the napi fallback uses (the shared seam).
|
|
37
|
-
import { getAddonPath } from './loader'
|
|
37
|
+
import { getAddonPath, getAddonPathCandidates } from './loader'
|
|
38
38
|
|
|
39
39
|
// Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
|
|
40
40
|
// keeps working (existing call sites).
|
|
@@ -49,6 +49,12 @@ let cached: BunFFI | null | undefined
|
|
|
49
49
|
*/
|
|
50
50
|
let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The addon file the live `bun:ffi` binding was opened from, or `null` while
|
|
54
|
+
* unbound. Diagnostics only — a v3→baseline fallback is visible here.
|
|
55
|
+
*/
|
|
56
|
+
let boundAddonPath: string | null = null
|
|
57
|
+
|
|
52
58
|
// ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
|
|
53
59
|
|
|
54
60
|
function resolveFfiMode(): FfiMode {
|
|
@@ -96,6 +102,20 @@ export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
|
|
|
96
102
|
return bufferAbiMode
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The addon file path the live `bun:ffi` binding was opened from, or `null`
|
|
107
|
+
* when the transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or every
|
|
108
|
+
* candidate failed). Lazy — triggers the same one-time bind as
|
|
109
|
+
* {@link getBunFFI}. Use it to confirm WHICH artifact bound, e.g. when a stale
|
|
110
|
+
* v3 SIMD binary forced the baseline fallback.
|
|
111
|
+
*/
|
|
112
|
+
export function ffiAddonPath(): string | null {
|
|
113
|
+
if (cached === undefined) {
|
|
114
|
+
getBunFFI()
|
|
115
|
+
}
|
|
116
|
+
return boundAddonPath
|
|
117
|
+
}
|
|
118
|
+
|
|
99
119
|
/**
|
|
100
120
|
* Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
|
|
101
121
|
* `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
|
|
@@ -127,7 +147,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
|
|
|
127
147
|
}
|
|
128
148
|
}
|
|
129
149
|
|
|
130
|
-
function bind(): BunFFI | null {
|
|
150
|
+
function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
|
|
131
151
|
bufferAbiMode = null // reset — the mode below is only valid for a live bind
|
|
132
152
|
const mode = resolveFfiMode()
|
|
133
153
|
if (!isBun()) {
|
|
@@ -143,12 +163,13 @@ function bind(): BunFFI | null {
|
|
|
143
163
|
if (mode === 'napi') {
|
|
144
164
|
return null
|
|
145
165
|
}
|
|
166
|
+
// Resolve the SAME addon file napi uses (./loader.ts — statically imported
|
|
167
|
+
// above; it only resolves the path, it never dlopens the addon). On a retry,
|
|
168
|
+
// `forcedPath` is the next candidate from `getAddonPathCandidates()`.
|
|
169
|
+
const path = forcedPath ?? getAddonPath()
|
|
146
170
|
try {
|
|
147
171
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
148
172
|
const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
|
|
149
|
-
// Resolve the SAME addon file napi uses (./loader.ts — statically imported
|
|
150
|
-
// above; it only resolves the path, it never dlopens the addon).
|
|
151
|
-
const path = getAddonPath()
|
|
152
173
|
|
|
153
174
|
// Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
|
|
154
175
|
// TypedArray at call time — an atomic snapshot) once; when supported we use
|
|
@@ -209,6 +230,14 @@ function bind(): BunFFI | null {
|
|
|
209
230
|
// The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
|
|
210
231
|
// the caller already holds bytes (a cstring arg would force a decode +
|
|
211
232
|
// engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
|
|
233
|
+
//
|
|
234
|
+
// SAFETY RULE for every `cstring` ARG added here: the transcode is
|
|
235
|
+
// NUL-TERMINATED, so an embedded U+0000 truncates the value in-engine. A
|
|
236
|
+
// verdict op bound this way can be fooled (`validateEmail` on
|
|
237
|
+
// 'a@b.com\0<script>' returned TRUE before the byte re-route). Either
|
|
238
|
+
// give the op a `_bytes` sibling and route byte callers to it, or guard
|
|
239
|
+
// the string form with `hasNul` — see docs/FFI_BUN_GUIDE.md §6.1,
|
|
240
|
+
// pinned by test/unit/native/cstring-nul.test.ts.
|
|
212
241
|
castrum_validate_email: { args: ['cstring'], returns: 'u8' },
|
|
213
242
|
castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
|
|
214
243
|
castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
|
|
@@ -547,6 +576,39 @@ function bind(): BunFFI | null {
|
|
|
547
576
|
args: abi(['cstring', 'cstring', 'ptr', 'usize']),
|
|
548
577
|
returns: U64_FAST,
|
|
549
578
|
},
|
|
579
|
+
// Byte-arg siblings of the pair above: identical core and wire format,
|
|
580
|
+
// but every INPUT crosses as `(ptr,len)`. A `cstring` ARG costs the
|
|
581
|
+
// engine a transcode per arg AND silently truncates at an embedded
|
|
582
|
+
// U+0000 — reachable here because `id`/`data_json` are user-derived.
|
|
583
|
+
castrum_session_seal_bytes: {
|
|
584
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
|
|
585
|
+
returns: 'cstring',
|
|
586
|
+
},
|
|
587
|
+
castrum_session_open_bytes: {
|
|
588
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
589
|
+
returns: U64_FAST,
|
|
590
|
+
},
|
|
591
|
+
// Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
|
|
592
|
+
// plus a JS-assigned id; `drain` writes the packed completion batch into
|
|
593
|
+
// a caller buffer (needed-size convention). `set_doorbell` takes the raw
|
|
594
|
+
// `JSCallback({threadsafe:true}).ptr` as a bare pointer (`abi` leaves a
|
|
595
|
+
// lone `ptr` untouched), so the pool can wake JS once per batch.
|
|
596
|
+
castrum_task_init: { args: ['u32'], returns: 'u32' },
|
|
597
|
+
castrum_task_submit: { args: abi(['u32', 'ptr', 'usize', 'usize']), returns: 'u32' },
|
|
598
|
+
castrum_task_submit_slice: {
|
|
599
|
+
args: abi(['u32', 'ptr', 'usize', 'ptr', 'usize', 'usize']),
|
|
600
|
+
returns: 'u32',
|
|
601
|
+
},
|
|
602
|
+
castrum_task_submit_out: {
|
|
603
|
+
args: abi(['u32', 'ptr', 'usize', 'usize', 'ptr', 'usize']),
|
|
604
|
+
returns: 'u32',
|
|
605
|
+
},
|
|
606
|
+
castrum_task_drain: { args: abi(['ptr', 'usize']), returns: U64_FAST },
|
|
607
|
+
castrum_task_pending: { args: [], returns: 'u32' },
|
|
608
|
+
castrum_task_cancel: { args: ['usize'], returns: 'u32' },
|
|
609
|
+
castrum_task_set_doorbell: { args: ['ptr'], returns: 'u32' },
|
|
610
|
+
castrum_task_shutdown: { args: [], returns: 'u32' },
|
|
611
|
+
castrum_task_threads: { args: [], returns: 'u32' },
|
|
550
612
|
castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
|
|
551
613
|
})
|
|
552
614
|
|
|
@@ -557,20 +619,29 @@ function bind(): BunFFI | null {
|
|
|
557
619
|
useBufferLength,
|
|
558
620
|
)
|
|
559
621
|
if (!selfTest(bindings)) {
|
|
560
|
-
|
|
561
|
-
throw new Error(
|
|
562
|
-
'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
|
|
563
|
-
'primary transport cannot be trusted on this Bun/addon combination. ' +
|
|
564
|
-
'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
|
|
565
|
-
)
|
|
566
|
-
}
|
|
567
|
-
return null
|
|
622
|
+
throw new Error(`bun:ffi bind-time self-test failed on ${path}`)
|
|
568
623
|
}
|
|
624
|
+
boundAddonPath = path
|
|
569
625
|
return bindings
|
|
570
626
|
} catch (err) {
|
|
627
|
+
bufferAbiMode = null
|
|
628
|
+
// The preferred artifact failed to dlopen/bind/self-test. The common cause
|
|
629
|
+
// is a STALE v3 SIMD binary (`castrum.linux-x64-v3-gnu.node`) that predates
|
|
630
|
+
// a newly added `castrum_*` symbol: dlopen fails, and without this walk the
|
|
631
|
+
// whole FFI transport would be disabled (every call silently downgraded to
|
|
632
|
+
// napi, and the task runtime silently made synchronous). Try the remaining
|
|
633
|
+
// existing candidates — the baseline binary — before giving up.
|
|
634
|
+
for (const next of getAddonPathCandidates()) {
|
|
635
|
+
if (next === path || tried.includes(next)) continue
|
|
636
|
+
const bound = bind(next, [...tried, path])
|
|
637
|
+
if (bound) return bound
|
|
638
|
+
}
|
|
571
639
|
if (mode === 'ffi') {
|
|
572
640
|
const cause = err instanceof Error ? `: ${err.message}` : ''
|
|
573
|
-
throw new Error(
|
|
641
|
+
throw new Error(
|
|
642
|
+
`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause} ` +
|
|
643
|
+
`(tried: ${[...tried, path].join(', ')})`,
|
|
644
|
+
)
|
|
574
645
|
}
|
|
575
646
|
return null
|
|
576
647
|
}
|
package/src/native/loader.ts
CHANGED
|
@@ -67,15 +67,16 @@ export function supportsX8664V3(cpuinfo?: string): boolean {
|
|
|
67
67
|
* @param arch `process.arch`
|
|
68
68
|
* @param cpuinfo optional `/proc/cpuinfo` text (tests inject it; the real call
|
|
69
69
|
* omits it and reads the file lazily on linux/x64 only)
|
|
70
|
-
* @returns
|
|
70
|
+
* @returns every candidate path, in preference order (v3 SIMD first on a
|
|
71
|
+
* capable linux/x64 host, then the baseline names)
|
|
71
72
|
*/
|
|
72
|
-
export function
|
|
73
|
+
export function addonCandidatesFrom(
|
|
73
74
|
envOverride: string | undefined,
|
|
74
75
|
baseDir: string,
|
|
75
76
|
platform: string,
|
|
76
77
|
arch: string,
|
|
77
78
|
cpuinfo?: string,
|
|
78
|
-
): string {
|
|
79
|
+
): string[] {
|
|
79
80
|
// napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
|
|
80
81
|
// e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
|
|
81
82
|
const libcVariants =
|
|
@@ -156,6 +157,22 @@ export function resolveAddonPathFrom(
|
|
|
156
157
|
candidates.push(join(baseDir, '..', '..', 'target', 'release', name))
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
return candidates
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the addon path: the FIRST existing candidate from
|
|
165
|
+
* {@link addonCandidatesFrom} (v3 SIMD first on a capable linux/x64 host).
|
|
166
|
+
* Throws when nothing exists.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveAddonPathFrom(
|
|
169
|
+
envOverride: string | undefined,
|
|
170
|
+
baseDir: string,
|
|
171
|
+
platform: string,
|
|
172
|
+
arch: string,
|
|
173
|
+
cpuinfo?: string,
|
|
174
|
+
): string {
|
|
175
|
+
const candidates = addonCandidatesFrom(envOverride, baseDir, platform, arch, cpuinfo)
|
|
159
176
|
for (const candidate of candidates) {
|
|
160
177
|
if (existsSync(candidate)) {
|
|
161
178
|
return candidate
|
|
@@ -169,6 +186,39 @@ export function resolveAddonPathFrom(
|
|
|
169
186
|
)
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
/** Cached result of {@link getAddonPathCandidates} (existing files only). */
|
|
190
|
+
let cachedCandidates: string[] | undefined
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every EXISTING addon candidate, in preference order (v3 SIMD first on a
|
|
194
|
+
* capable linux/x64 host, then the baseline names).
|
|
195
|
+
*
|
|
196
|
+
* The bun:ffi binder walks this list so a stale or partial *preferred* artifact
|
|
197
|
+
* — e.g. a `castrum.linux-x64-v3-gnu.node` built before a newly added
|
|
198
|
+
* `castrum_*` symbol — falls back to the baseline binary instead of silently
|
|
199
|
+
* disabling the whole FFI transport (which would downgrade every native call to
|
|
200
|
+
* napi and make the off-thread task runtime run synchronously).
|
|
201
|
+
*/
|
|
202
|
+
export function getAddonPathCandidates(): string[] {
|
|
203
|
+
if (cachedCandidates) return cachedCandidates
|
|
204
|
+
const all = addonCandidatesFrom(
|
|
205
|
+
resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
|
|
206
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
207
|
+
process.platform,
|
|
208
|
+
process.arch,
|
|
209
|
+
)
|
|
210
|
+
const seen = new Set<string>()
|
|
211
|
+
const existing: string[] = []
|
|
212
|
+
for (const candidate of all) {
|
|
213
|
+
if (!seen.has(candidate) && existsSync(candidate)) {
|
|
214
|
+
seen.add(candidate)
|
|
215
|
+
existing.push(candidate)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
cachedCandidates = existing
|
|
219
|
+
return existing
|
|
220
|
+
}
|
|
221
|
+
|
|
172
222
|
function resolveAddonPath(): string {
|
|
173
223
|
return resolveAddonPathFrom(
|
|
174
224
|
resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
|
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
|
|
12
12
|
import type { EncodingPrefResult, MediaTypeResult } from '../../native'
|
|
13
13
|
import type { BunFFI } from '../../native/ffi'
|
|
14
|
-
import { decoder } from '../../shared/bytes'
|
|
14
|
+
import { decoder, hasNul } from '../../shared/bytes'
|
|
15
15
|
import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
|
|
16
16
|
import { packPairs } from '../../shared/packed'
|
|
17
17
|
import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
|
|
18
18
|
import { writeInto } from '../into'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* RFC 6455 `Sec-WebSocket-Accept` is always 28 chars. Reused across calls
|
|
22
|
+
* because the value is decoded to a string synchronously before returning —
|
|
23
|
+
* the same discipline as the other pooled scratches in this layer.
|
|
24
|
+
*/
|
|
25
|
+
const WS_ACCEPT_SCRATCH = new Uint8Array(64)
|
|
26
|
+
|
|
20
27
|
// Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
|
|
21
28
|
// validate when a high byte is present (the ASCII fast path skips it — same as
|
|
22
29
|
// Rust's saw_high check). On the Bun path the check is a native C-ABI probe
|
|
@@ -144,11 +151,22 @@ export function buildHttp(ctx: RustClientContext) {
|
|
|
144
151
|
|
|
145
152
|
return {
|
|
146
153
|
mimeFromExtension(ext: Uint8Array): Uint8Array | string {
|
|
147
|
-
// FFI-first
|
|
148
|
-
//
|
|
149
|
-
//
|
|
154
|
+
// FFI-first. This op has no `*_bytes` sibling, so the bytes must be decoded
|
|
155
|
+
// for the `cstring` ARG — the decode is the price of the cstring form, not
|
|
156
|
+
// a speed trick (route new byte-shaped ops to a byte sibling instead; see
|
|
157
|
+
// docs/FFI_BUN_GUIDE.md §6.1). napi keeps the memoized-bytes path
|
|
158
|
+
// (cachedMime).
|
|
159
|
+
//
|
|
160
|
+
// A NUL would truncate the DECODED string native-side, so `.js\0evil`
|
|
161
|
+
// would resolve as `.js` — a Content-Type from a prefix. An extension
|
|
162
|
+
// containing a NUL is not an extension, so it takes the unknown sentinel
|
|
163
|
+
// (the guard is `indexOf` on an already-decoded string: ~4 ns).
|
|
150
164
|
const f = ffi()
|
|
151
|
-
if (f)
|
|
165
|
+
if (f) {
|
|
166
|
+
const name = decoder.decode(ext)
|
|
167
|
+
if (hasNul(name)) return 'application/octet-stream'
|
|
168
|
+
return f.mimeFromExtension(name) ?? 'application/octet-stream'
|
|
169
|
+
}
|
|
152
170
|
return ctx.cachedMime(ext)
|
|
153
171
|
},
|
|
154
172
|
urlEncode(input: Uint8Array): Uint8Array | string {
|
|
@@ -188,34 +206,54 @@ export function buildHttp(ctx: RustClientContext) {
|
|
|
188
206
|
return n('urlDecodeInto')(input, output) as number
|
|
189
207
|
},
|
|
190
208
|
validateEmail(input: Uint8Array): boolean {
|
|
191
|
-
// FFI-first
|
|
192
|
-
//
|
|
193
|
-
// `
|
|
194
|
-
//
|
|
209
|
+
// FFI-first. The bytes cross as a `buffer`/`buffer_length` pair (an atomic
|
|
210
|
+
// ptr+len snapshot, zero transcode) rather than being decoded to a JS
|
|
211
|
+
// string for the `cstring` ARG. Measured on Bun 1.4.2 over 400k calls:
|
|
212
|
+
// 101 ns vs 234 ns (email), 65 vs 164 (uuid), 31 vs 138 (ipv4) — and the
|
|
213
|
+
// byte form cannot truncate at an embedded NUL the way a cstring ARG
|
|
214
|
+
// does (a NUL byte used to make "a@b.com\0<script>" validate as TRUE).
|
|
195
215
|
const f = ffi()
|
|
196
|
-
if (f) return f.
|
|
216
|
+
if (f) return f.validateEmailBytes(input)
|
|
197
217
|
return n('validateEmail')(input) as boolean
|
|
198
218
|
},
|
|
199
219
|
validateUuid(input: Uint8Array): boolean {
|
|
200
220
|
const f = ffi()
|
|
201
|
-
if (f) return f.
|
|
221
|
+
if (f) return f.validateUuidBytes(input)
|
|
202
222
|
return n('validateUuid')(input) as boolean
|
|
203
223
|
},
|
|
204
224
|
validateIpv4(input: Uint8Array): boolean {
|
|
205
225
|
const f = ffi()
|
|
206
|
-
if (f) return f.
|
|
226
|
+
if (f) return f.validateIpv4Bytes(input)
|
|
207
227
|
return n('validateIpv4')(input) as boolean
|
|
208
228
|
},
|
|
209
229
|
validateIpv6(input: Uint8Array): boolean {
|
|
210
230
|
const f = ffi()
|
|
211
|
-
if (f) return f.
|
|
231
|
+
if (f) return f.validateIpv6Bytes(input)
|
|
212
232
|
return n('validateIpv6')(input) as boolean
|
|
213
233
|
},
|
|
214
234
|
wsAcceptKey(key: Uint8Array): Uint8Array | string {
|
|
215
|
-
// FFI-first: the `cstring` ARG is
|
|
216
|
-
//
|
|
235
|
+
// FFI-first: the `cstring` ARG + cstring return is the fast path (no JS
|
|
236
|
+
// encode, engine-cloned result) — end-to-end it measures ~367 ns, while
|
|
237
|
+
// routing every call through the byte sibling costs ~381 ns (the extra
|
|
238
|
+
// `subarray` + decode outweighs the transcode saved; measured over 300k
|
|
239
|
+
// calls on Bun 1.4.2 through THIS function, not the raw binding).
|
|
240
|
+
//
|
|
241
|
+
// Correctness rider: a NUL would truncate the `cstring` ARG, so the accept
|
|
242
|
+
// value would be computed for a PREFIX of the key — verified:
|
|
243
|
+
// `'dGhlIHNhbXBsZSBub25jZQ==\0junk'` returned exactly the value for the
|
|
244
|
+
// key WITHOUT the suffix. That case takes the byte sibling (exact length).
|
|
217
245
|
const f = ffi()
|
|
218
|
-
if (f)
|
|
246
|
+
if (f) {
|
|
247
|
+
const text = decoder.decode(key)
|
|
248
|
+
if (!hasNul(text)) return f.wsAcceptKey(text)
|
|
249
|
+
const w = f.wsAcceptKeyInto(key, WS_ACCEPT_SCRATCH)
|
|
250
|
+
if (w > 0 && w <= WS_ACCEPT_SCRATCH.length) {
|
|
251
|
+
return decoder.decode(WS_ACCEPT_SCRATCH.subarray(0, w))
|
|
252
|
+
}
|
|
253
|
+
// Unreachable for a live view (the native side writes 28 for any key);
|
|
254
|
+
// the napi path is byte-exact, so it can never resurrect truncation.
|
|
255
|
+
return n('wsAcceptKey')(key) as Uint8Array | string
|
|
256
|
+
}
|
|
219
257
|
return n('wsAcceptKey')(key) as Uint8Array | string
|
|
220
258
|
},
|
|
221
259
|
wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number {
|
package/src/rust-ffi/text.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
// src/rust-ffi/text.ts — String-oriented FFI namespace (text in → text out).
|
|
2
2
|
//
|
|
3
3
|
// `rust.text.*` is the ergonomic string API over the byte-level native
|
|
4
|
-
// functions.
|
|
4
|
+
// functions. MOST of it is FFI-first with `cstring` args/returns: the engine
|
|
5
|
+
// transcodes a JS string in-call, so there is no JS-side encode at all (and
|
|
6
|
+
// byte-arg ops are not involved). Encode/decode wrappers exist only for the
|
|
7
|
+
// napi fallback and for ops with no cstring form — plus a NUL guard wherever a
|
|
8
|
+
// `cstring` ARG could truncate a verdict or a byte-exact value
|
|
9
|
+
// (docs/FFI_BUN_GUIDE.md §6.1).
|
|
5
10
|
//
|
|
6
11
|
// Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
|
|
7
12
|
// built-in delegations (urlEncode/urlDecode) come from `builtins.has(op)` and
|
|
8
13
|
// the native call from `transport.ffi` / `transport.resolve` (bun:ffi first,
|
|
9
14
|
// napi fallback) — no inline `isBun()` / `getBunFFI()`.
|
|
10
15
|
|
|
11
|
-
import { decoder, encoder } from '../shared/bytes'
|
|
16
|
+
import { decoder, encoder, hasNul } from '../shared/bytes'
|
|
12
17
|
import { type RustClientContext, resolveNative } from './context'
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
|
|
21
|
+
* NUL case only). Decoded synchronously before returning, so reuse is safe.
|
|
22
|
+
*/
|
|
23
|
+
const WS_ACCEPT_OUT = new Uint8Array(64)
|
|
24
|
+
|
|
14
25
|
/** String-oriented FFI namespace. */
|
|
15
26
|
export interface RustText {
|
|
16
27
|
/**
|
|
@@ -91,8 +102,19 @@ export function buildText(ctx: RustClientContext): RustText {
|
|
|
91
102
|
// FFI-first: `cstring` ARG + cstring return (native transfer both ways —
|
|
92
103
|
// the engine transcodes the key in-engine, no JS encode). napi keeps the
|
|
93
104
|
// encode/decode path.
|
|
105
|
+
//
|
|
106
|
+
// Exception: a NUL would truncate the `cstring` ARG, so the accept value
|
|
107
|
+
// would be computed for a PREFIX of the key. Encode and take the byte path
|
|
108
|
+
// in that (practically unreachable) case: still a wrong key for a client
|
|
109
|
+
// that sent a NUL, but wrong for the bytes the caller actually passed.
|
|
94
110
|
const f = transport.ffi
|
|
95
|
-
if (f)
|
|
111
|
+
if (f) {
|
|
112
|
+
if (!hasNul(key)) return f.wsAcceptKey(key)
|
|
113
|
+
const w = f.wsAcceptKeyInto(encoder.encode(key), WS_ACCEPT_OUT)
|
|
114
|
+
return w > 0 && w <= WS_ACCEPT_OUT.length
|
|
115
|
+
? decoder.decode(WS_ACCEPT_OUT.subarray(0, w))
|
|
116
|
+
: ''
|
|
117
|
+
}
|
|
96
118
|
return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
|
|
97
119
|
},
|
|
98
120
|
validateEmail(input) {
|
package/src/shared/bytes.ts
CHANGED
|
@@ -134,3 +134,22 @@ export function toPlainBuffer(bytes: Uint8Array): Uint8Array {
|
|
|
134
134
|
copy.set(bytes)
|
|
135
135
|
return copy
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Does this string contain a NUL (`U+0000`)?
|
|
140
|
+
*
|
|
141
|
+
* `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
|
|
142
|
+
* NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
|
|
143
|
+
* natively (Bun's docs describe the transcode; the truncation is the C
|
|
144
|
+
* convention it inherits). That is fine for developer-supplied config, and a
|
|
145
|
+
* real bug wherever the callee answers with a VERDICT (a truncated prefix
|
|
146
|
+
* passes where the full input fails) or where the exact bytes ARE the result.
|
|
147
|
+
*
|
|
148
|
+
* Cheap: one `indexOf` scan, ~4 ns for typical inputs.
|
|
149
|
+
*
|
|
150
|
+
* @param s - The string to check.
|
|
151
|
+
* @returns `true` when a NUL is present.
|
|
152
|
+
*/
|
|
153
|
+
export function hasNul(s: string): boolean {
|
|
154
|
+
return s.indexOf('\u0000') !== -1
|
|
155
|
+
}
|