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
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
// `parseQuery` + a cookie pair section iff `parseCookies`. Pair sections are
|
|
27
27
|
// `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
|
|
28
28
|
|
|
29
|
-
use std::borrow::Cow;
|
|
30
29
|
use std::sync::Arc;
|
|
31
30
|
|
|
32
31
|
use super::packed::{read_section, read_u32_at};
|
|
@@ -254,28 +253,30 @@ impl NativeRoute {
|
|
|
254
253
|
}
|
|
255
254
|
}
|
|
256
255
|
|
|
257
|
-
// ──
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
256
|
+
// ── Assemble the result in ONE streaming pass ───────────────
|
|
257
|
+
// Pair sections are decoded + written as they are walked. The old
|
|
258
|
+
// shape sized them first and walked again, which decoded every escaped
|
|
259
|
+
// segment TWICE and allocated twice per segment in BOTH passes.
|
|
260
|
+
// The reused `scratch` holds the decoded bytes of one segment at a time.
|
|
261
|
+
let mut scratch: Vec<u8> = Vec::new();
|
|
262
|
+
let mut w = ResultWriter::new(out, RESULT_HEADER_LEN);
|
|
263
|
+
let mut query_capped = false;
|
|
264
|
+
let mut cookie_capped = false;
|
|
262
265
|
if self.parse_query {
|
|
263
|
-
|
|
264
|
-
let (size, capped) = query_section_size(query, self.max_pairs);
|
|
265
|
-
query_size = size;
|
|
266
|
-
query_valid = !over && !capped;
|
|
266
|
+
query_capped = write_query_section(&mut w, &mut scratch, query, self.max_pairs);
|
|
267
267
|
}
|
|
268
268
|
if self.parse_cookies {
|
|
269
|
-
|
|
270
|
-
let (size, capped) = cookie_section_size(cookie, self.max_pairs);
|
|
271
|
-
cookie_size = size;
|
|
272
|
-
cookie_valid = !over && !capped;
|
|
269
|
+
cookie_capped = write_cookie_section(&mut w, cookie, self.max_pairs);
|
|
273
270
|
}
|
|
274
271
|
// validateQuery/validateCookies without a schema are no-ops (the parse
|
|
275
|
-
// VALID bit
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
272
|
+
// VALID bit is the verdict); with a schema, compile would have rejected
|
|
273
|
+
// the descriptor → the caller fell back to JS. A section that is not
|
|
274
|
+
// parsed never reports VALID.
|
|
275
|
+
let query_valid = self.parse_query && !query_capped && query.len() <= self.max_query_bytes;
|
|
276
|
+
let cookie_valid =
|
|
277
|
+
self.parse_cookies && !cookie_capped && cookie.len() <= self.max_cookie_bytes;
|
|
278
|
+
|
|
279
|
+
// ── Verdict flags + header (committed LAST) ─────────────────
|
|
279
280
|
let mut result_flags: u32 = 0;
|
|
280
281
|
if error_code == 0 {
|
|
281
282
|
result_flags |= ROUTE_RESULT_FLAG_OK;
|
|
@@ -292,25 +293,10 @@ impl NativeRoute {
|
|
|
292
293
|
if body_valid {
|
|
293
294
|
result_flags |= ROUTE_RESULT_FLAG_BODY_VALID;
|
|
294
295
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
let mut w = 0usize;
|
|
302
|
-
// NOTE: pass the FULL `out` slice (not `&mut out[w..]`) — `w` is the
|
|
303
|
-
// absolute write position, so re-slicing would double-apply the offset
|
|
304
|
-
// and scatter the header across the buffer.
|
|
305
|
-
write_u32(out, &mut w, result_flags);
|
|
306
|
-
write_u32(out, &mut w, error_code);
|
|
307
|
-
if self.parse_query {
|
|
308
|
-
write_query_section(out, &mut w, query, self.max_pairs);
|
|
309
|
-
}
|
|
310
|
-
if self.parse_cookies {
|
|
311
|
-
write_cookie_section(out, &mut w, cookie, self.max_pairs);
|
|
312
|
-
}
|
|
313
|
-
Ok(w)
|
|
296
|
+
let mut header = [0u8; RESULT_HEADER_LEN];
|
|
297
|
+
header[..4].copy_from_slice(&result_flags.to_le_bytes());
|
|
298
|
+
header[4..].copy_from_slice(&error_code.to_le_bytes());
|
|
299
|
+
Ok(w.commit(header))
|
|
314
300
|
}
|
|
315
301
|
}
|
|
316
302
|
|
|
@@ -347,143 +333,148 @@ fn query_pairs(query: &[u8]) -> impl Iterator<Item = Pair<'_>> + '_ {
|
|
|
347
333
|
/// LENIENT segment decode (matches JS `decodeSegment`): `+` → space, `%XX` →
|
|
348
334
|
/// byte, result must be valid UTF-8; on ANY failure the WHOLE original segment
|
|
349
335
|
/// is returned unchanged (with `+` AND `%` intact — the JS catch returns `s`).
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
336
|
+
///
|
|
337
|
+
/// Decodes into a caller-provided `scratch` (reused across every segment and
|
|
338
|
+
/// pair of the call) so the hot path NEVER allocates per segment — the old
|
|
339
|
+
/// implementation built `Vec::with_capacity` for the `+` replacement AND
|
|
340
|
+
/// another for the percent decode, i.e. two allocations per escaped segment,
|
|
341
|
+
/// twice per call (size pass + write pass).
|
|
342
|
+
///
|
|
343
|
+
/// The slice returned borrows either `seg` (fast path / lenient fallback) or
|
|
344
|
+
/// `scratch`; it is valid only until the next call with the same scratch.
|
|
345
|
+
#[inline]
|
|
346
|
+
fn decode_segment_scratch<'a>(seg: &'a [u8], scratch: &'a mut Vec<u8>) -> &'a [u8] {
|
|
347
|
+
// Semantics live in ONE place (`util::bytes`) so the route stack and the
|
|
348
|
+
// packed pair parsers can never disagree: `+` → space, `%XX` → byte, and a
|
|
349
|
+
// malformed escape or an invalid-UTF-8 result falls back to the WHOLE
|
|
350
|
+
// original segment (JS `try { decodeURIComponent(...) } catch { return s }`).
|
|
351
|
+
crate::util::bytes::decode_form_component_scratch(seg, scratch)
|
|
362
352
|
}
|
|
363
353
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
354
|
+
// ── Streaming result writer (ONE pass, zero per-segment alloc) ─────
|
|
355
|
+
|
|
356
|
+
/// Bytes reserved at the head of the result for the `[flags u32][error u32]`
|
|
357
|
+
/// verdict header.
|
|
358
|
+
const RESULT_HEADER_LEN: usize = 8;
|
|
359
|
+
|
|
360
|
+
/// Streaming result writer: appends bytes while tracking the EXACT required
|
|
361
|
+
/// size, so a single pass produces both the packed result AND the needed-size
|
|
362
|
+
/// answer. The previous shape walked every pair section TWICE (a sizing pass
|
|
363
|
+
/// and a write pass), decoding every escaped segment in both.
|
|
364
|
+
///
|
|
365
|
+
/// Once the caller's buffer proves too small the writer stops copying (and
|
|
366
|
+
/// stops patching) but KEEPS counting `needed`, so the needed-size convention
|
|
367
|
+
/// still reports the exact size from the same pass. The verdict header is
|
|
368
|
+
/// committed LAST, so a too-small buffer is left untouched — the contract
|
|
369
|
+
/// `run()`'s callers rely on.
|
|
370
|
+
struct ResultWriter<'a> {
|
|
371
|
+
out: &'a mut [u8],
|
|
372
|
+
pos: usize,
|
|
373
|
+
needed: usize,
|
|
374
|
+
full: bool,
|
|
369
375
|
}
|
|
370
376
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
out.
|
|
390
|
-
|
|
377
|
+
impl<'a> ResultWriter<'a> {
|
|
378
|
+
#[inline]
|
|
379
|
+
fn new(out: &'a mut [u8], header_len: usize) -> Self {
|
|
380
|
+
Self {
|
|
381
|
+
full: out.len() < header_len,
|
|
382
|
+
out,
|
|
383
|
+
pos: header_len,
|
|
384
|
+
needed: header_len,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// Append `bytes`, tracking the required size even after the buffer is full.
|
|
389
|
+
#[inline]
|
|
390
|
+
fn bytes(&mut self, bytes: &[u8]) {
|
|
391
|
+
self.needed += bytes.len();
|
|
392
|
+
if !self.full {
|
|
393
|
+
let end = self.pos + bytes.len();
|
|
394
|
+
if end <= self.out.len() {
|
|
395
|
+
self.out[self.pos..end].copy_from_slice(bytes);
|
|
396
|
+
self.pos = end;
|
|
397
|
+
} else {
|
|
398
|
+
self.full = true;
|
|
391
399
|
}
|
|
392
400
|
}
|
|
393
401
|
}
|
|
394
|
-
std::str::from_utf8(&out).ok()?;
|
|
395
|
-
Some(out)
|
|
396
|
-
}
|
|
397
402
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
match b {
|
|
402
|
-
b'0'..=b'9' => Some(b - b'0'),
|
|
403
|
-
b'a'..=b'f' => Some(b - b'a' + 10),
|
|
404
|
-
b'A'..=b'F' => Some(b - b'A' + 10),
|
|
405
|
-
_ => None,
|
|
403
|
+
#[inline]
|
|
404
|
+
fn u32(&mut self, value: u32) {
|
|
405
|
+
self.bytes(&value.to_le_bytes());
|
|
406
406
|
}
|
|
407
|
-
}
|
|
408
407
|
|
|
409
|
-
|
|
408
|
+
/// Append `[u32 len][bytes]` — the pair-section element layout.
|
|
409
|
+
#[inline]
|
|
410
|
+
fn len_prefixed(&mut self, bytes: &[u8]) {
|
|
411
|
+
self.u32(bytes.len() as u32);
|
|
412
|
+
self.bytes(bytes);
|
|
413
|
+
}
|
|
410
414
|
|
|
411
|
-
///
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if max_pairs > 0 && count >= max_pairs {
|
|
417
|
-
capped = true;
|
|
418
|
-
break;
|
|
415
|
+
/// Patch a previously reserved u32 (the section pair count) in place.
|
|
416
|
+
#[inline]
|
|
417
|
+
fn patch_u32(&mut self, at: usize, value: u32) {
|
|
418
|
+
if !self.full && at + 4 <= self.out.len() {
|
|
419
|
+
self.out[at..at + 4].copy_from_slice(&value.to_le_bytes());
|
|
419
420
|
}
|
|
420
|
-
size += 4 + decode_segment_len(pair.name) + 4 + decode_segment_len(pair.value);
|
|
421
421
|
}
|
|
422
|
-
(size, capped)
|
|
423
|
-
}
|
|
424
422
|
|
|
425
|
-
///
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
capped = true;
|
|
432
|
-
break;
|
|
423
|
+
/// Commit the verdict header and return the bytes written — or, when the
|
|
424
|
+
/// buffer was too small, the EXACT required size (nothing committed).
|
|
425
|
+
#[inline]
|
|
426
|
+
fn commit(self, header: [u8; RESULT_HEADER_LEN]) -> usize {
|
|
427
|
+
if self.full || self.needed > self.out.len() {
|
|
428
|
+
return self.needed;
|
|
433
429
|
}
|
|
434
|
-
|
|
430
|
+
self.out[..RESULT_HEADER_LEN].copy_from_slice(&header);
|
|
431
|
+
self.pos
|
|
435
432
|
}
|
|
436
|
-
(size, capped)
|
|
437
433
|
}
|
|
438
434
|
|
|
439
|
-
/// Write
|
|
440
|
-
|
|
441
|
-
fn
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
/// Write the query pair section (lenient-decoded names/values).
|
|
450
|
-
fn write_query_section(out: &mut [u8], pos: &mut usize, query: &[u8], max_pairs: usize) {
|
|
451
|
-
let count_pos = *pos;
|
|
452
|
-
write_u32(out, pos, 0); // count placeholder
|
|
435
|
+
/// Write the query pair section (lenient-decoded names/values) in ONE pass.
|
|
436
|
+
/// Returns `true` when `max_pairs` capped the section (→ not valid).
|
|
437
|
+
fn write_query_section(
|
|
438
|
+
w: &mut ResultWriter<'_>,
|
|
439
|
+
scratch: &mut Vec<u8>,
|
|
440
|
+
query: &[u8],
|
|
441
|
+
max_pairs: usize,
|
|
442
|
+
) -> bool {
|
|
443
|
+
let count_pos = w.pos;
|
|
444
|
+
w.u32(0); // count placeholder, patched below
|
|
453
445
|
let mut count = 0usize;
|
|
446
|
+
let mut capped = false;
|
|
454
447
|
for pair in query_pairs(query) {
|
|
455
448
|
if max_pairs > 0 && count >= max_pairs {
|
|
449
|
+
capped = true;
|
|
456
450
|
break;
|
|
457
451
|
}
|
|
458
|
-
|
|
459
|
-
|
|
452
|
+
w.len_prefixed(decode_segment_scratch(pair.name, scratch));
|
|
453
|
+
w.len_prefixed(decode_segment_scratch(pair.value, scratch));
|
|
460
454
|
count += 1;
|
|
461
455
|
}
|
|
462
|
-
|
|
456
|
+
w.patch_u32(count_pos, count as u32);
|
|
457
|
+
capped
|
|
463
458
|
}
|
|
464
459
|
|
|
465
|
-
/// Write the cookie pair section (raw trimmed/unquoted names/values).
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
460
|
+
/// Write the cookie pair section (raw trimmed/unquoted names/values) in ONE pass.
|
|
461
|
+
/// Returns `true` when `max_pairs` capped the section (→ not valid).
|
|
462
|
+
fn write_cookie_section(w: &mut ResultWriter<'_>, cookie: &[u8], max_pairs: usize) -> bool {
|
|
463
|
+
let count_pos = w.pos;
|
|
464
|
+
w.u32(0); // count placeholder, patched below
|
|
469
465
|
let mut count = 0usize;
|
|
466
|
+
let mut capped = false;
|
|
470
467
|
for (name, value) in cookie_pairs(cookie) {
|
|
471
468
|
if max_pairs > 0 && count >= max_pairs {
|
|
469
|
+
capped = true;
|
|
472
470
|
break;
|
|
473
471
|
}
|
|
474
|
-
|
|
475
|
-
|
|
472
|
+
w.len_prefixed(name);
|
|
473
|
+
w.len_prefixed(value);
|
|
476
474
|
count += 1;
|
|
477
475
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
/// Write a u32 LE into `out` at `pos`, advancing `pos`. Caller guarantees
|
|
482
|
-
/// capacity (sizes are pre-computed).
|
|
483
|
-
#[inline]
|
|
484
|
-
fn write_u32(out: &mut [u8], pos: &mut usize, value: u32) {
|
|
485
|
-
out[*pos..*pos + 4].copy_from_slice(&value.to_le_bytes());
|
|
486
|
-
*pos += 4;
|
|
476
|
+
w.patch_u32(count_pos, count as u32);
|
|
477
|
+
capped
|
|
487
478
|
}
|
|
488
479
|
|
|
489
480
|
// ── napi boundary: the `Route` class (Node/fallback transport) ─────
|
package/rust/ingress/pipeline.rs
CHANGED
|
@@ -486,9 +486,9 @@ impl IngressInner {
|
|
|
486
486
|
self.limits.max_pairs,
|
|
487
487
|
) {
|
|
488
488
|
Ok(written) => written as u32,
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
489
|
+
// A malformed `%XX` is NOT an error: the decoder returns the
|
|
490
|
+
// raw component, exactly like the JS `decodeURIComponent`
|
|
491
|
+
// fallback (this used to 400 where JS answers 200).
|
|
492
492
|
Err(crate::json::json_ser::QueryJsonError::BufferTooSmall) => {
|
|
493
493
|
truncated = true;
|
|
494
494
|
0
|
package/rust/ingress/tests.rs
CHANGED
|
@@ -547,9 +547,10 @@ fn truncated_multibyte_query_escaped() {
|
|
|
547
547
|
parse_query: true,
|
|
548
548
|
..base_inner()
|
|
549
549
|
};
|
|
550
|
-
// `%E2%9C`
|
|
551
|
-
//
|
|
552
|
-
// and the
|
|
550
|
+
// `%E2%9C` is a truncated 3-byte sequence: `decodeURIComponent` throws, so
|
|
551
|
+
// the component is returned RAW ("%E2%9C") — the same thing the JS
|
|
552
|
+
// fallback does — and the request is NOT rejected. The JSON writer then
|
|
553
|
+
// sees plain ASCII and the length accounting is the raw length.
|
|
553
554
|
let input = packed_input(0, "/api?q=%E2%9C".as_bytes(), b"127.0.0.1", b"rid");
|
|
554
555
|
let mut out = vec![0u8; 4096];
|
|
555
556
|
inner.handle_packed(&input, b"", &mut out).unwrap();
|
|
@@ -562,5 +563,5 @@ fn truncated_multibyte_query_escaped() {
|
|
|
562
563
|
out[OUT_QUERY_JSON_LEN + 3],
|
|
563
564
|
]) as usize;
|
|
564
565
|
let query_json = &out[OUT_DATA_START..OUT_DATA_START + query_len];
|
|
565
|
-
assert_eq!(query_json, br#"{"q":"
|
|
566
|
+
assert_eq!(query_json, br#"{"q":"%E2%9C"}"#);
|
|
566
567
|
}
|
package/rust/json/json_ser.rs
CHANGED
|
@@ -416,15 +416,18 @@ pub fn packed_pairs_to_json_into_slice(
|
|
|
416
416
|
/// same split the old packed pipeline had between its parse and write steps).
|
|
417
417
|
#[derive(Debug, PartialEq, Eq)]
|
|
418
418
|
pub enum QueryJsonError {
|
|
419
|
-
Malformed,
|
|
420
419
|
BufferTooSmall,
|
|
421
420
|
}
|
|
422
421
|
|
|
423
422
|
/// Decode a single URL-form component (`+` → space, `%XX` → byte) into `out`.
|
|
424
423
|
///
|
|
425
|
-
///
|
|
426
|
-
///
|
|
427
|
-
///
|
|
424
|
+
/// Uses the LENIENT arm of the shared decoder — the same one the packed pair
|
|
425
|
+
/// parsers use — so a malformed escape or an invalid-UTF-8 escape yields the RAW
|
|
426
|
+
/// component exactly like the pure-TS fallback. (This used to be a strict arm
|
|
427
|
+
/// that rejected malformed `%XX`, which made the native pipeline answer 400
|
|
428
|
+
/// where the JS path returns the raw value.) Bytes that are invalid UTF-8 in the
|
|
429
|
+
/// INPUT still reach the JSON writer verbatim; the escaping below keeps the
|
|
430
|
+
/// output valid JSON.
|
|
428
431
|
#[inline]
|
|
429
432
|
fn decode_query_component(
|
|
430
433
|
src: &[u8],
|
|
@@ -432,12 +435,8 @@ fn decode_query_component(
|
|
|
432
435
|
) -> std::result::Result<(), QueryJsonError> {
|
|
433
436
|
let start = out.len();
|
|
434
437
|
out.resize(start + src.len(), 0);
|
|
435
|
-
let written = crate::util::bytes::decode_form_component_into(src, &mut out[start..])
|
|
436
|
-
|
|
|
437
|
-
crate::util::bytes::FormDecodeError::Malformed => QueryJsonError::Malformed,
|
|
438
|
-
crate::util::bytes::FormDecodeError::BufferTooSmall => QueryJsonError::BufferTooSmall,
|
|
439
|
-
},
|
|
440
|
-
)?;
|
|
438
|
+
let written = crate::util::bytes::decode_form_component_into(src, &mut out[start..])
|
|
439
|
+
.map_err(|_| QueryJsonError::BufferTooSmall)?;
|
|
441
440
|
out.truncate(start + written);
|
|
442
441
|
Ok(())
|
|
443
442
|
}
|
|
@@ -864,7 +863,10 @@ mod tests {
|
|
|
864
863
|
"query={raw:?}"
|
|
865
864
|
);
|
|
866
865
|
}
|
|
867
|
-
|
|
866
|
+
// Both paths share the lenient decoder now, so a malformed
|
|
867
|
+
// `%XX` produces the SAME raw component on both sides (the
|
|
868
|
+
// `Err` arm that used to allow "both reject" is gone with the
|
|
869
|
+
// strict contract).
|
|
868
870
|
(other, _) => {
|
|
869
871
|
panic!("mismatched outcome for query={raw:?}: {other:?} vs {direct:?}")
|
|
870
872
|
}
|
package/rust/lib.rs
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// rust/task/completion.rs — the completion ring + batched doorbell.
|
|
2
|
+
//
|
|
3
|
+
// THE performance-critical piece. A pooled native thread never returns to JS
|
|
4
|
+
// directly; it pushes a `Completion` here and (at most once per batch) calls a
|
|
5
|
+
// single thread-safe trampoline so the JS thread can drain everything in ONE
|
|
6
|
+
// `castrum_task_drain` call. That amortizes the ~666 ns/crossing threadsafe
|
|
7
|
+
// callback over a whole batch instead of paying it per task.
|
|
8
|
+
//
|
|
9
|
+
// PACKED DRAIN LAYOUT (`out`):
|
|
10
|
+
// [u32 count LE]
|
|
11
|
+
// repeat count: [u64 id LE][u32 status][u32 len][len bytes]
|
|
12
|
+
//
|
|
13
|
+
// Needed-size convention (repo-wide): when `out` is too small the EXACT
|
|
14
|
+
// required byte count is returned and nothing is consumed, so JS allocates
|
|
15
|
+
// once and retries once. `0` means "nothing pending".
|
|
16
|
+
//
|
|
17
|
+
// NOTE (2026-09-11): a pointer-based zero-copy variant was prototyped and
|
|
18
|
+
// REVERTED — Bun 1.4.2's `toArrayBuffer` deallocator hook segfaults (see
|
|
19
|
+
// docs/RND-CONCURRENCY.md §7a). Zero-copy will instead pre-allocate the
|
|
20
|
+
// destination in JS and have the pool thread write into it directly.
|
|
21
|
+
|
|
22
|
+
use parking_lot::Mutex;
|
|
23
|
+
use std::collections::VecDeque;
|
|
24
|
+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
25
|
+
use std::sync::OnceLock;
|
|
26
|
+
|
|
27
|
+
/// One finished task. `status`: 0 = ok, 1 = error, 2 = cancelled. `body` is
|
|
28
|
+
/// the result bytes, or the UTF-8 error message when `status != 0`.
|
|
29
|
+
pub struct Completion {
|
|
30
|
+
pub id: u64,
|
|
31
|
+
pub status: u32,
|
|
32
|
+
pub body: Vec<u8>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Completions waiting to be drained. Lazily initialised WITH capacity so a
|
|
36
|
+
/// burst (hundreds of tasks finishing together) does not pay repeated VecDeque
|
|
37
|
+
/// growth reallocations on the worker threads.
|
|
38
|
+
static RING: OnceLock<Mutex<VecDeque<Completion>>> = OnceLock::new();
|
|
39
|
+
|
|
40
|
+
/// One burst's worth of entries before the first realloc.
|
|
41
|
+
const RING_CAPACITY: usize = 256;
|
|
42
|
+
|
|
43
|
+
fn ring() -> &'static Mutex<VecDeque<Completion>> {
|
|
44
|
+
RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(RING_CAPACITY)))
|
|
45
|
+
}
|
|
46
|
+
/// `true` while a doorbell call is outstanding, so N completions in a burst
|
|
47
|
+
/// wake JS once, not N times.
|
|
48
|
+
static ARMED: AtomicBool = AtomicBool::new(false);
|
|
49
|
+
/// Address of the thread-safe trampoline (`JSCallback({threadsafe:true}).ptr`).
|
|
50
|
+
static DOORBELL: AtomicUsize = AtomicUsize::new(0);
|
|
51
|
+
|
|
52
|
+
/// Register the doorbell trampoline. `0` disables it (poll-only mode).
|
|
53
|
+
pub fn set_doorbell(cb: usize) {
|
|
54
|
+
DOORBELL.store(cb, Ordering::Release);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Push a completion and ring the doorbell at most once per armed window.
|
|
58
|
+
///
|
|
59
|
+
/// The ring lock is released BEFORE the trampoline is called: the JS callback
|
|
60
|
+
/// re-enters `drain`, which locks the ring again — holding it here would
|
|
61
|
+
/// deadlock the process.
|
|
62
|
+
pub fn push(c: Completion) {
|
|
63
|
+
let should_ring = {
|
|
64
|
+
let mut q = ring().lock();
|
|
65
|
+
q.push_back(c);
|
|
66
|
+
!ARMED.swap(true, Ordering::AcqRel)
|
|
67
|
+
};
|
|
68
|
+
if should_ring {
|
|
69
|
+
let p = DOORBELL.load(Ordering::Acquire);
|
|
70
|
+
if p != 0 {
|
|
71
|
+
// SAFETY: `p` is a live `JSCallback` thread-safe trampoline whose
|
|
72
|
+
// JS owner is held for the process lifetime by the task runtime.
|
|
73
|
+
let f: extern "C" fn() = unsafe { std::mem::transmute(p) };
|
|
74
|
+
f();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Drain every pending completion into `out`. Returns bytes written, `0` when
|
|
80
|
+
/// nothing is pending, or the exact required size when `out` is too small.
|
|
81
|
+
pub fn drain(out: &mut [u8]) -> usize {
|
|
82
|
+
// Disarm FIRST so a completion pushed while we drain re-arms and rings the
|
|
83
|
+
// bell instead of being stranded (see the module note).
|
|
84
|
+
ARMED.store(false, Ordering::Release);
|
|
85
|
+
let mut q = ring().lock();
|
|
86
|
+
if q.is_empty() {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
let mut needed = 4usize;
|
|
90
|
+
for c in q.iter() {
|
|
91
|
+
needed += 16 + c.body.len();
|
|
92
|
+
}
|
|
93
|
+
if out.len() < needed {
|
|
94
|
+
return needed;
|
|
95
|
+
}
|
|
96
|
+
out[0..4].copy_from_slice(&(q.len() as u32).to_le_bytes());
|
|
97
|
+
let mut off = 4usize;
|
|
98
|
+
for c in q.iter() {
|
|
99
|
+
out[off..off + 8].copy_from_slice(&c.id.to_le_bytes());
|
|
100
|
+
out[off + 8..off + 12].copy_from_slice(&c.status.to_le_bytes());
|
|
101
|
+
out[off + 12..off + 16].copy_from_slice(&(c.body.len() as u32).to_le_bytes());
|
|
102
|
+
out[off + 16..off + 16 + c.body.len()].copy_from_slice(&c.body);
|
|
103
|
+
off += 16 + c.body.len();
|
|
104
|
+
}
|
|
105
|
+
q.clear();
|
|
106
|
+
needed
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Completions waiting to be drained.
|
|
110
|
+
pub fn pending() -> u32 {
|
|
111
|
+
ring().lock().len() as u32
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Drop every pending completion (shutdown / test isolation). Bodies already
|
|
115
|
+
/// handed to JS by `drain_ptrs` are owned by JS and are NOT freed here.
|
|
116
|
+
pub fn clear() {
|
|
117
|
+
ring().lock().clear();
|
|
118
|
+
ARMED.store(false, Ordering::Release);
|
|
119
|
+
}
|
package/rust/task/mod.rs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// rust/task — off-thread task runtime ("castrum Tasks").
|
|
2
|
+
//
|
|
3
|
+
// A Bun-first answer to synchronous FFI: any CPU-bound native op that would
|
|
4
|
+
// otherwise freeze the JS thread is submitted as a task, runs on a dedicated
|
|
5
|
+
// pool thread (NOT the rayon batch pool), and resolves a JS promise through a
|
|
6
|
+
// BATCHED completion doorbell. See docs/RND-CONCURRENCY.md for the design and
|
|
7
|
+
// the measured Bun concurrency costs.
|
|
8
|
+
//
|
|
9
|
+
// runtime.rs pool lifecycle (once-per-process) + admission
|
|
10
|
+
// completion.rs result ring + batched thread-safe doorbell (the fast path)
|
|
11
|
+
// ops.rs op dispatch table + cancellation + panic containment
|
|
12
|
+
// tests.rs pool/ring/cancel/panic/doorbell unit tests
|
|
13
|
+
//
|
|
14
|
+
// Safety contract (identical to the rest of the C-ABI surface): every task body
|
|
15
|
+
// runs under `catch_unwind`; no `Mutex` is held across the doorbell call; the
|
|
16
|
+
// pool owns every input buffer after `submit` returns.
|
|
17
|
+
|
|
18
|
+
pub mod completion;
|
|
19
|
+
pub mod ops;
|
|
20
|
+
pub mod runtime;
|
|
21
|
+
|
|
22
|
+
#[cfg(test)]
|
|
23
|
+
mod tests;
|
|
24
|
+
|
|
25
|
+
pub use completion::{drain, pending, set_doorbell};
|
|
26
|
+
pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
|
|
27
|
+
pub use runtime::{init, shutdown, threads};
|
|
28
|
+
|
|
29
|
+
/// Submit one task op with packed `args`, completing under `id`.
|
|
30
|
+
///
|
|
31
|
+
/// The pool auto-starts (default worker count) on first use, so callers do not
|
|
32
|
+
/// have to sequence `init` before `submit`. `args` is moved onto the worker.
|
|
33
|
+
pub fn submit_op(op: u32, args: Vec<u8>, id: u64) -> bool {
|
|
34
|
+
runtime::submit(move || ops::execute_guarded(op, &args, id))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Submit one zero-copy task op: the pool thread writes its result DIRECTLY
|
|
38
|
+
/// into the caller's `out` buffer, and the completion body is the 8-byte LE
|
|
39
|
+
/// written length instead of the data. This removes the last full-size copy and
|
|
40
|
+
/// its allocation from the JS thread (see docs/RND-CONCURRENCY.md §7a).
|
|
41
|
+
///
|
|
42
|
+
/// # Safety
|
|
43
|
+
/// `out` must point to a buffer of at least `out_cap` bytes that stays alive
|
|
44
|
+
/// and is not touched by JS until the completion for `id` is drained.
|
|
45
|
+
pub fn submit_op_into(op: u32, args: Vec<u8>, id: u64, out: usize, out_cap: usize) -> bool {
|
|
46
|
+
runtime::submit(move || unsafe {
|
|
47
|
+
ops::execute_into_guarded(op, &args, id, out as *mut u8, out_cap)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Submit one task whose PAYLOAD is read IN PLACE from the caller's buffer
|
|
52
|
+
/// (zero-copy INPUT): only the `hdr` header bytes are copied, so a large
|
|
53
|
+
/// argument never crosses the boundary. Without this, offloading a large
|
|
54
|
+
/// op pays a full-size copy on the JS thread — measured to make
|
|
55
|
+
/// `gzipCompress` of 24 MiB SLOWER than the synchronous built-in it replaces
|
|
56
|
+
/// (18.6 ms / 11.7 ms stall vs 5.6 ms), which is the opposite of the point.
|
|
57
|
+
///
|
|
58
|
+
/// # Safety
|
|
59
|
+
/// `data`/`data_len` must describe a readable region that stays alive and
|
|
60
|
+
/// unmodified until the completion for `id` is drained.
|
|
61
|
+
pub fn submit_slice_op(op: u32, hdr: Vec<u8>, data: usize, data_len: usize, id: u64) -> bool {
|
|
62
|
+
runtime::submit(move || unsafe {
|
|
63
|
+
ops::execute_slice_guarded(op, &hdr, data as *const u8, data_len, id)
|
|
64
|
+
})
|
|
65
|
+
}
|