castrum 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  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 +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -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
  };
@@ -224,9 +226,11 @@ impl NativeRoute {
224
226
  if self.require_json_body || self.validate_body {
225
227
  if !has_body || body.len() > self.max_body_bytes {
226
228
  body_valid_json = false;
227
- if self.require_json_body {
228
- error_code = ERR_BODY_NOT_JSON;
229
- }
229
+ // Inside `require_json_body || validate_body`: an absent or
230
+ // oversized body can never satisfy either constraint, so fail
231
+ // closed. (Pre-fix a validateBody-only route returned OK with
232
+ // error_code 0 and skipped schema validation entirely.)
233
+ error_code = ERR_BODY_NOT_JSON;
230
234
  } else {
231
235
  body_valid_json = crate::json::json_ops::json_valid_bytes(body);
232
236
  if self.require_json_body && !body_valid_json {
@@ -732,6 +736,22 @@ mod tests {
732
736
  assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
733
737
  }
734
738
 
739
+ #[test]
740
+ fn validate_body_without_require_json_fails_closed_on_absent_body() {
741
+ let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
742
+ // Deliberately NO requireJsonBody stage: a route that only declares
743
+ // validateBody must still reject a missing body. Pre-fix this returned
744
+ // OK with error_code 0 and skipped schema validation entirely.
745
+ let d = descriptor(&[STAGE_VALIDATE_BODY], &[(PART_BODY, schema)]);
746
+ let r = NativeRoute::compile(&d).unwrap();
747
+ let mut out = vec![0u8; 64];
748
+ r.run(&frame(b"", b"", None), &mut out).unwrap();
749
+ let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
750
+ let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
751
+ assert_eq!(code, 400);
752
+ assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
753
+ }
754
+
735
755
  #[test]
736
756
  fn body_only_route_is_bare_header() {
737
757
  let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
@@ -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
+ }
@@ -54,6 +54,13 @@ pub fn set_doorbell(cb: usize) {
54
54
  DOORBELL.store(cb, Ordering::Release);
55
55
  }
56
56
 
57
+ /// Disable the doorbell trampoline (`0`). Called on shutdown BEFORE the JS
58
+ /// owner closes the `JSCallback`, so a completion that lands late can never
59
+ /// call a freed trampoline (poll-only mode drains it instead).
60
+ pub fn clear_doorbell() {
61
+ DOORBELL.store(0, Ordering::Release);
62
+ }
63
+
57
64
  /// Push a completion and ring the doorbell at most once per armed window.
58
65
  ///
59
66
  /// The ring lock is released BEFORE the trampoline is called: the JS callback
package/rust/task/mod.rs CHANGED
@@ -22,7 +22,7 @@ pub mod runtime;
22
22
  #[cfg(test)]
23
23
  mod tests;
24
24
 
25
- pub use completion::{drain, pending, set_doorbell};
25
+ pub use completion::{clear_doorbell, drain, pending, set_doorbell};
26
26
  pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
27
27
  pub use runtime::{init, shutdown, threads};
28
28
 
@@ -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
  }
@@ -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
- fn enqueue(&'static self, job: Job) {
125
- self.queue.lock().push_back(job);
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. Always accepted in v1
194
- /// (the queue is unbounded; admission/backpressure is a later milestone).
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.enqueue(Box::new(job));
205
- true
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).
@@ -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::{
@@ -223,9 +225,134 @@ fn doorbell_coalesces_a_batch() {
223
225
  completion::clear();
224
226
  }
225
227
 
228
+ #[test]
229
+ fn clear_doorbell_suppresses_the_ring() {
230
+ let _g = LOCK.lock();
231
+ completion::clear();
232
+ completion::set_doorbell(bell as *const () as usize);
233
+ BELLS.store(0, Ordering::SeqCst);
234
+ completion::push(Completion {
235
+ id: 1,
236
+ status: STATUS_OK,
237
+ body: vec![0],
238
+ });
239
+ assert_eq!(BELLS.load(Ordering::SeqCst), 1);
240
+ // Disarm by draining, then clear the trampoline. A later completion must
241
+ // NOT call the (now closed) trampoline address.
242
+ let mut buf = vec![0u8; 4096];
243
+ completion::drain(&mut buf);
244
+ completion::clear_doorbell();
245
+ completion::push(Completion {
246
+ id: 2,
247
+ status: STATUS_OK,
248
+ body: vec![0],
249
+ });
250
+ assert_eq!(
251
+ BELLS.load(Ordering::SeqCst),
252
+ 1,
253
+ "a cleared doorbell must never be called"
254
+ );
255
+ completion::clear();
256
+ }
257
+
226
258
  #[test]
227
259
  fn pool_starts_with_a_nonzero_thread_count() {
228
260
  let _g = LOCK.lock();
229
261
  runtime::init(None);
230
262
  assert!(runtime::threads() >= 1);
231
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
+ }