castrum 0.9.8 → 0.9.10
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 +11 -1
- package/Cargo.toml +17 -3
- 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 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/bun-builtins-decision-matrix.md +17 -7
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/crypto/base64.rs +472 -57
- package/rust/crypto/hashing.rs +68 -0
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/payload/sse.rs +78 -5
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/rust/util/text.rs +112 -10
- package/rust/util/validation.rs +34 -0
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
package/rust/task/runtime.rs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
use parking_lot::{Condvar, Mutex};
|
|
12
12
|
use std::collections::VecDeque;
|
|
13
|
-
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
13
|
+
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
|
14
14
|
use std::sync::OnceLock;
|
|
15
15
|
use std::time::Duration;
|
|
16
16
|
|
|
@@ -25,6 +25,11 @@ const SPIN_ROUNDS: u32 = 64;
|
|
|
25
25
|
/// Idle park timeout. A bounded wait (instead of an indefinite one) means a
|
|
26
26
|
/// missed wakeup costs at most this much latency and can never wedge a worker.
|
|
27
27
|
const PARK_TIMEOUT: Duration = Duration::from_micros(200);
|
|
28
|
+
/// Default admission bound for the work queue. A submission that would exceed
|
|
29
|
+
/// it is rejected (the C ABI returns `2 = overloaded`) instead of growing
|
|
30
|
+
/// memory without bound. Overridable with `CASTRUM_TASK_QUEUE_MAX` (minimum 1)
|
|
31
|
+
/// when the pool is first created.
|
|
32
|
+
pub const DEFAULT_QUEUE_MAX: usize = 4096;
|
|
28
33
|
|
|
29
34
|
/// A unit of offloaded work. Owns its input bytes (`'static`, `Send`).
|
|
30
35
|
pub type Job = Box<dyn FnOnce() + Send + 'static>;
|
|
@@ -35,6 +40,9 @@ pub struct TaskPool {
|
|
|
35
40
|
cv: Condvar,
|
|
36
41
|
running: AtomicBool,
|
|
37
42
|
threads: AtomicU32,
|
|
43
|
+
/// Admission bound for `queue`; read once when the pool is created (tests
|
|
44
|
+
/// override it through [`set_queue_max`]).
|
|
45
|
+
queue_max: AtomicUsize,
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
static POOL: OnceLock<TaskPool> = OnceLock::new();
|
|
@@ -46,6 +54,7 @@ impl TaskPool {
|
|
|
46
54
|
cv: Condvar::new(),
|
|
47
55
|
running: AtomicBool::new(false),
|
|
48
56
|
threads: AtomicU32::new(0),
|
|
57
|
+
queue_max: AtomicUsize::new(default_queue_max()),
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
|
|
@@ -121,9 +130,17 @@ impl TaskPool {
|
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
/// Push `job` only while the queue is below its admission bound. Returns
|
|
134
|
+
/// `false` when full — the caller drops the job (overload).
|
|
135
|
+
fn try_enqueue(&'static self, job: Job) -> bool {
|
|
136
|
+
let mut q = self.queue.lock();
|
|
137
|
+
if q.len() >= self.queue_max.load(Ordering::Relaxed) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
q.push_back(job);
|
|
141
|
+
drop(q);
|
|
126
142
|
self.cv.notify_one();
|
|
143
|
+
true
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
fn shutdown(&self) {
|
|
@@ -173,6 +190,16 @@ pub fn default_threads() -> usize {
|
|
|
173
190
|
})
|
|
174
191
|
}
|
|
175
192
|
|
|
193
|
+
/// Default admission bound: `CASTRUM_TASK_QUEUE_MAX` when set to a valid value
|
|
194
|
+
/// `>= 1`, else {@link DEFAULT_QUEUE_MAX}.
|
|
195
|
+
fn default_queue_max() -> usize {
|
|
196
|
+
std::env::var("CASTRUM_TASK_QUEUE_MAX")
|
|
197
|
+
.ok()
|
|
198
|
+
.and_then(|v| v.parse::<usize>().ok())
|
|
199
|
+
.filter(|&n| n > 0)
|
|
200
|
+
.unwrap_or(DEFAULT_QUEUE_MAX)
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
/// Start the pool with `threads` workers (`None`/`0` → {@link default_threads}).
|
|
177
204
|
///
|
|
178
205
|
/// Returns `0` when this call started the pool and `1` when it was already
|
|
@@ -190,8 +217,9 @@ pub fn init(threads: Option<u32>) -> u32 {
|
|
|
190
217
|
0
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
/// Enqueue `job`, auto-starting the pool on first use.
|
|
194
|
-
///
|
|
220
|
+
/// Enqueue `job`, auto-starting the pool on first use. Returns `false` (the C
|
|
221
|
+
/// ABI's `2 = overloaded`) when the bounded work queue is full — admission is
|
|
222
|
+
/// capped by `CASTRUM_TASK_QUEUE_MAX` (default {@link DEFAULT_QUEUE_MAX}).
|
|
195
223
|
pub fn submit<F>(job: F) -> bool
|
|
196
224
|
where
|
|
197
225
|
F: FnOnce() + Send + 'static,
|
|
@@ -201,8 +229,28 @@ where
|
|
|
201
229
|
let n = default_threads();
|
|
202
230
|
p.start(n);
|
|
203
231
|
}
|
|
204
|
-
p.
|
|
205
|
-
|
|
232
|
+
p.try_enqueue(Box::new(job))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Jobs queued but not yet started (monitoring / tests).
|
|
236
|
+
pub fn queue_depth() -> usize {
|
|
237
|
+
POOL.get_or_init(TaskPool::new).queue.lock().len()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Configured admission bound for the work queue (monitoring / tests).
|
|
241
|
+
pub fn queue_max() -> usize {
|
|
242
|
+
POOL.get_or_init(TaskPool::new)
|
|
243
|
+
.queue_max
|
|
244
|
+
.load(Ordering::Relaxed)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Override the admission bound. TEST ONLY: production reads
|
|
248
|
+
/// `CASTRUM_TASK_QUEUE_MAX` once when the pool is created.
|
|
249
|
+
#[cfg(test)]
|
|
250
|
+
pub fn set_queue_max(max: usize) {
|
|
251
|
+
POOL.get_or_init(TaskPool::new)
|
|
252
|
+
.queue_max
|
|
253
|
+
.store(max.max(1), Ordering::Relaxed);
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
/// Configured worker count (0 before the pool starts).
|
package/rust/task/tests.rs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// threads.
|
|
6
6
|
|
|
7
7
|
use parking_lot::Mutex;
|
|
8
|
-
use std::sync::atomic::{AtomicU32, Ordering};
|
|
8
|
+
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
|
9
|
+
use std::sync::Arc;
|
|
10
|
+
use std::time::Duration;
|
|
9
11
|
|
|
10
12
|
use super::completion::{self, Completion};
|
|
11
13
|
use super::ops::{
|
|
@@ -259,3 +261,98 @@ fn pool_starts_with_a_nonzero_thread_count() {
|
|
|
259
261
|
runtime::init(None);
|
|
260
262
|
assert!(runtime::threads() >= 1);
|
|
261
263
|
}
|
|
264
|
+
|
|
265
|
+
/// Block until `counter` reaches `target` (10 s guard).
|
|
266
|
+
fn wait_for_counter(counter: &AtomicUsize, target: usize) {
|
|
267
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
|
268
|
+
while counter.load(Ordering::SeqCst) < target {
|
|
269
|
+
assert!(
|
|
270
|
+
std::time::Instant::now() < deadline,
|
|
271
|
+
"counter never reached {target}"
|
|
272
|
+
);
|
|
273
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Block until the admission queue is empty (10 s guard).
|
|
278
|
+
fn wait_for_drained_queue() {
|
|
279
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
|
280
|
+
while runtime::queue_depth() != 0 {
|
|
281
|
+
assert!(std::time::Instant::now() < deadline, "queue never drained");
|
|
282
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// The queue is a hard-bounded admission gate: once every worker is occupied
|
|
287
|
+
/// and the queue holds `queue_max` jobs, further submits are rejected rather
|
|
288
|
+
/// than growing memory; releasing the workers restores capacity.
|
|
289
|
+
#[test]
|
|
290
|
+
fn queue_admission_is_bounded_and_recovers() {
|
|
291
|
+
let _g = LOCK.lock();
|
|
292
|
+
completion::clear();
|
|
293
|
+
runtime::init(None);
|
|
294
|
+
let workers = runtime::threads().max(1) as usize;
|
|
295
|
+
|
|
296
|
+
const CAP: usize = 4;
|
|
297
|
+
runtime::set_queue_max(CAP);
|
|
298
|
+
|
|
299
|
+
let started = Arc::new(AtomicUsize::new(0));
|
|
300
|
+
let finished = Arc::new(AtomicUsize::new(0));
|
|
301
|
+
let release = Arc::new(AtomicBool::new(false));
|
|
302
|
+
|
|
303
|
+
let make_blocker = || {
|
|
304
|
+
let started = Arc::clone(&started);
|
|
305
|
+
let finished = Arc::clone(&finished);
|
|
306
|
+
let release = Arc::clone(&release);
|
|
307
|
+
move || {
|
|
308
|
+
started.fetch_add(1, Ordering::SeqCst);
|
|
309
|
+
while !release.load(Ordering::SeqCst) {
|
|
310
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
311
|
+
}
|
|
312
|
+
finished.fetch_add(1, Ordering::SeqCst);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Occupy every worker, one submission at a time, waiting for each to START
|
|
317
|
+
// so it has left the queue. (Submitting them all at once would let one
|
|
318
|
+
// worker batch-pop several, which still occupies it but muddies the depth
|
|
319
|
+
// arithmetic this test asserts.)
|
|
320
|
+
for i in 0..workers {
|
|
321
|
+
assert!(
|
|
322
|
+
runtime::submit(make_blocker()),
|
|
323
|
+
"worker {i} submit rejected"
|
|
324
|
+
);
|
|
325
|
+
wait_for_counter(&started, i + 1);
|
|
326
|
+
}
|
|
327
|
+
assert_eq!(runtime::queue_depth(), 0);
|
|
328
|
+
assert_eq!(runtime::queue_max(), CAP);
|
|
329
|
+
|
|
330
|
+
// Every worker is busy: fill the queue to the cap.
|
|
331
|
+
for i in 0..CAP {
|
|
332
|
+
assert!(
|
|
333
|
+
runtime::submit(make_blocker()),
|
|
334
|
+
"queued submit {i} rejected"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
assert_eq!(runtime::queue_depth(), CAP);
|
|
338
|
+
|
|
339
|
+
// The cap is a HARD bound — the next submit is rejected, not queued.
|
|
340
|
+
assert!(
|
|
341
|
+
!runtime::submit(make_blocker()),
|
|
342
|
+
"submit accepted at the cap"
|
|
343
|
+
);
|
|
344
|
+
assert_eq!(runtime::queue_depth(), CAP);
|
|
345
|
+
|
|
346
|
+
// Release: the queue drains and admission recovers.
|
|
347
|
+
release.store(true, Ordering::SeqCst);
|
|
348
|
+
wait_for_counter(&finished, workers + CAP);
|
|
349
|
+
wait_for_drained_queue();
|
|
350
|
+
assert!(
|
|
351
|
+
runtime::submit(make_blocker()),
|
|
352
|
+
"capacity did not recover after drain"
|
|
353
|
+
);
|
|
354
|
+
wait_for_counter(&finished, workers + CAP + 1);
|
|
355
|
+
|
|
356
|
+
// Restore the default bound so a later test cannot inherit the tiny cap.
|
|
357
|
+
runtime::set_queue_max(4096);
|
|
358
|
+
}
|
package/rust/util/text.rs
CHANGED
|
@@ -14,23 +14,50 @@ use napi_derive::napi;
|
|
|
14
14
|
/// [`regex_escape_write`].
|
|
15
15
|
const REGEX_META: &[u8; 14] = b"\\.*+?^${}()|[]";
|
|
16
16
|
|
|
17
|
+
/// 256-entry membership lookup for [`REGEX_META`] — one table load per byte.
|
|
18
|
+
/// The previous per-byte `REGEX_META.contains(&c)` did a linear 14-element
|
|
19
|
+
/// scan PER byte (O(n·14)); a 16KB payload took ~100µs (~160MB/s) and the
|
|
20
|
+
/// win margin vs JS regex-replace collapsed with size. The LUT makes the hot
|
|
21
|
+
/// loop branch-predictable (~1 load/byte), and safe runs are copied in bulk
|
|
22
|
+
/// (memcpy) instead of one push per byte.
|
|
23
|
+
const REGEX_META_LUT: [u8; 256] = {
|
|
24
|
+
let mut t = [0u8; 256];
|
|
25
|
+
let mut i = 0usize;
|
|
26
|
+
while i < REGEX_META.len() {
|
|
27
|
+
t[REGEX_META[i] as usize] = 1;
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
t
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
#[inline(always)]
|
|
34
|
+
fn is_meta(b: u8) -> bool {
|
|
35
|
+
REGEX_META_LUT[b as usize] != 0
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/// Append the RegExp-escaped form of `input` to `out`. ASCII-only transform
|
|
18
39
|
/// (backslash before metachar bytes) — multi-byte UTF-8 sequences pass
|
|
19
40
|
/// through byte-exact because no UTF-8 continuation byte is a metachar.
|
|
41
|
+
/// Safe runs are bulk-copied (memcpy) between metachars, so throughput scales
|
|
42
|
+
/// with memory bandwidth, not the metachar-set size.
|
|
20
43
|
pub fn regex_escape_into(input: &[u8], out: &mut Vec<u8>) {
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
let mut run_start = 0usize;
|
|
45
|
+
for (i, &c) in input.iter().enumerate() {
|
|
46
|
+
if is_meta(c) {
|
|
47
|
+
out.extend_from_slice(&input[run_start..i]);
|
|
23
48
|
out.push(b'\\');
|
|
49
|
+
out.push(c);
|
|
50
|
+
run_start = i + 1;
|
|
24
51
|
}
|
|
25
|
-
out.push(c);
|
|
26
52
|
}
|
|
53
|
+
out.extend_from_slice(&input[run_start..]);
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
/// Exact escaped length (metachar count + input length) — lets callers size
|
|
30
57
|
/// output buffers without running the escape twice.
|
|
31
58
|
#[inline]
|
|
32
59
|
pub fn regex_escape_len(input: &[u8]) -> usize {
|
|
33
|
-
input.len() + input.iter().filter(
|
|
60
|
+
input.len() + input.iter().filter(|&&b| is_meta(b)).count()
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
/// Write the escaped form of `input` into `out` (which must be at least
|
|
@@ -38,15 +65,21 @@ pub fn regex_escape_len(input: &[u8]) -> usize {
|
|
|
38
65
|
/// written.
|
|
39
66
|
pub fn regex_escape_write(input: &[u8], out: &mut [u8]) -> usize {
|
|
40
67
|
let mut pos = 0usize;
|
|
41
|
-
|
|
42
|
-
|
|
68
|
+
let mut run_start = 0usize;
|
|
69
|
+
for (i, &c) in input.iter().enumerate() {
|
|
70
|
+
if is_meta(c) {
|
|
71
|
+
let run = &input[run_start..i];
|
|
72
|
+
out[pos..pos + run.len()].copy_from_slice(run);
|
|
73
|
+
pos += run.len();
|
|
43
74
|
out[pos] = b'\\';
|
|
44
|
-
pos
|
|
75
|
+
out[pos + 1] = c;
|
|
76
|
+
pos += 2;
|
|
77
|
+
run_start = i + 1;
|
|
45
78
|
}
|
|
46
|
-
out[pos] = c;
|
|
47
|
-
pos += 1;
|
|
48
79
|
}
|
|
49
|
-
|
|
80
|
+
let tail = &input[run_start..];
|
|
81
|
+
out[pos..pos + tail.len()].copy_from_slice(tail);
|
|
82
|
+
pos + tail.len()
|
|
50
83
|
}
|
|
51
84
|
|
|
52
85
|
/// Escape JS RegExp metacharacters in a string → a pattern that matches the
|
|
@@ -127,4 +160,73 @@ mod tests {
|
|
|
127
160
|
let escaped = String::from_utf8(out).expect("utf8");
|
|
128
161
|
assert_eq!(escaped, r"a\.c");
|
|
129
162
|
}
|
|
163
|
+
|
|
164
|
+
/// Reference escape (the previous per-byte `REGEX_META.contains` loop).
|
|
165
|
+
fn ref_escape(input: &[u8]) -> Vec<u8> {
|
|
166
|
+
const META: &[u8] = b"\\.*+?^${}()|[]";
|
|
167
|
+
let mut out = Vec::new();
|
|
168
|
+
for &c in input {
|
|
169
|
+
if META.contains(&c) {
|
|
170
|
+
out.push(b'\\');
|
|
171
|
+
}
|
|
172
|
+
out.push(c);
|
|
173
|
+
}
|
|
174
|
+
out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn regex_escape_matches_reference_for_all_lengths() {
|
|
179
|
+
// Every length 0..=96 plus larger payloads: the LUT + run-copy core
|
|
180
|
+
// (bulk memcpy between metachars) must be byte-identical to the naive
|
|
181
|
+
// per-byte reference. Lengths 15/16/17… straddle no SIMD chunk here
|
|
182
|
+
// (the transform is scalar), but the sweep locks positional behavior:
|
|
183
|
+
// a run-copy off-by-one (dropping or duplicating a safe byte) surfaces.
|
|
184
|
+
let mut lens: Vec<usize> = (0..=96).collect();
|
|
185
|
+
lens.extend([128, 512, 16 * 1024, 256 * 1024]);
|
|
186
|
+
for n in lens {
|
|
187
|
+
let input: Vec<u8> = (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect();
|
|
188
|
+
let expect = ref_escape(&input);
|
|
189
|
+
// allocating path
|
|
190
|
+
let mut out = Vec::new();
|
|
191
|
+
regex_escape_into(&input, &mut out);
|
|
192
|
+
assert_eq!(out, expect, "len {n}");
|
|
193
|
+
// len formula
|
|
194
|
+
assert_eq!(regex_escape_len(&input), expect.len(), "len {n}");
|
|
195
|
+
// write path (exact-size buffer, like the C-ABI caller)
|
|
196
|
+
let mut buf = vec![0u8; expect.len()];
|
|
197
|
+
let written = regex_escape_write(&input, &mut buf);
|
|
198
|
+
assert_eq!(written, expect.len(), "len {n}");
|
|
199
|
+
assert_eq!(&buf[..written], &expect[..], "len {n}");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[test]
|
|
204
|
+
fn regex_escape_large_payload_edges() {
|
|
205
|
+
// Three 256 KiB extremes: every byte a metachar (no bulk runs — the
|
|
206
|
+
// worst case for the run-copy core), no metachars at all (pure bulk
|
|
207
|
+
// copy), and a dense alternating pattern. All must match the reference
|
|
208
|
+
// and the exact-length contract.
|
|
209
|
+
let all_meta: Vec<u8> = (0..256 * 1024)
|
|
210
|
+
.map(|i| b"\\.*+?^${}()|[]"[i % 14])
|
|
211
|
+
.collect();
|
|
212
|
+
let none: Vec<u8> = (0..256 * 1024).map(|i| b"abcXYZ0123_-/"[i % 13]).collect();
|
|
213
|
+
let dense: Vec<u8> = (0..256 * 1024)
|
|
214
|
+
.map(|i| if i % 2 == 0 { b'.' } else { b'a' })
|
|
215
|
+
.collect();
|
|
216
|
+
for input in [all_meta, none, dense] {
|
|
217
|
+
let expect = ref_escape(&input);
|
|
218
|
+
let mut out = Vec::new();
|
|
219
|
+
regex_escape_into(&input, &mut out);
|
|
220
|
+
assert_eq!(out, expect);
|
|
221
|
+
assert_eq!(regex_escape_len(&input), expect.len());
|
|
222
|
+
let mut buf = vec![0u8; expect.len()];
|
|
223
|
+
let written = regex_escape_write(&input, &mut buf);
|
|
224
|
+
assert_eq!(written, expect.len());
|
|
225
|
+
assert_eq!(&buf[..written], &expect[..]);
|
|
226
|
+
}
|
|
227
|
+
// All-metachar bound: escaped length is exactly 2x (every byte gets a
|
|
228
|
+
// backslash); all-safe bound: escaped length equals input length.
|
|
229
|
+
assert_eq!(regex_escape_len(&vec![b'.'; 1024]), 2048);
|
|
230
|
+
assert_eq!(regex_escape_len(&vec![b'a'; 1024]), 1024);
|
|
231
|
+
}
|
|
130
232
|
}
|
package/rust/util/validation.rs
CHANGED
|
@@ -317,6 +317,40 @@ mod tests {
|
|
|
317
317
|
assert!(!validate_uuid_bytes(b"550e8400e29b41d4a716446655440000")); // no dashes
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/// Byte-for-byte pins the accept set: replacing ANY single hex position
|
|
321
|
+
/// (0..7, 9..12, 15..17, 20..22, 24..35) with a non-hex digit must reject,
|
|
322
|
+
/// and every one of the 32 hex positions must accept uppercase + lowercase
|
|
323
|
+
/// digits. Guards against a future rewrite (e.g. SIMD/run-split) silently
|
|
324
|
+
/// dropping or mis-scoping a position — measured perf-neutral, kept as a
|
|
325
|
+
/// spec-lock regression net.
|
|
326
|
+
#[test]
|
|
327
|
+
fn validate_uuid_hex_run_coverage() {
|
|
328
|
+
let dash = |i: usize| matches!(i, 8 | 13 | 18 | 23);
|
|
329
|
+
for pos in 0..36 {
|
|
330
|
+
let valid = b"550e8400-e29b-41d4-a716-446655440000";
|
|
331
|
+
if dash(pos) {
|
|
332
|
+
continue; // dash positions are pinned by their own check
|
|
333
|
+
}
|
|
334
|
+
// every non-dash position is a hex position (version/variant cols
|
|
335
|
+
// included — 14 must stay '4', 19 must stay in 8/9/a/b set)
|
|
336
|
+
if valid[pos].is_ascii_hexdigit() {
|
|
337
|
+
let mut upper = *valid;
|
|
338
|
+
upper[pos] = valid[pos].to_ascii_uppercase();
|
|
339
|
+
assert!(validate_uuid_bytes(&upper), "uppercase at {pos}");
|
|
340
|
+
}
|
|
341
|
+
let mut bad = *valid;
|
|
342
|
+
bad[pos] = b'g'; // 'g' is not a hex digit
|
|
343
|
+
assert!(!validate_uuid_bytes(&bad), "g at {pos}");
|
|
344
|
+
}
|
|
345
|
+
// and the version / variant columns still reject wrong values
|
|
346
|
+
let mut v = *b"550e8400-e29b-41d4-a716-446655440000";
|
|
347
|
+
v[14] = b'5';
|
|
348
|
+
assert!(!validate_uuid_bytes(&v));
|
|
349
|
+
v = *b"550e8400-e29b-41d4-a716-446655440000";
|
|
350
|
+
v[19] = b'c';
|
|
351
|
+
assert!(!validate_uuid_bytes(&v));
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
#[test]
|
|
321
355
|
fn validate_ipv4_cases() {
|
|
322
356
|
assert!(validate_ipv4_bytes(b"192.168.0.1"));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/ingress/abort.ts — PURE request-cancellation helpers.
|
|
2
|
+
//
|
|
3
|
+
// The async ingress reads `Request.signal` (fired when the client disconnects)
|
|
4
|
+
// at its genuine async boundaries. A body read or a route wrapper that observes
|
|
5
|
+
// an abort returns the cancelled-client response instead of running more work.
|
|
6
|
+
// PURE: no addon / pool imports — safe for the route factories.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `err.code` value a body read carries when it is rejected because the
|
|
10
|
+
* client disconnected (`Request.signal` aborted).
|
|
11
|
+
*/
|
|
12
|
+
export const ABORT_CODE = 'REQUEST_ABORTED'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a thrown value is the cancellation error produced when `req.signal`
|
|
16
|
+
* aborts during a body read (see `readBodyWithLimit` in `./body`).
|
|
17
|
+
*
|
|
18
|
+
* @param err - The caught value.
|
|
19
|
+
* @returns `true` when `err.code === 'REQUEST_ABORTED'`.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* try {
|
|
23
|
+
* await readBodyWithLimit(req, 1 << 20, true)
|
|
24
|
+
* } catch (err) {
|
|
25
|
+
* if (isAbortError(err)) return abortResponse()
|
|
26
|
+
* throw err
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function isAbortError(err: unknown): boolean {
|
|
31
|
+
return (err as { code?: unknown } | null | undefined)?.code === ABORT_CODE
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the cancelled-client response: status **499** ("Client Closed
|
|
36
|
+
* Request", a de-facto nginx status) with no body. This is the only new
|
|
37
|
+
* outcome added by cancellation — it never replaces an existing success or
|
|
38
|
+
* error status.
|
|
39
|
+
*
|
|
40
|
+
* @returns A bodyless `Response` with status 499.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* if (req.signal?.aborted) return abortResponse()
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function abortResponse(): Response {
|
|
47
|
+
return new Response(null, { status: 499 })
|
|
48
|
+
}
|
package/src/ingress/body.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// API.
|
|
3
3
|
|
|
4
4
|
import { isBun } from '../shared/runtime'
|
|
5
|
+
import { ABORT_CODE } from './abort'
|
|
5
6
|
|
|
6
7
|
const EMPTY_BODY = new Uint8Array(0)
|
|
7
8
|
|
|
@@ -65,6 +66,59 @@ function bodyTooLargeError(): Error & { code?: string } {
|
|
|
65
66
|
return err
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function abortError(): Error & { code?: string } {
|
|
70
|
+
const err = new Error(ABORT_CODE) as Error & { code?: string }
|
|
71
|
+
err.code = ABORT_CODE
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reject when `signal` is already aborted (the entry guard for every path). */
|
|
76
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
77
|
+
if (signal?.aborted) throw abortError()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A reusable rejection promise + listener-cleanup pair for a read race. */
|
|
81
|
+
interface ReadGuard {
|
|
82
|
+
promise: Promise<never>
|
|
83
|
+
cancel: () => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Track `req.signal` as a rejection racer: settles with a `REQUEST_ABORTED`
|
|
88
|
+
* error when the client disconnects. Returns `null` when the request carries no
|
|
89
|
+
* signal, so runtimes without `Request.signal` are unaffected. The
|
|
90
|
+
* `{ once: true }` listener self-removes when it fires; `cancel()` detaches it
|
|
91
|
+
* on the read-completed-first path so no listener outlives the read.
|
|
92
|
+
*/
|
|
93
|
+
function createAbortGuard(signal: AbortSignal | undefined): ReadGuard | null {
|
|
94
|
+
if (!signal) return null
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
return { promise: Promise.reject(abortError()), cancel: () => {} }
|
|
97
|
+
}
|
|
98
|
+
let listener: (() => void) | null = null
|
|
99
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
100
|
+
listener = () => reject(abortError())
|
|
101
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
102
|
+
})
|
|
103
|
+
return {
|
|
104
|
+
promise,
|
|
105
|
+
cancel: () => {
|
|
106
|
+
if (listener !== null) {
|
|
107
|
+
signal.removeEventListener('abort', listener)
|
|
108
|
+
listener = null
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Race a read against guard rejections (a plain await when there are none). */
|
|
115
|
+
function raceRead<T>(read: Promise<T>, guards: ReadonlyArray<ReadGuard>): Promise<T> {
|
|
116
|
+
if (guards.length === 0) return read
|
|
117
|
+
const racers: Promise<unknown>[] = [read]
|
|
118
|
+
for (const guard of guards) racers.push(guard.promise)
|
|
119
|
+
return Promise.race(racers) as Promise<T>
|
|
120
|
+
}
|
|
121
|
+
|
|
68
122
|
// ── Shared body-read deadline watchdog ──────────────────────────────────────
|
|
69
123
|
// One interval timer sweeps ALL in-flight body reads that carry a deadline,
|
|
70
124
|
// replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
|
|
@@ -145,6 +199,9 @@ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel:
|
|
|
145
199
|
* (thrown as soon as the limit is crossed — the body is never fully
|
|
146
200
|
* buffered first, which is the slowloris/large-body protection).
|
|
147
201
|
* - `REQUEST_TIMEOUT` when `timeoutMs` elapses before the body completes.
|
|
202
|
+
* - `REQUEST_ABORTED` when `req.signal` aborts (client disconnect) before the
|
|
203
|
+
* read settles — including an already-aborted signal at entry. The race's
|
|
204
|
+
* abort listener is removed once the read settles.
|
|
148
205
|
*/
|
|
149
206
|
export async function readBodyWithLimit(
|
|
150
207
|
req: Request,
|
|
@@ -152,6 +209,10 @@ export async function readBodyWithLimit(
|
|
|
152
209
|
guard: boolean,
|
|
153
210
|
timeoutMs?: number,
|
|
154
211
|
): Promise<Uint8Array> {
|
|
212
|
+
// Client disconnect before any read work: reject immediately (also covers an
|
|
213
|
+
// already-aborted signal on every downstream path).
|
|
214
|
+
throwIfAborted(req.signal)
|
|
215
|
+
|
|
155
216
|
const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
|
|
156
217
|
|
|
157
218
|
// Fast path: a declared Content-Length lets us PROVE the body fits the
|
|
@@ -173,11 +234,34 @@ export async function readBodyWithLimit(
|
|
|
173
234
|
// path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
|
|
174
235
|
// body directly, avoiding a lazily-constructed stream per write request.
|
|
175
236
|
if (deadline <= 0) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
237
|
+
// No deadline: use the same buffered fast path as the deadline branch.
|
|
238
|
+
// Under Bun a small declared-length body is usually already buffered —
|
|
239
|
+
// `Bun.peek` returns the bytes synchronously, so the common case skips
|
|
240
|
+
// the abort race + microtask entirely. Only a genuinely pending read
|
|
241
|
+
// (slow/trickling body) or a runtime without `req.bytes()` (Node) pays
|
|
242
|
+
// the abort race.
|
|
243
|
+
const buffered = peekBufferedBody(req)
|
|
244
|
+
if (buffered !== false && 'bytes' in buffered) {
|
|
245
|
+
throwIfAborted(req.signal)
|
|
246
|
+
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
247
|
+
throw bodyTooLargeError()
|
|
248
|
+
}
|
|
249
|
+
return buffered.bytes
|
|
250
|
+
}
|
|
251
|
+
const abort = createAbortGuard(req.signal)
|
|
252
|
+
try {
|
|
253
|
+
const bytes = await raceRead(
|
|
254
|
+
buffered === false ? readBodyBytes(req) : buffered,
|
|
255
|
+
abort !== null ? [abort] : [],
|
|
256
|
+
)
|
|
257
|
+
throwIfAborted(req.signal)
|
|
258
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
259
|
+
throw bodyTooLargeError()
|
|
260
|
+
}
|
|
261
|
+
return bytes
|
|
262
|
+
} finally {
|
|
263
|
+
abort?.cancel()
|
|
179
264
|
}
|
|
180
|
-
return bytes
|
|
181
265
|
}
|
|
182
266
|
// Deadline path. Under Bun, a small declared-length body is usually
|
|
183
267
|
// already buffered — `Bun.peek` returns the bytes synchronously, so we
|
|
@@ -187,31 +271,38 @@ export async function readBodyWithLimit(
|
|
|
187
271
|
const buffered = peekBufferedBody(req)
|
|
188
272
|
if (buffered === false) {
|
|
189
273
|
const dl = createDeadline(deadline)
|
|
274
|
+
const abort = createAbortGuard(req.signal)
|
|
190
275
|
try {
|
|
191
|
-
const bytes = await
|
|
276
|
+
const bytes = await raceRead(readBodyBytes(req), abort !== null ? [dl, abort] : [dl])
|
|
277
|
+
throwIfAborted(req.signal)
|
|
192
278
|
if (guard && bytes.byteLength > maxBytes) {
|
|
193
279
|
throw bodyTooLargeError()
|
|
194
280
|
}
|
|
195
281
|
return bytes
|
|
196
282
|
} finally {
|
|
197
283
|
dl.cancel()
|
|
284
|
+
abort?.cancel()
|
|
198
285
|
}
|
|
199
286
|
}
|
|
200
287
|
if ('bytes' in buffered) {
|
|
288
|
+
throwIfAborted(req.signal)
|
|
201
289
|
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
202
290
|
throw bodyTooLargeError()
|
|
203
291
|
}
|
|
204
292
|
return buffered.bytes
|
|
205
293
|
}
|
|
206
294
|
const dl = createDeadline(deadline)
|
|
295
|
+
const abort = createAbortGuard(req.signal)
|
|
207
296
|
try {
|
|
208
|
-
const bytes = await
|
|
297
|
+
const bytes = await raceRead(buffered, abort !== null ? [dl, abort] : [dl])
|
|
298
|
+
throwIfAborted(req.signal)
|
|
209
299
|
if (guard && bytes.byteLength > maxBytes) {
|
|
210
300
|
throw bodyTooLargeError()
|
|
211
301
|
}
|
|
212
302
|
return bytes
|
|
213
303
|
} finally {
|
|
214
304
|
dl.cancel()
|
|
305
|
+
abort?.cancel()
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
308
|
}
|
|
@@ -220,6 +311,7 @@ export async function readBodyWithLimit(
|
|
|
220
311
|
// is only touched here — the declared-length fast path never needs it.
|
|
221
312
|
const body = req.body
|
|
222
313
|
if (!body) {
|
|
314
|
+
throwIfAborted(req.signal)
|
|
223
315
|
return EMPTY_BODY
|
|
224
316
|
}
|
|
225
317
|
|
|
@@ -231,11 +323,14 @@ export async function readBodyWithLimit(
|
|
|
231
323
|
// per request. It is still required when the loop exits early (timeout/error).
|
|
232
324
|
let completed = false
|
|
233
325
|
const dl = deadline > 0 ? createDeadline(deadline) : null
|
|
326
|
+
const abort = createAbortGuard(req.signal)
|
|
327
|
+
const guards: ReadGuard[] = []
|
|
328
|
+
if (dl !== null) guards.push(dl)
|
|
329
|
+
if (abort !== null) guards.push(abort)
|
|
234
330
|
|
|
235
331
|
try {
|
|
236
332
|
for (;;) {
|
|
237
|
-
const { done, value } =
|
|
238
|
-
dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
|
|
333
|
+
const { done, value } = await raceRead(reader.read(), guards)
|
|
239
334
|
|
|
240
335
|
if (done) {
|
|
241
336
|
completed = true
|
|
@@ -253,6 +348,7 @@ export async function readBodyWithLimit(
|
|
|
253
348
|
}
|
|
254
349
|
} finally {
|
|
255
350
|
dl?.cancel()
|
|
351
|
+
abort?.cancel()
|
|
256
352
|
// Release the underlying stream. Cancelling while a read() is still
|
|
257
353
|
// pending (the timeout case) can resolve that pending read() as `done`
|
|
258
354
|
// and RACE the timeout rejection — so reject first, cancel only AFTER
|
package/src/ingress/handlers.ts
CHANGED
|
@@ -94,6 +94,7 @@ export type {
|
|
|
94
94
|
CreateIngressServerOptions,
|
|
95
95
|
GracefulShutdownOptions,
|
|
96
96
|
ServerHandle,
|
|
97
|
+
StaticRoute,
|
|
97
98
|
} from './server'
|
|
98
99
|
export { createIngressServer, gracefulShutdown } from './server'
|
|
99
100
|
export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|