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.
Files changed (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. 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
- // ── Parse + size the pair sections ──────────────────────────
258
- let mut query_size = 0usize;
259
- let mut cookie_size = 0usize;
260
- let mut query_valid = false;
261
- let mut cookie_valid = false;
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
- let over = query.len() > self.max_query_bytes;
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
- let over = cookie.len() > self.max_cookie_bytes;
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 above is the verdict); with a schema, compile would have
276
- // rejected the descriptor → the caller fell back to JS.
277
-
278
- // ── Assemble the result ─────────────────────────────────────
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
- let required = 8 + query_size + cookie_size;
297
- if out.len() < required {
298
- return Ok(required); // needed-size: report the exact size, write nothing
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
- fn decode_segment_lenient(seg: &[u8]) -> Cow<'_, [u8]> {
351
- if memchr::memchr2(b'+', b'%', seg).is_none() {
352
- return Cow::Borrowed(seg);
353
- }
354
- let mut replaced = Vec::with_capacity(seg.len());
355
- for &b in seg {
356
- replaced.push(if b == b'+' { b' ' } else { b });
357
- }
358
- match decode_percent_utf8(&replaced) {
359
- Some(decoded) => Cow::Owned(decoded),
360
- None => Cow::Borrowed(seg),
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
- /// The decoded byte length of `seg` under the lenient rules (the original
365
- /// length when decoding fails — the segment is passed through raw).
366
- #[inline]
367
- fn decode_segment_len(seg: &[u8]) -> usize {
368
- decode_segment_lenient(seg).len()
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
- /// Percent-decode `input` (which has already had `+`→space applied) and
372
- /// validate the result is well-formed UTF-8. Returns `None` on a malformed
373
- /// `%XX` OR invalid UTF-8 (both throw in `decodeURIComponent`).
374
- fn decode_percent_utf8(input: &[u8]) -> Option<Vec<u8>> {
375
- let mut out = Vec::with_capacity(input.len());
376
- let mut i = 0usize;
377
- while i < input.len() {
378
- match input[i] {
379
- b'%' => {
380
- if i + 2 >= input.len() {
381
- return None;
382
- }
383
- let hi = hex_val(input[i + 1])?;
384
- let lo = hex_val(input[i + 2])?;
385
- out.push((hi << 4) | lo);
386
- i += 3;
387
- }
388
- b => {
389
- out.push(b);
390
- i += 1;
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
- /// Hex digit → nibble (None for non-hex).
399
- #[inline]
400
- fn hex_val(b: u8) -> Option<u8> {
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
- // ── Pair-section size + write (two passes, zero alloc) ────────────
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
- /// Size pass over decoded query pairs (name/value both lenient-decoded).
412
- fn query_section_size(query: &[u8], max_pairs: usize) -> (usize, bool) {
413
- let mut size = 4usize;
414
- let mut capped = false;
415
- for (count, pair) in query_pairs(query).enumerate() {
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
- /// Size pass over raw cookie pairs (no URL-decoding).
426
- fn cookie_section_size(cookie: &[u8], max_pairs: usize) -> (usize, bool) {
427
- let mut size = 4usize;
428
- let mut capped = false;
429
- for (count, (name, value)) in cookie_pairs(cookie).enumerate() {
430
- if max_pairs > 0 && count >= max_pairs {
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
- size += 4 + name.len() + 4 + value.len();
430
+ self.out[..RESULT_HEADER_LEN].copy_from_slice(&header);
431
+ self.pos
435
432
  }
436
- (size, capped)
437
433
  }
438
434
 
439
- /// Write a decoded segment as `[u32 len][bytes]` into `out` at `pos`.
440
- #[inline]
441
- fn write_segment(out: &mut [u8], pos: &mut usize, seg: &[u8]) {
442
- let len = seg.len();
443
- out[*pos..*pos + 4].copy_from_slice(&(len as u32).to_le_bytes());
444
- *pos += 4;
445
- out[*pos..*pos + len].copy_from_slice(seg);
446
- *pos += len;
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
- write_segment(out, pos, &decode_segment_lenient(pair.name));
459
- write_segment(out, pos, &decode_segment_lenient(pair.value));
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
- out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
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
- fn write_cookie_section(out: &mut [u8], pos: &mut usize, cookie: &[u8], max_pairs: usize) {
467
- let count_pos = *pos;
468
- write_u32(out, pos, 0); // count placeholder
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
- write_segment(out, pos, name);
475
- write_segment(out, pos, value);
472
+ w.len_prefixed(name);
473
+ w.len_prefixed(value);
476
474
  count += 1;
477
475
  }
478
- out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
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) ─────
@@ -486,9 +486,9 @@ impl IngressInner {
486
486
  self.limits.max_pairs,
487
487
  ) {
488
488
  Ok(written) => written as u32,
489
- Err(crate::json::json_ser::QueryJsonError::Malformed) => {
490
- return Err(Error::from_reason("query parse failed"))
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
@@ -547,9 +547,10 @@ fn truncated_multibyte_query_escaped() {
547
547
  parse_query: true,
548
548
  ..base_inner()
549
549
  };
550
- // `%E2%9C` decodes to 2 bytes of a 3-byte sequence → invalid UTF-8. The
551
- // JSON writer must escape every byte as `\u00XX` (not emit invalid UTF-8),
552
- // and the length accounting must match (6 bytes per byte).
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":"\u00e2\u009c"}"#);
566
+ assert_eq!(query_json, br#"{"q":"%E2%9C"}"#);
566
567
  }
@@ -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
- /// Shared decoder with `query_parser::write_decoded_form_component` (this one
426
- /// appends to a `Vec` rather than writing a length-prefixed slice); errors on
427
- /// malformed `%XX`.
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..]).map_err(
436
- |e| match e {
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
- (Err(_), Err(QueryJsonError::Malformed)) => {} // both reject malformed %XX
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
@@ -59,6 +59,7 @@ pub mod json;
59
59
  pub mod metrics;
60
60
  pub mod payload;
61
61
  pub mod selection;
62
+ pub mod task;
62
63
  pub mod util;
63
64
 
64
65
  // ── Unit tests (cargo test) ───────────────────────────────────────
@@ -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
+ }
@@ -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
+ }