castrum 0.9.7 → 0.9.9
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 +1 -1
- package/Cargo.toml +1 -1
- 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 +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +55 -35
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
|
@@ -39,6 +39,21 @@
|
|
|
39
39
|
* converge on one buffer size instead of re-growing each time. Default false.
|
|
40
40
|
*/
|
|
41
41
|
adaptive?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Number of buffers (beyond the initial one) to ALLOCATE UP FRONT at
|
|
44
|
+
* construction, so a burst-ramp (e.g. a load-test connection flood) never
|
|
45
|
+
* pays per-request `new Uint8Array` + GC during its first wave. A pure
|
|
46
|
+
* memory-for-latency trade: retained memory becomes `prefill + 1` ×
|
|
47
|
+
* `initialSize` (still bounded by `maxBuffers`). Default 0.
|
|
48
|
+
*/
|
|
49
|
+
prefill?: number
|
|
50
|
+
/**
|
|
51
|
+
* Byte budget for `prefill`: the prefilled count is additionally capped to
|
|
52
|
+
* `floor(prefillBytes / initialSize)`, so a large `initialSize` (e.g. a
|
|
53
|
+
* 64 MiB output buffer) cannot multiply into gigabytes when a router creates
|
|
54
|
+
* one pool per route. Default `Infinity` (only `prefill`/`maxBuffers` apply).
|
|
55
|
+
*/
|
|
56
|
+
prefillBytes?: number
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
/** A borrowed buffer handle handed out by a pool; `release()` returns it. */
|
|
@@ -138,6 +153,22 @@ export class BufferPool {
|
|
|
138
153
|
: null
|
|
139
154
|
this.free.push(new Uint8Array(this.initialSize))
|
|
140
155
|
this.created = 1
|
|
156
|
+
// Pre-fill (memory-for-latency trade, see `prefill` in BufferPoolOptions):
|
|
157
|
+
// spend up-front allocation so a burst ramp never allocates during its
|
|
158
|
+
// first wave. Capped at maxBuffers so the documented retention bound
|
|
159
|
+
// (free list ≤ maxBuffers) still holds, and by `prefillBytes` so a large
|
|
160
|
+
// `initialSize` cannot multiply into an unbounded up-front allocation.
|
|
161
|
+
const prefill = Math.min(
|
|
162
|
+
Math.max(0, Math.floor(options.prefill ?? 0)),
|
|
163
|
+
this.maxBuffers - 1,
|
|
164
|
+
options.prefillBytes !== undefined
|
|
165
|
+
? Math.max(0, Math.floor(options.prefillBytes / this.initialSize))
|
|
166
|
+
: Number.POSITIVE_INFINITY,
|
|
167
|
+
)
|
|
168
|
+
for (let i = 0; i < prefill; i++) {
|
|
169
|
+
this.free.push(new Uint8Array(this.initialSize))
|
|
170
|
+
this.created++
|
|
171
|
+
}
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
/** Number of buffers currently available for immediate reuse. */
|
|
@@ -150,6 +181,16 @@ export class BufferPool {
|
|
|
150
181
|
return this.created
|
|
151
182
|
}
|
|
152
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Drop every retained free buffer. In-flight (borrowed) buffers are
|
|
186
|
+
* unaffected and return to an empty pool on `release()`, so the next
|
|
187
|
+
* `acquire()` allocates (or grows) as needed. Used by an explicit memory
|
|
188
|
+
* flush; the pool stays fully functional afterwards.
|
|
189
|
+
*/
|
|
190
|
+
shrink(): void {
|
|
191
|
+
this.free.length = 0
|
|
192
|
+
}
|
|
193
|
+
|
|
153
194
|
/**
|
|
154
195
|
* Acquire a buffer of at least `minSize` bytes. The returned handle must be
|
|
155
196
|
* released (directly, or via a zero-copy Response) once the caller is done.
|
|
@@ -204,11 +245,14 @@ export class BufferPool {
|
|
|
204
245
|
// Learn the observed demand so future allocations pre-size to it.
|
|
205
246
|
this.estimate?.sample(minSize)
|
|
206
247
|
|
|
207
|
-
// 1) Reuse a free buffer that is already large enough.
|
|
248
|
+
// 1) Reuse a free buffer that is already large enough. Free-list ORDER is
|
|
249
|
+
// irrelevant, so remove by swap-with-last (O(1), no splice compaction/
|
|
250
|
+
// allocation) — this is a per-request hot path.
|
|
208
251
|
for (let i = 0; i < this.free.length; i++) {
|
|
209
252
|
const candidate = this.free[i] as Uint8Array
|
|
210
253
|
if (candidate.byteLength >= minSize) {
|
|
211
|
-
this.free.
|
|
254
|
+
const last = this.free.pop() as Uint8Array
|
|
255
|
+
if (i < this.free.length) this.free[i] = last
|
|
212
256
|
return candidate
|
|
213
257
|
}
|
|
214
258
|
}
|
|
@@ -227,7 +271,8 @@ export class BufferPool {
|
|
|
227
271
|
}
|
|
228
272
|
}
|
|
229
273
|
if (largestBuf !== null) {
|
|
230
|
-
this.free.
|
|
274
|
+
const last = this.free.pop() as Uint8Array
|
|
275
|
+
if (largest < this.free.length) this.free[largest] = last
|
|
231
276
|
this.created++
|
|
232
277
|
return new Uint8Array(target)
|
|
233
278
|
}
|
|
@@ -241,16 +286,16 @@ export class BufferPool {
|
|
|
241
286
|
private releaseBuffer(buffer: Uint8Array): void {
|
|
242
287
|
if (this.free.length >= this.maxBuffers) {
|
|
243
288
|
// Free list is full: drop the smallest retained buffer to stay bounded.
|
|
244
|
-
let smallestBuf: Uint8Array | null = null
|
|
245
289
|
let smallest = 0
|
|
246
|
-
for (let i =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
290
|
+
for (let i = 1; i < this.free.length; i++) {
|
|
291
|
+
if (
|
|
292
|
+
(this.free[i] as Uint8Array).byteLength < (this.free[smallest] as Uint8Array).byteLength
|
|
293
|
+
) {
|
|
250
294
|
smallest = i
|
|
251
295
|
}
|
|
252
296
|
}
|
|
253
|
-
this.free.
|
|
297
|
+
const last = this.free.pop() as Uint8Array
|
|
298
|
+
if (smallest < this.free.length) this.free[smallest] = last
|
|
254
299
|
}
|
|
255
300
|
this.free.push(buffer)
|
|
256
301
|
}
|
package/src/shared/codec.ts
CHANGED
|
@@ -79,6 +79,32 @@ export const decodeUtf8: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8
|
|
|
79
79
|
export const decodeUtf8Range: (bytes: Uint8Array, start: number, end: number) => string =
|
|
80
80
|
runtimeCodec.decodeUtf8Range
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Decode a range of a PREPARED bounded Buffer `view` (absolute offsets into
|
|
84
|
+
* the view) to a string — replacement mode on invalid. Companion to
|
|
85
|
+
* `decodeUtf8Range` for packed-wire unpackers that decode MANY fields from
|
|
86
|
+
* ONE packed result: the caller resolves the Buffer view once and every field
|
|
87
|
+
* decode skips the per-field cache lookup + offset arithmetic.
|
|
88
|
+
*
|
|
89
|
+
* Falls back to `decodeUtf8Range` when the runtime codec does not provide the
|
|
90
|
+
* view variant (older adapters / custom codecs).
|
|
91
|
+
*
|
|
92
|
+
* @param view - A bounded Buffer over the packed bytes.
|
|
93
|
+
* @param start - Range start (inclusive, relative to the view).
|
|
94
|
+
* @param end - Range end (exclusive, relative to the view).
|
|
95
|
+
* @returns The decoded string.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const view = Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength)
|
|
100
|
+
* decodeUtf8RangeView(view, 4, 24) // → 'multipart/form-data'
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const decodeUtf8RangeView: (view: Buffer, start: number, end: number) => string =
|
|
104
|
+
runtimeCodec.decodeUtf8RangeView
|
|
105
|
+
? runtimeCodec.decodeUtf8RangeView
|
|
106
|
+
: (view, start, end) => decodeUtf8Range(view, start, end)
|
|
107
|
+
|
|
82
108
|
/**
|
|
83
109
|
* Decode UTF-8 bytes to a string, throwing on invalid UTF-8.
|
|
84
110
|
*
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/shared/memory.ts — Public memory-flush escape hatch.
|
|
2
|
+
//
|
|
3
|
+
// Impure boundary module: it owns no state of its own but reaches the cache
|
|
4
|
+
// owners (the global loader LRU and the process-wide MIME caches) to drop their
|
|
5
|
+
// retained entries, then optionally requests a runtime GC. The metrics registry
|
|
6
|
+
// is deliberately NOT flushed — an observability registry is not a cache, and
|
|
7
|
+
// clearing it would erase the counters an operator is watching.
|
|
8
|
+
|
|
9
|
+
import { loader } from '../loader'
|
|
10
|
+
import { rust } from '../rust-ffi'
|
|
11
|
+
import { clearMimeCaches } from '../rust-ffi/context'
|
|
12
|
+
import { forceGc } from './runtime'
|
|
13
|
+
|
|
14
|
+
/** Options for {@link flushMemory}. */
|
|
15
|
+
export interface FlushMemoryOptions {
|
|
16
|
+
/**
|
|
17
|
+
* When true (the default), also request a full garbage collection from the
|
|
18
|
+
* runtime: `Bun.gc(true)` on Bun, `globalThis.gc?.()` on Node when the
|
|
19
|
+
* process runs with `--expose-gc`. Set false to flush caches only.
|
|
20
|
+
*/
|
|
21
|
+
gc?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flush process-level caches and (by default) request a full GC. Intended for
|
|
26
|
+
* explicit memory-pressure handling — e.g. after a burst or before a snapshot.
|
|
27
|
+
*
|
|
28
|
+
* Clears:
|
|
29
|
+
* - the global loader LRU cache (`loader.clear()`),
|
|
30
|
+
* - the process-wide MIME caches (`clearMimeCaches()`),
|
|
31
|
+
* - the native compiled-schema cache (`rust.clearSchemaCache()`).
|
|
32
|
+
*
|
|
33
|
+
* The metrics registry is intentionally left untouched.
|
|
34
|
+
*
|
|
35
|
+
* Idempotent, and safe to call before the native addon has ever been loaded
|
|
36
|
+
* (every cache starts empty, the loader cache is a plain Map, and the native
|
|
37
|
+
* schema-cache clear is a defensive no-op when the addon/symbol is absent).
|
|
38
|
+
*
|
|
39
|
+
* @param options - `gc: false` skips the runtime GC request. Defaults to `{}`
|
|
40
|
+
* (GC requested).
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { flushMemory } from 'castrum'
|
|
44
|
+
*
|
|
45
|
+
* flushMemory() // drop caches + request GC
|
|
46
|
+
* flushMemory({ gc: false }) // drop caches only
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function flushMemory(options: FlushMemoryOptions = {}): void {
|
|
50
|
+
// Global loader LRU (createLoader() instances own their own caches and are
|
|
51
|
+
// not registered globally — flush the shared singleton only).
|
|
52
|
+
loader.clear()
|
|
53
|
+
|
|
54
|
+
// Process-wide MIME caches (bytes + string forms).
|
|
55
|
+
clearMimeCaches()
|
|
56
|
+
|
|
57
|
+
// Process-wide native compiled-schema cache. Defensive: an absent or stale
|
|
58
|
+
// addon (no `clearSchemaCache` symbol) must be a no-op, never a throw.
|
|
59
|
+
try {
|
|
60
|
+
rust.clearSchemaCache()
|
|
61
|
+
} catch {
|
|
62
|
+
// addon not loaded / stale build — nothing to clear.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.gc !== false) {
|
|
66
|
+
forceGc()
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/shared/metrics.ts
CHANGED
|
@@ -12,6 +12,27 @@ export const DEFAULT_BUCKETS: readonly number[] = [
|
|
|
12
12
|
0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
13
13
|
]
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Per-family cap on the number of distinct label sets (series) retained by a
|
|
17
|
+
* single counter/gauge/histogram. A metrics registry is keyed by caller-supplied
|
|
18
|
+
* label values, so an unbounded label (e.g. a raw path or user id) would grow a
|
|
19
|
+
* `Map` without limit. At the cap the oldest half of that family is evicted
|
|
20
|
+
* (insertion order) and the count is accumulated into the synthetic counter
|
|
21
|
+
* `castrum_metrics_series_dropped_total`, making the loss observable instead of
|
|
22
|
+
* silent. That name is RESERVED — a caller declaring a counter/gauge/histogram
|
|
23
|
+
* with it suppresses the synthetic emission (see {@link SERIES_DROPPED_METRIC}).
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_SERIES_PER_METRIC = 10_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reserved exposition name of the synthetic counter reporting series evicted by
|
|
29
|
+
* {@link MAX_SERIES_PER_METRIC}. Callers must not register a metric family with
|
|
30
|
+
* this name; if one exists, `render()` skips the synthetic emission so the
|
|
31
|
+
* scrape never carries two `# HELP`/`# TYPE` lines for one family (Prometheus
|
|
32
|
+
* rejects that).
|
|
33
|
+
*/
|
|
34
|
+
const SERIES_DROPPED_METRIC = 'castrum_metrics_series_dropped_total'
|
|
35
|
+
|
|
15
36
|
type LabelValues = readonly string[]
|
|
16
37
|
type Labels = Readonly<Record<string, string>>
|
|
17
38
|
|
|
@@ -29,6 +50,24 @@ function escapeLabel(v: string): string {
|
|
|
29
50
|
return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
30
51
|
}
|
|
31
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Once a series map is at {@link MAX_SERIES_PER_METRIC}, evict its oldest half
|
|
55
|
+
* (insertion order) and return the keys dropped; returns `[]` below the cap.
|
|
56
|
+
* Mirrors Elysia's `evictOldestHalf` generational policy: halving at the cap
|
|
57
|
+
* amortizes the eviction cost instead of paying a delete per insertion.
|
|
58
|
+
*/
|
|
59
|
+
function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
|
|
60
|
+
if (map.size < MAX_SERIES_PER_METRIC) return []
|
|
61
|
+
const drop = Math.ceil(map.size / 2)
|
|
62
|
+
const dropped: K[] = []
|
|
63
|
+
for (const key of map.keys()) {
|
|
64
|
+
if (dropped.length >= drop) break
|
|
65
|
+
dropped.push(key)
|
|
66
|
+
}
|
|
67
|
+
for (const key of dropped) map.delete(key)
|
|
68
|
+
return dropped
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
/** A single labelled counter value. */
|
|
33
72
|
export interface Counter {
|
|
34
73
|
/** Increment by 1 (or `by`). `labels` must match the label names exactly. */
|
|
@@ -97,6 +136,8 @@ export function createMetrics(): MetricsRegistry {
|
|
|
97
136
|
const counters = new Map<string, CounterEntry>()
|
|
98
137
|
const gauges = new Map<string, GaugeEntry>()
|
|
99
138
|
const histograms = new Map<string, HistogramEntry>()
|
|
139
|
+
/** Series evicted by the per-family cardinality cap, rendered as a counter. */
|
|
140
|
+
let seriesDropped = 0
|
|
100
141
|
|
|
101
142
|
const bucketUpper = (buckets: number[]): number[] => [
|
|
102
143
|
...buckets.map((b) => b),
|
|
@@ -144,6 +185,22 @@ export function createMetrics(): MetricsRegistry {
|
|
|
144
185
|
for (const e of counters.values()) out.push(...renderCounter(e))
|
|
145
186
|
for (const e of gauges.values()) out.push(...renderGauge(e))
|
|
146
187
|
for (const e of histograms.values()) out.push(...renderHistogram(e))
|
|
188
|
+
// Only emitted once a family has actually overflowed, so the output for a
|
|
189
|
+
// well-behaved registry is byte-identical to before the cap existed. Guard
|
|
190
|
+
// against a caller-registered family of the same (reserved) name — two
|
|
191
|
+
// `# HELP`/`# TYPE` lines for one family make the scrape invalid.
|
|
192
|
+
if (
|
|
193
|
+
seriesDropped > 0 &&
|
|
194
|
+
!counters.has(SERIES_DROPPED_METRIC) &&
|
|
195
|
+
!gauges.has(SERIES_DROPPED_METRIC) &&
|
|
196
|
+
!histograms.has(SERIES_DROPPED_METRIC)
|
|
197
|
+
) {
|
|
198
|
+
out.push(
|
|
199
|
+
`# HELP ${SERIES_DROPPED_METRIC} Total metric series evicted by the per-family cardinality cap.`,
|
|
200
|
+
)
|
|
201
|
+
out.push(`# TYPE ${SERIES_DROPPED_METRIC} counter`)
|
|
202
|
+
out.push(`${SERIES_DROPPED_METRIC} ${seriesDropped}`)
|
|
203
|
+
}
|
|
147
204
|
return `${out.join('\n')}\n`
|
|
148
205
|
}
|
|
149
206
|
|
|
@@ -151,6 +208,7 @@ export function createMetrics(): MetricsRegistry {
|
|
|
151
208
|
counters.clear()
|
|
152
209
|
gauges.clear()
|
|
153
210
|
histograms.clear()
|
|
211
|
+
seriesDropped = 0
|
|
154
212
|
}
|
|
155
213
|
|
|
156
214
|
return {
|
|
@@ -172,7 +230,14 @@ export function createMetrics(): MetricsRegistry {
|
|
|
172
230
|
names,
|
|
173
231
|
names.map((n) => labels?.[n] ?? ''),
|
|
174
232
|
)
|
|
175
|
-
|
|
233
|
+
const prev = entry.values.get(key)
|
|
234
|
+
if (prev === undefined) {
|
|
235
|
+
// New series: make room first (bounded family) and count evictions.
|
|
236
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
237
|
+
entry.values.set(key, by)
|
|
238
|
+
} else {
|
|
239
|
+
entry.values.set(key, prev + by)
|
|
240
|
+
}
|
|
176
241
|
},
|
|
177
242
|
}
|
|
178
243
|
},
|
|
@@ -192,14 +257,27 @@ export function createMetrics(): MetricsRegistry {
|
|
|
192
257
|
names,
|
|
193
258
|
names.map((n) => labels?.[n] ?? ''),
|
|
194
259
|
)
|
|
260
|
+
const add = (labels: Labels | undefined, delta: number): void => {
|
|
261
|
+
const key = keyOf(labels)
|
|
262
|
+
const prev = entry.values.get(key)
|
|
263
|
+
if (prev === undefined) {
|
|
264
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
265
|
+
entry.values.set(key, delta)
|
|
266
|
+
} else {
|
|
267
|
+
entry.values.set(key, prev + delta)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
195
270
|
return {
|
|
196
271
|
inc(labels, by = 1) {
|
|
197
|
-
|
|
272
|
+
add(labels, by)
|
|
198
273
|
},
|
|
199
274
|
dec(labels, by = 1) {
|
|
200
|
-
|
|
275
|
+
add(labels, -by)
|
|
201
276
|
},
|
|
202
277
|
set(labels, value) {
|
|
278
|
+
if (!entry.values.has(keyOf(labels))) {
|
|
279
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
280
|
+
}
|
|
203
281
|
entry.values.set(keyOf(labels), value)
|
|
204
282
|
},
|
|
205
283
|
}
|
|
@@ -225,6 +303,11 @@ export function createMetrics(): MetricsRegistry {
|
|
|
225
303
|
)
|
|
226
304
|
let counts = entry.counts.get(key)
|
|
227
305
|
if (!counts) {
|
|
306
|
+
// New series: bound the family and drop the matching sum entries
|
|
307
|
+
// (counts and sums always share the same key set).
|
|
308
|
+
const dropped = evictOldestHalf(entry.counts)
|
|
309
|
+
for (const k of dropped) entry.sums.delete(k)
|
|
310
|
+
seriesDropped += dropped.length
|
|
228
311
|
// One slot per bucket PLUS the implicit +Inf slot.
|
|
229
312
|
counts = new Array<number>(entry.buckets.length + 1).fill(0)
|
|
230
313
|
entry.counts.set(key, counts)
|
|
@@ -4,28 +4,85 @@
|
|
|
4
4
|
// don't have to hand-pack buffers or decode packed pairs:
|
|
5
5
|
// parseQueryString("a=1&b=2") // { a: "1", b: "2" }
|
|
6
6
|
// parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
|
|
7
|
+
//
|
|
8
|
+
// Transport: bun:ffi PRIMARY — the packed pairs are consumed SYNCHRONOUSLY
|
|
9
|
+
// (readPairsPacked + pairsToObject materialize strings before return), so a
|
|
10
|
+
// reused per-parser scratch is safe (same model as the scalar unpackers);
|
|
11
|
+
// the bytes never escape. Node / `CASTRUM_FFI_MODE=napi` / bind failure fall
|
|
12
|
+
// back to the napi addon (same wire output — pinned by packed-parsers-routing).
|
|
7
13
|
|
|
14
|
+
import { getBunFFI } from '../../native/ffi'
|
|
8
15
|
import { getAddon, lazyAddon } from '../../native'
|
|
9
16
|
import { encoder } from '../bytes'
|
|
10
17
|
import { pairsToObject, readPairsPacked } from './wire'
|
|
11
18
|
|
|
12
|
-
// Lazy: importing this module does not dlopen the addon until first use
|
|
19
|
+
// Lazy: importing this module does not dlopen the addon until first use
|
|
20
|
+
// (getBunFFI binds on first call; the napi proxy loads on first get).
|
|
13
21
|
const addon = lazyAddon(getAddon)
|
|
14
22
|
|
|
23
|
+
// FFI scratch (documented impure module state, per-Worker): grown lazily and
|
|
24
|
+
// reused across calls — the parsed result is a JS object, the packed bytes
|
|
25
|
+
// never escape.
|
|
26
|
+
const ffiCaches: Record<'query' | 'cookie' | 'form', Uint8Array | null> = {
|
|
27
|
+
query: null,
|
|
28
|
+
cookie: null,
|
|
29
|
+
form: null,
|
|
30
|
+
}
|
|
31
|
+
function scratchFor(kind: 'query' | 'cookie' | 'form', input: Uint8Array): Uint8Array {
|
|
32
|
+
// Packed pairs worst case ≈ 9× input (u32 len + bytes per key/value) + header.
|
|
33
|
+
const need = input.length * 9 + 16
|
|
34
|
+
let s = ffiCaches[kind]
|
|
35
|
+
if (s === null || s.length < need) {
|
|
36
|
+
s = new Uint8Array(need)
|
|
37
|
+
ffiCaches[kind] = s
|
|
38
|
+
}
|
|
39
|
+
return s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parsePackedInto(
|
|
43
|
+
kind: 'query' | 'cookie' | 'form',
|
|
44
|
+
input: Uint8Array,
|
|
45
|
+
): Record<string, string | string[]> | null {
|
|
46
|
+
const f = getBunFFI()
|
|
47
|
+
if (!f) return null
|
|
48
|
+
const out = scratchFor(kind, input)
|
|
49
|
+
const w =
|
|
50
|
+
kind === 'query'
|
|
51
|
+
? f.queryParsePackedInto(input, out)
|
|
52
|
+
: kind === 'cookie'
|
|
53
|
+
? f.cookieParsePackedInto(input, out)
|
|
54
|
+
: f.formParsePackedInto(input, out)
|
|
55
|
+
if (w === 0 || w > out.length) return null
|
|
56
|
+
return pairsToObject(readPairsPacked(out.subarray(0, w)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared FFI-first parse with an napi fallback (one encode, per kind). */
|
|
60
|
+
function parseVia(
|
|
61
|
+
kind: 'query' | 'cookie' | 'form',
|
|
62
|
+
input: Uint8Array,
|
|
63
|
+
): Record<string, string | string[]> {
|
|
64
|
+
const fast = parsePackedInto(kind, input)
|
|
65
|
+
if (fast) return fast
|
|
66
|
+
const packed =
|
|
67
|
+
kind === 'query'
|
|
68
|
+
? addon.queryParsePacked(input)
|
|
69
|
+
: kind === 'cookie'
|
|
70
|
+
? addon.cookieParsePacked(input)
|
|
71
|
+
: addon.formParsePacked(input)
|
|
72
|
+
return pairsToObject(readPairsPacked(packed))
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
/** Parse a query string (`a=1&b=2`) into an object via the native parser. */
|
|
16
76
|
export function parseQueryString(query: string): Record<string, string | string[]> {
|
|
17
|
-
|
|
18
|
-
return pairsToObject(readPairsPacked(packed))
|
|
77
|
+
return parseVia('query', encoder.encode(query))
|
|
19
78
|
}
|
|
20
79
|
|
|
21
80
|
/** Parse a cookie header (`a=1; b=2`) into an object via the native parser. */
|
|
22
81
|
export function parseCookieHeader(header: string): Record<string, string | string[]> {
|
|
23
|
-
|
|
24
|
-
return pairsToObject(readPairsPacked(packed))
|
|
82
|
+
return parseVia('cookie', encoder.encode(header))
|
|
25
83
|
}
|
|
26
84
|
|
|
27
85
|
/** Parse an application/x-www-form-urlencoded body into a key/value object. */
|
|
28
86
|
export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
|
|
29
|
-
|
|
30
|
-
return pairsToObject(readPairsPacked(packed))
|
|
87
|
+
return parseVia('form', body)
|
|
31
88
|
}
|
package/src/shared/runtime.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
detectedRuntime,
|
|
10
|
+
forceGc as forceGcDetected,
|
|
10
11
|
isBunRuntime,
|
|
11
12
|
isNodeRuntime,
|
|
12
13
|
nodeMajorVersion as nodeMajorVersionDetected,
|
|
@@ -27,3 +28,9 @@ export const runtimeName: () => RuntimeName = () => detectedRuntime
|
|
|
27
28
|
|
|
28
29
|
/** The Node.js major version when running under Node, otherwise `null`. */
|
|
29
30
|
export const nodeMajorVersion: () => number | null = nodeMajorVersionDetected
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Request a synchronous full GC when the runtime exposes one (`Bun.gc(true)` on
|
|
34
|
+
* Bun, `globalThis.gc?.()` under Node `--expose-gc`). A no-op otherwise.
|
|
35
|
+
*/
|
|
36
|
+
export const forceGc: () => void = forceGcDetected
|
package/src/task/runtime.ts
CHANGED
|
@@ -75,11 +75,7 @@ export interface TaskRuntime {
|
|
|
75
75
|
/** gzip-compress `data` on a pool thread. */
|
|
76
76
|
gzipCompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
|
|
77
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>
|
|
78
|
+
argon2Verify(password: Uint8Array, phc: Uint8Array, options?: TaskRunOptions): Promise<boolean>
|
|
83
79
|
/** PBKDF2-HMAC-SHA256 on a pool thread (10-200 ms CPU, 1-64 B output). */
|
|
84
80
|
pbkdf2Sha256(
|
|
85
81
|
password: Uint8Array,
|
|
@@ -156,6 +152,37 @@ function cancelledError(): Error {
|
|
|
156
152
|
return err
|
|
157
153
|
}
|
|
158
154
|
|
|
155
|
+
/** Native `taskSubmit*` return: the task was accepted onto the pool. */
|
|
156
|
+
const TASK_SUBMIT_ACCEPTED = 1
|
|
157
|
+
/** Native `taskSubmit*` return: the bounded work queue is full (overload). */
|
|
158
|
+
const TASK_SUBMIT_OVERLOADED = 2
|
|
159
|
+
|
|
160
|
+
/** Error raised when the bounded native task queue is at its admission bound. */
|
|
161
|
+
interface TaskOverloadedError extends Error {
|
|
162
|
+
/** Stable discriminant so callers can branch on overload. */
|
|
163
|
+
code: 'OVERLOADED'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Map a native `taskSubmit*` return code to the reason a task promise should
|
|
168
|
+
* reject with, or `null` when the submission was accepted. Pure and exported
|
|
169
|
+
* for tests, so the rejection mapping can be checked without driving the native
|
|
170
|
+
* pool into a real overload.
|
|
171
|
+
*
|
|
172
|
+
* @param code Native submit return: `1` accepted, `2` overloaded, else rejected.
|
|
173
|
+
* @returns The typed overload error, the generic rejection, or `null`.
|
|
174
|
+
*/
|
|
175
|
+
export function taskSubmitError(code: number): Error | null {
|
|
176
|
+
if (code === TASK_SUBMIT_ACCEPTED) return null
|
|
177
|
+
if (code === TASK_SUBMIT_OVERLOADED) {
|
|
178
|
+
const err = new Error('task rejected: native work queue is full') as TaskOverloadedError
|
|
179
|
+
err.name = 'TaskOverloadedError'
|
|
180
|
+
err.code = 'OVERLOADED'
|
|
181
|
+
return err
|
|
182
|
+
}
|
|
183
|
+
return new Error('task rejected by the native runtime')
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
/** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
|
|
160
187
|
interface Doorbell {
|
|
161
188
|
ptr: number | null
|
|
@@ -345,9 +372,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
345
372
|
} else if (status === TASK_STATUS_CANCELLED) {
|
|
346
373
|
deferred.reject(cancelledError())
|
|
347
374
|
} else {
|
|
348
|
-
deferred.reject(
|
|
349
|
-
new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'),
|
|
350
|
-
)
|
|
375
|
+
deferred.reject(new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'))
|
|
351
376
|
}
|
|
352
377
|
completed++
|
|
353
378
|
}
|
|
@@ -361,19 +386,20 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
361
386
|
queueMicrotask(drainNow)
|
|
362
387
|
}
|
|
363
388
|
|
|
364
|
-
function submit(
|
|
365
|
-
|
|
366
|
-
args: Uint8Array,
|
|
367
|
-
runOptions?: TaskRunOptions,
|
|
368
|
-
): Promise<Uint8Array> {
|
|
389
|
+
function submit(op: number, args: Uint8Array, runOptions?: TaskRunOptions): Promise<Uint8Array> {
|
|
390
|
+
ensureDoorbell()
|
|
369
391
|
const id = nextId++
|
|
370
392
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
371
393
|
inflight.set(id, { out: null, keep: null, resolve, reject })
|
|
372
394
|
retain()
|
|
373
|
-
|
|
395
|
+
const rejected = taskSubmitError(ffi.taskSubmit(op, args, id))
|
|
396
|
+
if (rejected) {
|
|
374
397
|
inflight.delete(id)
|
|
375
|
-
|
|
376
|
-
|
|
398
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
399
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
400
|
+
// its polling safety net) while other tasks are still pending.
|
|
401
|
+
if (inflight.size === 0) release()
|
|
402
|
+
reject(rejected)
|
|
377
403
|
return
|
|
378
404
|
}
|
|
379
405
|
const signal = runOptions?.signal
|
|
@@ -398,14 +424,19 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
398
424
|
output: Uint8Array,
|
|
399
425
|
runOptions?: TaskRunOptions,
|
|
400
426
|
): Promise<Uint8Array> {
|
|
427
|
+
ensureDoorbell()
|
|
401
428
|
const id = nextId++
|
|
402
429
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
403
430
|
inflight.set(id, { out: output, keep: null, resolve, reject })
|
|
404
431
|
retain()
|
|
405
|
-
|
|
432
|
+
const rejected = taskSubmitError(ffi.taskSubmitOut(op, args, id, output))
|
|
433
|
+
if (rejected) {
|
|
406
434
|
inflight.delete(id)
|
|
407
|
-
|
|
408
|
-
|
|
435
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
436
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
437
|
+
// its polling safety net) while other tasks are still pending.
|
|
438
|
+
if (inflight.size === 0) release()
|
|
439
|
+
reject(rejected)
|
|
409
440
|
return
|
|
410
441
|
}
|
|
411
442
|
const signal = runOptions?.signal
|
|
@@ -431,14 +462,19 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
431
462
|
data: Uint8Array,
|
|
432
463
|
runOptions?: TaskRunOptions,
|
|
433
464
|
): Promise<Uint8Array> {
|
|
465
|
+
ensureDoorbell()
|
|
434
466
|
const id = nextId++
|
|
435
467
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
436
468
|
inflight.set(id, { out: null, keep: data, resolve, reject })
|
|
437
469
|
retain()
|
|
438
|
-
|
|
470
|
+
const rejected = taskSubmitError(ffi.taskSubmitSlice(op, hdr, data, id))
|
|
471
|
+
if (rejected) {
|
|
439
472
|
inflight.delete(id)
|
|
440
|
-
|
|
441
|
-
|
|
473
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
474
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
475
|
+
// its polling safety net) while other tasks are still pending.
|
|
476
|
+
if (inflight.size === 0) release()
|
|
477
|
+
reject(rejected)
|
|
442
478
|
return
|
|
443
479
|
}
|
|
444
480
|
const signal = runOptions?.signal
|
|
@@ -453,19 +489,26 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
453
489
|
}
|
|
454
490
|
|
|
455
491
|
// One process-wide trampoline; Bun marshals calls from pool threads onto the
|
|
456
|
-
// JS thread. Rust coalesces so a burst rings it once.
|
|
492
|
+
// JS thread. Rust coalesces so a burst rings it once. Created lazily (and
|
|
493
|
+
// re-created after a shutdown) so the native side NEVER holds the address of
|
|
494
|
+
// a closed `JSCallback` — calling a freed trampoline is a use-after-free.
|
|
457
495
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
458
496
|
const { JSCallback } = require('bun:ffi') as typeof import('bun:ffi')
|
|
459
|
-
|
|
460
|
-
()
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
497
|
+
function ensureDoorbell(): void {
|
|
498
|
+
if (doorbell !== null) return
|
|
499
|
+
const cb = new JSCallback(
|
|
500
|
+
() => {
|
|
501
|
+
drainNow()
|
|
502
|
+
},
|
|
503
|
+
{ args: [], returns: 'void', threadsafe: true } as unknown as ConstructorParameters<
|
|
504
|
+
typeof JSCallback
|
|
505
|
+
>[1],
|
|
506
|
+
)
|
|
507
|
+
doorbell = cb
|
|
508
|
+
ffi.taskSetDoorbell(Number(cb.ptr))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
ensureDoorbell()
|
|
469
512
|
|
|
470
513
|
// A zero-copy result handoff via `toArrayBuffer(ptr, 0, len, dealloc)` was
|
|
471
514
|
// prototyped here and REVERTED: Bun 1.4.2's deallocator hook segfaults
|
|
@@ -492,8 +535,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
492
535
|
// UNSUPPORTED from an older addon) fall back, so behavior can't regress.
|
|
493
536
|
// Args are re-submitted on the retry step, so they own their memory.
|
|
494
537
|
const args = encodeGzipDecompressArgs(data, runOptions?.maxDecompressed)
|
|
495
|
-
const copyPath = (): Promise<Uint8Array> =>
|
|
496
|
-
submit(TASK_OP.gzipDecompress, args, runOptions)
|
|
538
|
+
const copyPath = (): Promise<Uint8Array> => submit(TASK_OP.gzipDecompress, args, runOptions)
|
|
497
539
|
const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
|
|
498
540
|
submitInto(TASK_OP.gzipDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
|
|
499
541
|
return zeroCopyOnce(size)
|
|
@@ -521,8 +563,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
521
563
|
},
|
|
522
564
|
brotliDecompress(data, runOptions) {
|
|
523
565
|
const args = encodeBrotliDecompressArgs(data, runOptions?.maxDecompressed)
|
|
524
|
-
const copyPath = (): Promise<Uint8Array> =>
|
|
525
|
-
submit(TASK_OP.brotliDecompress, args, runOptions)
|
|
566
|
+
const copyPath = (): Promise<Uint8Array> => submit(TASK_OP.brotliDecompress, args, runOptions)
|
|
526
567
|
// Brotli has no ISIZE trailer, so there is no exact size hint: start from
|
|
527
568
|
// a ratio guess and let the needed-size retry land it exactly (at most
|
|
528
569
|
// one extra pass; on anything but TOO_SMALL, fall back to the copy op).
|
|
@@ -557,6 +598,9 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
557
598
|
deferred.reject(cancelledError())
|
|
558
599
|
}
|
|
559
600
|
inflight.clear()
|
|
601
|
+
// Zero the native doorbell BEFORE closing the JS trampoline: a worker
|
|
602
|
+
// completing after this point must never call a freed callback.
|
|
603
|
+
ffi.taskSetDoorbell(0)
|
|
560
604
|
ffi.taskShutdown()
|
|
561
605
|
release()
|
|
562
606
|
doorbell?.close()
|