castrum 0.9.8 → 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 (55) 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 +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/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  16. package/index.ts +5 -0
  17. package/package.json +2 -1
  18. package/rust/ffi/task.rs +19 -18
  19. package/rust/ingress/api.rs +21 -5
  20. package/rust/ingress/mod.rs +7 -0
  21. package/rust/ingress/native_route.rs +4 -2
  22. package/rust/ingress/schema_cache.rs +266 -0
  23. package/rust/task/mod.rs +1 -0
  24. package/rust/task/runtime.rs +55 -7
  25. package/rust/task/tests.rs +98 -1
  26. package/src/ingress/abort.ts +48 -0
  27. package/src/ingress/body.ts +104 -8
  28. package/src/ingress/handlers.ts +1 -0
  29. package/src/ingress/health.ts +22 -0
  30. package/src/ingress/index.ts +3 -1
  31. package/src/ingress/router.ts +36 -1
  32. package/src/ingress/routes/common.ts +9 -1
  33. package/src/ingress/routes/echo.ts +10 -0
  34. package/src/ingress/routes/json-write.ts +11 -0
  35. package/src/ingress/routes/native.ts +24 -5
  36. package/src/ingress/routes/options.ts +6 -2
  37. package/src/ingress/routes/responder.ts +25 -5
  38. package/src/ingress/routes/safe-record.ts +57 -0
  39. package/src/ingress/server-error.ts +111 -0
  40. package/src/ingress/server-node.ts +102 -23
  41. package/src/ingress/server.ts +140 -32
  42. package/src/integration/pipeline.ts +6 -0
  43. package/src/native/ffi/build/task.ts +2 -1
  44. package/src/native/ffi/types.ts +6 -1
  45. package/src/native/types.ts +6 -0
  46. package/src/runtime/detect.ts +14 -0
  47. package/src/rust-ffi/context.ts +46 -3
  48. package/src/rust-ffi/scalar/factories.ts +6 -0
  49. package/src/rust-ffi/scalar/interface.ts +14 -0
  50. package/src/rust-ffi/text.ts +5 -4
  51. package/src/shared/buffer-pool.ts +10 -0
  52. package/src/shared/memory.ts +68 -0
  53. package/src/shared/metrics.ts +86 -3
  54. package/src/shared/runtime.ts +7 -0
  55. package/src/task/runtime.ts +52 -9
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
+ }
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
  }
@@ -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::{
@@ -259,3 +261,98 @@ fn pool_starts_with_a_nonzero_thread_count() {
259
261
  runtime::init(None);
260
262
  assert!(runtime::threads() >= 1);
261
263
  }
264
+
265
+ /// Block until `counter` reaches `target` (10 s guard).
266
+ fn wait_for_counter(counter: &AtomicUsize, target: usize) {
267
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
268
+ while counter.load(Ordering::SeqCst) < target {
269
+ assert!(
270
+ std::time::Instant::now() < deadline,
271
+ "counter never reached {target}"
272
+ );
273
+ std::thread::sleep(Duration::from_millis(1));
274
+ }
275
+ }
276
+
277
+ /// Block until the admission queue is empty (10 s guard).
278
+ fn wait_for_drained_queue() {
279
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
280
+ while runtime::queue_depth() != 0 {
281
+ assert!(std::time::Instant::now() < deadline, "queue never drained");
282
+ std::thread::sleep(Duration::from_millis(1));
283
+ }
284
+ }
285
+
286
+ /// The queue is a hard-bounded admission gate: once every worker is occupied
287
+ /// and the queue holds `queue_max` jobs, further submits are rejected rather
288
+ /// than growing memory; releasing the workers restores capacity.
289
+ #[test]
290
+ fn queue_admission_is_bounded_and_recovers() {
291
+ let _g = LOCK.lock();
292
+ completion::clear();
293
+ runtime::init(None);
294
+ let workers = runtime::threads().max(1) as usize;
295
+
296
+ const CAP: usize = 4;
297
+ runtime::set_queue_max(CAP);
298
+
299
+ let started = Arc::new(AtomicUsize::new(0));
300
+ let finished = Arc::new(AtomicUsize::new(0));
301
+ let release = Arc::new(AtomicBool::new(false));
302
+
303
+ let make_blocker = || {
304
+ let started = Arc::clone(&started);
305
+ let finished = Arc::clone(&finished);
306
+ let release = Arc::clone(&release);
307
+ move || {
308
+ started.fetch_add(1, Ordering::SeqCst);
309
+ while !release.load(Ordering::SeqCst) {
310
+ std::thread::sleep(Duration::from_millis(1));
311
+ }
312
+ finished.fetch_add(1, Ordering::SeqCst);
313
+ }
314
+ };
315
+
316
+ // Occupy every worker, one submission at a time, waiting for each to START
317
+ // so it has left the queue. (Submitting them all at once would let one
318
+ // worker batch-pop several, which still occupies it but muddies the depth
319
+ // arithmetic this test asserts.)
320
+ for i in 0..workers {
321
+ assert!(
322
+ runtime::submit(make_blocker()),
323
+ "worker {i} submit rejected"
324
+ );
325
+ wait_for_counter(&started, i + 1);
326
+ }
327
+ assert_eq!(runtime::queue_depth(), 0);
328
+ assert_eq!(runtime::queue_max(), CAP);
329
+
330
+ // Every worker is busy: fill the queue to the cap.
331
+ for i in 0..CAP {
332
+ assert!(
333
+ runtime::submit(make_blocker()),
334
+ "queued submit {i} rejected"
335
+ );
336
+ }
337
+ assert_eq!(runtime::queue_depth(), CAP);
338
+
339
+ // The cap is a HARD bound — the next submit is rejected, not queued.
340
+ assert!(
341
+ !runtime::submit(make_blocker()),
342
+ "submit accepted at the cap"
343
+ );
344
+ assert_eq!(runtime::queue_depth(), CAP);
345
+
346
+ // Release: the queue drains and admission recovers.
347
+ release.store(true, Ordering::SeqCst);
348
+ wait_for_counter(&finished, workers + CAP);
349
+ wait_for_drained_queue();
350
+ assert!(
351
+ runtime::submit(make_blocker()),
352
+ "capacity did not recover after drain"
353
+ );
354
+ wait_for_counter(&finished, workers + CAP + 1);
355
+
356
+ // Restore the default bound so a later test cannot inherit the tiny cap.
357
+ runtime::set_queue_max(4096);
358
+ }