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.
Files changed (61) hide show
  1. package/Cargo.lock +11 -1
  2. package/Cargo.toml +17 -3
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +164 -4
  12. package/dist/index.js +434 -70
  13. package/docs/ENVIRONMENT.md +7 -0
  14. package/docs/RND-CONCURRENCY.md +20 -4
  15. package/docs/bun-builtins-decision-matrix.md +17 -7
  16. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  17. package/index.ts +5 -0
  18. package/package.json +2 -1
  19. package/rust/crypto/base64.rs +472 -57
  20. package/rust/crypto/hashing.rs +68 -0
  21. package/rust/ffi/task.rs +19 -18
  22. package/rust/ingress/api.rs +21 -5
  23. package/rust/ingress/mod.rs +7 -0
  24. package/rust/ingress/native_route.rs +4 -2
  25. package/rust/ingress/schema_cache.rs +266 -0
  26. package/rust/payload/sse.rs +78 -5
  27. package/rust/task/mod.rs +1 -0
  28. package/rust/task/runtime.rs +55 -7
  29. package/rust/task/tests.rs +98 -1
  30. package/rust/util/text.rs +112 -10
  31. package/rust/util/validation.rs +34 -0
  32. package/src/ingress/abort.ts +48 -0
  33. package/src/ingress/body.ts +104 -8
  34. package/src/ingress/handlers.ts +1 -0
  35. package/src/ingress/health.ts +22 -0
  36. package/src/ingress/index.ts +3 -1
  37. package/src/ingress/router.ts +36 -1
  38. package/src/ingress/routes/common.ts +9 -1
  39. package/src/ingress/routes/echo.ts +10 -0
  40. package/src/ingress/routes/json-write.ts +11 -0
  41. package/src/ingress/routes/native.ts +24 -5
  42. package/src/ingress/routes/options.ts +6 -2
  43. package/src/ingress/routes/responder.ts +25 -5
  44. package/src/ingress/routes/safe-record.ts +57 -0
  45. package/src/ingress/server-error.ts +111 -0
  46. package/src/ingress/server-node.ts +102 -23
  47. package/src/ingress/server.ts +140 -32
  48. package/src/integration/pipeline.ts +6 -0
  49. package/src/native/ffi/build/task.ts +2 -1
  50. package/src/native/ffi/types.ts +6 -1
  51. package/src/native/types.ts +6 -0
  52. package/src/runtime/detect.ts +14 -0
  53. package/src/rust-ffi/context.ts +46 -3
  54. package/src/rust-ffi/scalar/factories.ts +6 -0
  55. package/src/rust-ffi/scalar/interface.ts +14 -0
  56. package/src/rust-ffi/text.ts +5 -4
  57. package/src/shared/buffer-pool.ts +10 -0
  58. package/src/shared/memory.ts +68 -0
  59. package/src/shared/metrics.ts +86 -3
  60. package/src/shared/runtime.ts +7 -0
  61. package/src/task/runtime.ts +52 -9
@@ -137,4 +137,72 @@ mod tests {
137
137
  xxh3(Uint8Array::new(input.to_vec()))
138
138
  );
139
139
  }
140
+
141
+ /// Known-value xxh3-64 vectors that LOCK the spec output across both the
142
+ /// short-input (< 240 B) and long-input (>= 240 B) regimes of the engine.
143
+ /// Generated with `Bun.hash.xxHash3` (independent spec-compliant
144
+ /// implementation). The 240/241 pair guards the engine's internal
145
+ /// threshold: an implementation or length-handling regression that
146
+ /// diverges only above it fails here even though small-input tests pass.
147
+ /// (Perf note: the scalar core is FLAT ~30GB/s 64B→1MB — measured, no
148
+ /// size pathology; see docs/bun-builtins-decision-matrix.md for why Bun
149
+ /// still wins the consumer under Bun.)
150
+ #[test]
151
+ fn xxh3_vectors_lock_spec_across_simd_threshold() {
152
+ // input: (i * 31 + 5) % 256 for i in 0..n (deterministic)
153
+ const V: &[(usize, u64)] = &[
154
+ (0, 0x2d06_8005_38d3_94c2),
155
+ (1, 0x929e_358d_27ae_3ee2),
156
+ (2, 0xf8a9_b018_ddb0_dc1a),
157
+ (3, 0xea4f_94f7_8c69_f54e),
158
+ (4, 0xc674_aba0_6059_22b7),
159
+ (5, 0x988e_18b6_a130_77ac),
160
+ (6, 0x19e7_8a37_d9eb_8365),
161
+ (7, 0x4d32_b9b4_d45d_9319),
162
+ (8, 0xad43_7729_e900_e521),
163
+ (9, 0x2f54_64ee_70cc_cff9),
164
+ (10, 0x97a1_114c_1cbf_45ae),
165
+ (11, 0x2933_959d_e03b_8ce8),
166
+ (12, 0x5cba_0687_e71c_0f12),
167
+ (13, 0x5a27_1d93_20eb_cb8a),
168
+ (14, 0xeb81_cf37_8320_b598),
169
+ (15, 0xec7b_ee5b_893b_d5d5),
170
+ (16, 0x0903_f58f_8225_3d8c),
171
+ (17, 0x48a3_8615_d4ee_fb8b),
172
+ (18, 0xb871_d958_cc11_dfa4),
173
+ (19, 0xc6ed_7037_b89b_d206),
174
+ (23, 0xd410_62e4_0171_ac46),
175
+ (31, 0x4334_b263_9627_f197),
176
+ (32, 0xebeb_3ba5_7ab1_edca),
177
+ (33, 0x7ff1_7572_bd9e_70a9),
178
+ (63, 0x504d_3e53_12aa_2db1),
179
+ (64, 0xd8cc_034e_d06e_92f3),
180
+ (65, 0x73c9_73e2_4eda_1cdc),
181
+ (95, 0x45f6_3f33_cade_8c19),
182
+ (96, 0x4c46_e758_08f2_74e9),
183
+ (127, 0x1ed1_3eff_d01b_9402),
184
+ (128, 0x05a1_567d_530f_961c),
185
+ (191, 0xba5e_7dfd_d63d_7785),
186
+ (192, 0x999d_b9f2_4d8c_ffcf),
187
+ (238, 0xda80_d2d5_c7b9_397e),
188
+ (239, 0xb0ca_9389_9a38_51d5),
189
+ (240, 0xc502_31c4_c4c8_6a63),
190
+ (241, 0xa7dc_042e_5a9f_6aa6),
191
+ (255, 0x62c8_1de1_9704_658e),
192
+ (256, 0x84f8_cedc_d725_6d1a),
193
+ (512, 0xd4d7_d053_e788_d9ef),
194
+ (1024, 0xdb4a_2f32_c2a5_bc38),
195
+ (16384, 0xf04a_c437_8b21_4439),
196
+ (262144, 0x6c36_c9dc_9aaf_20f0),
197
+ ];
198
+ let input =
199
+ |n: usize| -> Vec<u8> { (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect() };
200
+ for (n, want) in V {
201
+ assert_eq!(
202
+ fast_hash_bytes(&input(*n)),
203
+ *want,
204
+ "xxh3({n} bytes) — engine regime mismatch",
205
+ );
206
+ }
207
+ }
140
208
  }
package/rust/ffi/task.rs CHANGED
@@ -18,7 +18,8 @@ pub extern "C" fn castrum_task_init(threads: u32) -> u32 {
18
18
  }
19
19
 
20
20
  /// Submit `op` with `args_len` packed arg bytes, completing under `task_id`.
21
- /// Returns `1` accepted, `0` rejected (null args with a non-zero length).
21
+ /// Returns `1` accepted, `2` overloaded (the bounded work queue is full), or
22
+ /// `0` invalid args (null args with a non-zero length) / panic.
22
23
  ///
23
24
  /// # Safety
24
25
  /// `args` must be valid for reads of `args_len` bytes.
@@ -39,7 +40,11 @@ pub unsafe extern "C" fn castrum_task_submit(
39
40
  } else {
40
41
  slice::from_raw_parts(args, args_len).to_vec()
41
42
  };
42
- u32::from(task::submit_op(op, owned, task_id as u64))
43
+ if task::submit_op(op, owned, task_id as u64) {
44
+ 1
45
+ } else {
46
+ 2
47
+ }
43
48
  },
44
49
  0,
45
50
  )
@@ -72,7 +77,7 @@ pub extern "C" fn castrum_task_pending() -> u32 {
72
77
  /// (UNSUPPORTED) means the op has no `_into` form and the caller should fall
73
78
  /// back to `castrum_task_submit`.
74
79
  ///
75
- /// Returns `1` accepted, `0` rejected.
80
+ /// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
76
81
  ///
77
82
  /// # Safety
78
83
  /// `args` must be valid for reads of `args_len` bytes. `out` must be valid for
@@ -97,13 +102,11 @@ pub unsafe extern "C" fn castrum_task_submit_out(
97
102
  } else {
98
103
  slice::from_raw_parts(args, args_len).to_vec()
99
104
  };
100
- u32::from(task::submit_op_into(
101
- op,
102
- owned,
103
- task_id as u64,
104
- out as usize,
105
- out_cap,
106
- ))
105
+ if task::submit_op_into(op, owned, task_id as u64, out as usize, out_cap) {
106
+ 1
107
+ } else {
108
+ 2
109
+ }
107
110
  },
108
111
  0,
109
112
  )
@@ -121,7 +124,7 @@ pub extern "C" fn castrum_task_cancel(task_id: usize) -> u32 {
121
124
  /// compression costs a full-size copy on the JS thread and measured WORSE than
122
125
  /// the synchronous built-in (docs/RND-CONCURRENCY.md §7b).
123
126
  ///
124
- /// Returns `1` accepted, `0` rejected.
127
+ /// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
125
128
  ///
126
129
  /// # Safety
127
130
  /// `hdr` must be valid for reads of `hdr_len` bytes. `data` must be valid for
@@ -148,13 +151,11 @@ pub unsafe extern "C" fn castrum_task_submit_slice(
148
151
  } else {
149
152
  slice::from_raw_parts(hdr, hdr_len).to_vec()
150
153
  };
151
- u32::from(task::submit_slice_op(
152
- op,
153
- owned,
154
- data as usize,
155
- data_len,
156
- task_id as u64,
157
- ))
154
+ if task::submit_slice_op(op, owned, data as usize, data_len, task_id as u64) {
155
+ 1
156
+ } else {
157
+ 2
158
+ }
158
159
  },
159
160
  0,
160
161
  )
@@ -85,11 +85,13 @@ impl Ingress {
85
85
  .map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
86
86
  // Compile BOTH the authoritative jsonschema validator and the
87
87
  // zero-DOM fast path once at construction — no per-request schema
88
- // work (see IngressSchema in pipeline.rs).
89
- let compiled = IngressSchema::compile(&schema_value).map_err(|e| {
90
- Error::new(Status::InvalidArg, format!("Schema compile error: {}", e))
91
- })?;
92
- Some(Arc::new(compiled))
88
+ // work (see IngressSchema in pipeline.rs). Identical schema bytes
89
+ // are shared process-wide via the compiled-schema cache.
90
+ let compiled =
91
+ crate::ingress::schema_cache::get_or_compile(&schema_value).map_err(|e| {
92
+ Error::new(Status::InvalidArg, format!("Schema compile error: {}", e))
93
+ })?;
94
+ Some(compiled)
93
95
  } else {
94
96
  None
95
97
  };
@@ -353,3 +355,17 @@ impl Ingress {
353
355
  inner.handle_packed(&packed, body, out)
354
356
  }
355
357
  }
358
+
359
+ /// Clear the process-wide compiled-schema cache — the dedupe store that shares
360
+ /// one compiled `IngressSchema` across every `Ingress`/`NativeRoute` built from
361
+ /// identical schema bytes.
362
+ ///
363
+ /// Maintenance hook for the public `flushMemory()` (napi export name:
364
+ /// `clearSchemaCache`). Idempotent; safe to call at any time, including before
365
+ /// any schema has ever been compiled. Dropping the cache only releases the
366
+ /// native validators — already-constructed ingress/route instances keep their
367
+ /// own `Arc<IngressSchema>`, so live routes are unaffected.
368
+ #[napi]
369
+ pub fn clear_schema_cache() {
370
+ crate::ingress::schema_cache::clear_schema_cache();
371
+ }
@@ -22,6 +22,9 @@ mod tests;
22
22
 
23
23
  pub(crate) mod options;
24
24
  pub(crate) mod packed;
25
+ // Process-wide compiled-schema cache: dedupes identical `IngressSchema`
26
+ // compiles across `Ingress` / `NativeRoute` instances (schema_cache.rs).
27
+ pub(crate) mod schema_cache;
25
28
  pub(crate) mod time;
26
29
 
27
30
  pub(crate) mod cors;
@@ -40,6 +43,10 @@ pub(crate) mod terminal;
40
43
  #[cfg(test)]
41
44
  pub(crate) use self::api::clamp_output_size;
42
45
  pub use self::api::Ingress;
46
+ // napi maintenance export (`clearSchemaCache`) — re-exported so it is a
47
+ // crate-reachable item (dead-code analysis) while the `#[napi]` macro does the
48
+ // JS-side registration.
49
+ pub use self::api::clear_schema_cache;
43
50
  pub(crate) use self::api::IngressInner;
44
51
  pub(crate) use self::native_route::NativeRoute;
45
52
  pub(crate) use self::options::{IngressOptions, Limits};
@@ -177,9 +177,11 @@ impl NativeRoute {
177
177
  .map_err(|_| "route descriptor: body schema is not valid UTF-8".to_string())?;
178
178
  let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
179
179
  .map_err(|e| format!("route descriptor: body schema JSON error: {e}"))?;
180
- let compiled = IngressSchema::compile(&schema_value)
180
+ // Shared process-wide: identical schema bytes compile once even
181
+ // across routes/instances (see ingress/schema_cache.rs).
182
+ let compiled = super::schema_cache::get_or_compile(&schema_value)
181
183
  .map_err(|e| format!("route descriptor: body schema compile error: {e}"))?;
182
- Some(Arc::new(compiled))
184
+ Some(compiled)
183
185
  }
184
186
  None => None,
185
187
  };
@@ -0,0 +1,266 @@
1
+ // rust/ingress/schema_cache.rs — Process-wide compiled-schema cache.
2
+ //
3
+ // `Ingress` and `NativeRoute` compile a request-body schema once at
4
+ // construction (`IngressSchema::compile`). When an application builds N routes
5
+ // over the SAME schema bytes, that is N identical compiles at startup and N
6
+ // copies retained at runtime. This module dedupes them: identical schema JSON
7
+ // yields ONE shared `Arc<IngressSchema>`, bounded by an LRU (default 128
8
+ // entries, override with `CASTRUM_SCHEMA_CACHE_MAX`).
9
+ //
10
+ // Key = a stable 64-bit hash of the raw serialized schema bytes (exact bytes:
11
+ // a different key order is a different key and simply compiles a second time).
12
+ // The hash only selects a bucket — it is NOT the identity: each entry stores
13
+ // its serialized bytes alongside the compiled schema, and a hit is returned
14
+ // only when those bytes EQUAL the requested bytes. `xxh3_64` is
15
+ // non-cryptographic, so trusting the hash alone would let a craftable collision
16
+ // reuse the wrong validator (a validation-bypass class bug); the byte compare
17
+ // makes collisions a safe miss (compile + replace the colliding bucket) rather
18
+ // than a wrong-schema hit.
19
+ //
20
+ // Only SUCCESSFUL compiles are cached. `IngressSchema` is immutable after
21
+ // compile (`Arc<jsonschema::Validator>` + optional `Arc<FastNode>`), so sharing
22
+ // the `Arc` across routes and threads is sound; mutable state is per-call
23
+ // (`Cursor`/`Ctx`), never on the schema.
24
+ //
25
+ // Concurrency: double-checked locking — the cache lock is released before the
26
+ // (slow) compile and re-taken for insert, so one slow compile never serializes
27
+ // other schema lookups.
28
+
29
+ use std::num::NonZeroUsize;
30
+ use std::sync::{Arc, OnceLock};
31
+
32
+ use lru::LruCache;
33
+ use parking_lot::Mutex;
34
+
35
+ use super::pipeline::IngressSchema;
36
+
37
+ /// Default cache capacity (distinct schema byte strings).
38
+ const DEFAULT_SCHEMA_CACHE_MAX: usize = 128;
39
+ /// Upper clamp on the configured capacity — an absurd env value must not size
40
+ /// an unbounded LRU (each entry retains a compiled validator + its bytes).
41
+ const MAX_SCHEMA_CACHE_MAX: usize = 4096;
42
+
43
+ /// Cache entry: the exact serialized schema bytes (collision disambiguation)
44
+ /// plus the shared compiled schema.
45
+ type CacheEntry = (Vec<u8>, Arc<IngressSchema>);
46
+
47
+ /// Cache map: schema-hash → (schema bytes, shared compiled schema).
48
+ type SchemaMap = Mutex<LruCache<u64, CacheEntry>>;
49
+
50
+ /// Process-wide compiled-schema cache (lazily initialized on first compile).
51
+ static SCHEMA_CACHE: OnceLock<SchemaMap> = OnceLock::new();
52
+
53
+ /// Resolve a capacity from the raw env value: unset/unparseable/zero →
54
+ /// [`DEFAULT_SCHEMA_CACHE_MAX`], otherwise clamped to
55
+ /// `1..=MAX_SCHEMA_CACHE_MAX` (an LRU needs a nonzero capacity).
56
+ fn resolve_cap(raw: Option<&str>) -> usize {
57
+ raw.and_then(|v| v.trim().parse::<usize>().ok())
58
+ .filter(|n| *n > 0)
59
+ .map(|n| n.min(MAX_SCHEMA_CACHE_MAX))
60
+ .unwrap_or(DEFAULT_SCHEMA_CACHE_MAX)
61
+ }
62
+
63
+ /// Resolve the configured cache capacity (`CASTRUM_SCHEMA_CACHE_MAX`).
64
+ fn cache_cap() -> usize {
65
+ resolve_cap(std::env::var("CASTRUM_SCHEMA_CACHE_MAX").ok().as_deref())
66
+ }
67
+
68
+ /// Hash the serialized schema bytes with the crate's XXH3-64 helper (stable and
69
+ /// well-distributed). The exact byte string is the identity: serialization
70
+ /// preserves key insertion order (`serde_json` is built with `preserve_order`),
71
+ /// so whitespace/key-order differences compile separately rather than collide.
72
+ #[inline]
73
+ fn schema_key(bytes: &[u8]) -> u64 {
74
+ crate::crypto::hashing::fast_hash_bytes(bytes)
75
+ }
76
+
77
+ /// Look up (or compile and insert) the shared `IngressSchema` for
78
+ /// `schema_value`. Only successes are cached; a compile error is returned
79
+ /// verbatim (identical message to a direct `IngressSchema::compile`).
80
+ fn get_or_compile_in(
81
+ cache: &SchemaMap,
82
+ schema_value: &serde_json::Value,
83
+ ) -> std::result::Result<Arc<IngressSchema>, String> {
84
+ let bytes = serde_json::to_vec(schema_value).map_err(|e| e.to_string())?;
85
+ let key = schema_key(&bytes);
86
+
87
+ // Fast path: a hash hit is only trusted when the bytes MATCH (a colliding
88
+ // hash falls through to a real compile, never a wrong-schema hit).
89
+ if let Some((stored, existing)) = cache.lock().get(&key) {
90
+ if stored.as_slice() == bytes.as_slice() {
91
+ return Ok(Arc::clone(existing));
92
+ }
93
+ }
94
+
95
+ // Miss: compile WITHOUT holding the lock (a slow compile must not block
96
+ // other schema lookups/inserts).
97
+ let compiled = Arc::new(IngressSchema::compile(schema_value)?);
98
+
99
+ // Re-check under the lock: a concurrent thread may have compiled the same
100
+ // schema while we were outside it — prefer the first inserter's Arc (same
101
+ // bytes) so all callers share one instance. A colliding entry with
102
+ // different bytes is REPLACED (the bucket holds exactly one schema).
103
+ let mut guard = cache.lock();
104
+ if let Some((stored, existing)) = guard.get(&key) {
105
+ if stored.as_slice() == bytes.as_slice() {
106
+ return Ok(Arc::clone(existing));
107
+ }
108
+ }
109
+ let _ = guard.put(key, (bytes, Arc::clone(&compiled)));
110
+ drop(guard);
111
+ Ok(compiled)
112
+ }
113
+
114
+ /// Return the shared compiled schema for `schema_value`, compiling it on first
115
+ /// use. Errors are NOT cached (a fixed schema compiles on the next attempt).
116
+ pub(crate) fn get_or_compile(
117
+ schema_value: &serde_json::Value,
118
+ ) -> std::result::Result<Arc<IngressSchema>, String> {
119
+ let cache = SCHEMA_CACHE.get_or_init(|| {
120
+ Mutex::new(LruCache::new(
121
+ NonZeroUsize::new(cache_cap()).expect("cache cap is nonzero"),
122
+ ))
123
+ });
124
+ get_or_compile_in(cache, schema_value)
125
+ }
126
+
127
+ /// Drop every retained compiled schema. Idempotent and safe to call before
128
+ /// any schema has ever been compiled (the cache is then simply uninitialized).
129
+ /// Maintenance hook for the public `flushMemory()`.
130
+ pub fn clear_schema_cache() {
131
+ if let Some(cache) = SCHEMA_CACHE.get() {
132
+ cache.lock().clear();
133
+ }
134
+ }
135
+
136
+ #[cfg(test)]
137
+ mod tests {
138
+ use super::*;
139
+ use serde_json::json;
140
+
141
+ fn local_cache(cap: usize) -> SchemaMap {
142
+ Mutex::new(LruCache::new(
143
+ NonZeroUsize::new(cap).expect("test cap is nonzero"),
144
+ ))
145
+ }
146
+
147
+ #[test]
148
+ fn same_schema_bytes_share_one_arc() {
149
+ let cache = local_cache(4);
150
+ let schema = json!({
151
+ "type": "object",
152
+ "required": ["x"],
153
+ "properties": { "x": { "type": "string" } }
154
+ });
155
+
156
+ let first = get_or_compile_in(&cache, &schema).unwrap();
157
+ let second = get_or_compile_in(&cache, &schema).unwrap();
158
+
159
+ assert!(Arc::ptr_eq(&first, &second));
160
+ assert_eq!(cache.lock().len(), 1);
161
+ }
162
+
163
+ #[test]
164
+ fn different_schema_bytes_compile_separately() {
165
+ let cache = local_cache(4);
166
+ let object = get_or_compile_in(&cache, &json!({ "type": "object" })).unwrap();
167
+ let string = get_or_compile_in(&cache, &json!({ "type": "string" })).unwrap();
168
+
169
+ assert!(!Arc::ptr_eq(&object, &string));
170
+ assert_eq!(cache.lock().len(), 2);
171
+ }
172
+
173
+ #[test]
174
+ fn compile_errors_are_not_cached() {
175
+ let cache = local_cache(4);
176
+ // `type` must be a string or array of strings — jsonschema rejects this
177
+ // at build time, so it never reaches the cache.
178
+ let invalid = json!({ "type": 42 });
179
+
180
+ assert!(get_or_compile_in(&cache, &invalid).is_err());
181
+ assert_eq!(cache.lock().len(), 0);
182
+ }
183
+
184
+ #[test]
185
+ fn cache_is_bounded_by_capacity() {
186
+ let cache = local_cache(2);
187
+ for i in 0..3 {
188
+ let schema = json!({
189
+ "type": "object",
190
+ "properties": { format!("k{i}"): { "type": "string" } }
191
+ });
192
+ get_or_compile_in(&cache, &schema).unwrap();
193
+ }
194
+ assert!(cache.lock().len() <= 2);
195
+ }
196
+
197
+ #[test]
198
+ fn clear_empties_the_cache() {
199
+ let cache = local_cache(4);
200
+ let schema = json!({ "type": "object" });
201
+ let first = get_or_compile_in(&cache, &schema).unwrap();
202
+
203
+ cache.lock().clear();
204
+
205
+ assert_eq!(cache.lock().len(), 0);
206
+ let second = get_or_compile_in(&cache, &schema).unwrap();
207
+ // A cleared cache recompiles: a fresh Arc, not the old shared one.
208
+ assert!(!Arc::ptr_eq(&first, &second));
209
+ }
210
+
211
+ #[test]
212
+ fn cap_defaults_clamps_and_rejects_bad_values() {
213
+ assert_eq!(resolve_cap(None), DEFAULT_SCHEMA_CACHE_MAX);
214
+ assert_eq!(resolve_cap(Some("")), DEFAULT_SCHEMA_CACHE_MAX);
215
+ assert_eq!(resolve_cap(Some("abc")), DEFAULT_SCHEMA_CACHE_MAX);
216
+ assert_eq!(resolve_cap(Some("0")), DEFAULT_SCHEMA_CACHE_MAX);
217
+ assert_eq!(resolve_cap(Some("256")), 256);
218
+ assert_eq!(resolve_cap(Some(" 64 ")), 64);
219
+ // Absurd values are clamped so the LRU cannot be sized unboundedly.
220
+ assert_eq!(resolve_cap(Some("999999999")), MAX_SCHEMA_CACHE_MAX);
221
+ }
222
+
223
+ #[test]
224
+ fn hash_collision_returns_the_correct_schema() {
225
+ let cache = local_cache(4);
226
+ let schema_a = json!({ "type": "object", "required": ["a"] });
227
+ let schema_b = json!({ "type": "object", "required": ["b"] });
228
+ let bytes_b = serde_json::to_vec(&schema_b).unwrap();
229
+ let key_b = schema_key(&bytes_b);
230
+
231
+ // Simulate an xxh3 collision: an entry under B's key that actually holds
232
+ // A's bytes + compiled validator. Under a hash-only cache this would be
233
+ // returned for B (wrong-schema validation bypass).
234
+ let a = get_or_compile_in(&cache, &schema_a).unwrap();
235
+ let bytes_a = serde_json::to_vec(&schema_a).unwrap();
236
+ let _ = cache.lock().put(key_b, (bytes_a, Arc::clone(&a)));
237
+
238
+ // The byte compare must reject the collision and compile the REAL B.
239
+ let b = get_or_compile_in(&cache, &schema_b).unwrap();
240
+ assert!(!Arc::ptr_eq(&a, &b));
241
+ assert!(b.validate(br#"{"b":1}"#));
242
+ assert!(!b.validate(br#"{"a":1}"#));
243
+ // A itself still resolves correctly (its own bucket is intact).
244
+ let a_again = get_or_compile_in(&cache, &schema_a).unwrap();
245
+ assert!(Arc::ptr_eq(&a, &a_again));
246
+ }
247
+
248
+ #[test]
249
+ fn global_get_or_compile_and_clear() {
250
+ // Use a schema unique to this test so parallel tests cannot repopulate
251
+ // the key between the clear and the recompile.
252
+ let schema = json!({
253
+ "type": "object",
254
+ "properties": { "schema_cache_global_probe": { "type": "integer" } }
255
+ });
256
+
257
+ clear_schema_cache();
258
+ let first = get_or_compile(&schema).unwrap();
259
+ let again = get_or_compile(&schema).unwrap();
260
+ assert!(Arc::ptr_eq(&first, &again));
261
+
262
+ clear_schema_cache();
263
+ let recompiled = get_or_compile(&schema).unwrap();
264
+ assert!(!Arc::ptr_eq(&first, &recompiled));
265
+ }
266
+ }
@@ -40,10 +40,14 @@ pub fn encode_event_size(
40
40
  if let Some(retry) = retry {
41
41
  need += 8 + u64_digits(retry); // "retry: " + digits + "\n"
42
42
  }
43
- // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves,
44
- // then the terminating blank `\n`.
45
- let line_count = data.iter().filter(|&&b| b == b'\n').count() + 1;
46
- need + line_count * 7 + data.len() + 1
43
+ // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves
44
+ // (SEGMENTS EXCLUDE the `\n` separators — sum of segment lengths =
45
+ // `data.len() - (line_count - 1)`), then the terminating blank `\n`.
46
+ // Line boundaries found via SIMD memchr — a scalar `filter().count()`
47
+ // pass over the full payload was ~1 cycle/byte (~650ns/KB steady state),
48
+ // collapsing large encodes to 0.10× of JS.
49
+ let line_count = memchr::memchr_iter(b'\n', data).count() + 1;
50
+ need + line_count * 7 + data.len() - (line_count - 1) + 1
47
51
  }
48
52
 
49
53
  /// Encode one SSE event into `out`, returning bytes written. Zero-alloc (no
@@ -98,7 +102,23 @@ pub fn encode_event_into_slice(
98
102
  out[w] = b'\n';
99
103
  w += 1;
100
104
  }
101
- for line in data.split(|&b| b == b'\n') {
105
+ // One `data: ` line per input line: SIMD memchr finds the `\n` boundaries
106
+ // (no scalar split() re-scan of the whole payload), copying each run.
107
+ // `data.split('\n')` semantics are preserved exactly: the tail after the
108
+ // last `\n` is emitted even when empty.
109
+ let mut line_start = 0usize;
110
+ for sep in memchr::memchr_iter(b'\n', data) {
111
+ let line = &data[line_start..sep];
112
+ out[w..w + 6].copy_from_slice(b"data: ");
113
+ w += 6;
114
+ out[w..w + line.len()].copy_from_slice(line);
115
+ w += line.len();
116
+ out[w] = b'\n';
117
+ w += 1;
118
+ line_start = sep + 1;
119
+ }
120
+ {
121
+ let line = &data[line_start..];
102
122
  out[w..w + 6].copy_from_slice(b"data: ");
103
123
  w += 6;
104
124
  out[w..w + line.len()].copy_from_slice(line);
@@ -211,4 +231,57 @@ mod tests {
211
231
  let mut out = [0u8; 3];
212
232
  assert!(encode_event_into_slice(Some("update"), b"payload", None, None, &mut out).is_err());
213
233
  }
234
+
235
+ #[test]
236
+ fn large_multiline_matches_allocating() {
237
+ // 4k lines x 96-byte data — spans well past any SIMD threshold.
238
+ let mut data = Vec::with_capacity(4 * 1024 * 96);
239
+ for i in 0..4 * 1024 {
240
+ data.extend_from_slice(format!("line-{i} ").as_bytes());
241
+ data.extend_from_slice(b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 55
242
+ data.push(b'\n');
243
+ }
244
+ let expected = encode_event(None, &data, None, None);
245
+ assert!(
246
+ expected.len() > 256 * 1024,
247
+ "test payload unexpectedly small: {}",
248
+ expected.len()
249
+ );
250
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
251
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
252
+ assert_eq!(w, expected.len());
253
+ assert_eq!(&out[..w], &expected[..]);
254
+ }
255
+
256
+ #[test]
257
+ fn single_large_line_scales_too() {
258
+ // One 1MB line — the old scalar split() path cost ~300us here.
259
+ let mut data = vec![b'a'; 1 << 20];
260
+ data.push(b'\n');
261
+ let expected = encode_event(None, &data, None, None);
262
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
263
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
264
+ assert_eq!(w, expected.len());
265
+ assert_eq!(&out[..w], &expected[..]);
266
+ }
267
+
268
+ #[test]
269
+ fn trailing_newline_emits_empty_tail_line() {
270
+ // split() semantics: "a\n" -> ["a", ""] -> one empty data: line.
271
+ assert_eq!(
272
+ encode_event(None, b"hello\n", None, None),
273
+ b"data: hello\ndata: \n\n"
274
+ );
275
+ assert_eq!(encode_event(None, b"\n", None, None), b"data: \ndata: \n\n");
276
+ assert_eq!(encode_event(None, b"", None, None), b"data: \n\n");
277
+ }
278
+
279
+ #[test]
280
+ fn embedded_crlf_kept_in_line() {
281
+ // Only \n splits lines; \r stays in the line bytes.
282
+ assert_eq!(
283
+ encode_event(None, b"a\r\nb", None, None),
284
+ b"data: a\r\ndata: b\n\n"
285
+ );
286
+ }
214
287
  }
package/rust/task/mod.rs CHANGED
@@ -30,6 +30,7 @@ pub use runtime::{init, shutdown, threads};
30
30
  ///
31
31
  /// The pool auto-starts (default worker count) on first use, so callers do not
32
32
  /// have to sequence `init` before `submit`. `args` is moved onto the worker.
33
+ /// Returns `false` when the bounded admission queue is full (`OVERLOADED`).
33
34
  pub fn submit_op(op: u32, args: Vec<u8>, id: u64) -> bool {
34
35
  runtime::submit(move || ops::execute_guarded(op, &args, id))
35
36
  }