kino 0.6.0 → 0.7.0

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.
@@ -8,20 +8,40 @@
8
8
  //! request headers.
9
9
  //!
10
10
  //! `Opaque<RString>` is magnus's sanctioned way to keep Ruby values in
11
- //! statics (it is Send + Sync); GC registration makes them immortal. Built
12
- //! once, with the GVL held, on the main ractor; read-only afterwards.
11
+ //! statics (it is Send + Sync); GC registration makes the static ones
12
+ //! immortal. Built once, with the GVL held, on the main ractor; read-only
13
+ //! afterwards.
14
+ //!
15
+ //! The LRU caches (hosts, peer addresses, interned header values) are the
16
+ //! exception: they insert and evict from every worker ractor in parallel.
17
+ //! That rules out per-value GC registration, because in Ruby 4.0
18
+ //! `rb_gc_register_address` and its unregister walk a VM-wide linked list
19
+ //! with no lock. Their strings are rooted through a [`PinSlab`] instead:
20
+ //! one atomic VALUE slot per cached string, marked from a keeper object
21
+ //! registered once at init. The same parallelism forbids any Ruby call
22
+ //! while a cache lock is held: a Ruby allocation can start a GC, a GC
23
+ //! with several ractors stops at a barrier every other ractor must
24
+ //! reach, and a ractor parked on the cache mutex never would. So the
25
+ //! caches allocate their strings first, then lock only to look up, root,
26
+ //! insert, or evict.
13
27
 
28
+ use std::borrow::Borrow;
29
+ use std::hash::Hash;
14
30
  use std::net::IpAddr;
15
- use std::sync::OnceLock;
31
+ use std::sync::{Arc, OnceLock};
16
32
 
33
+ use magnus::rb_sys::AsRawValue;
17
34
  use magnus::value::Opaque;
18
35
  use magnus::{gc, prelude::*, RString, Ruby, Value};
19
36
  use parking_lot::{Mutex, RwLock};
20
37
 
38
+ use crate::pin::{PinKeeper, PinSlab};
39
+
21
40
  /// These maps are probed several times per request; ahash beats the
22
41
  /// DoS-resistant default since every key here is our own static data.
23
42
  type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
24
43
  type HashSet<K> = std::collections::HashSet<K, ahash::RandomState>;
44
+ type LruCache<K, V> = lru::LruCache<K, V, ahash::RandomState>;
25
45
 
26
46
  pub struct EnvStrings {
27
47
  // keys
@@ -49,19 +69,22 @@ pub struct EnvStrings {
49
69
  pub methods: HashMap<&'static str, Opaque<RString>>,
50
70
  /// lowercase header name -> frozen "HTTP_<UPPER>" key
51
71
  pub header_names: HashMap<&'static str, Opaque<RString>>,
72
+ /// GC roots for every string the LRU caches below hold (see the
73
+ /// module docs); a cache entry owns its slots and clears them when
74
+ /// it is evicted.
75
+ pub slab: Arc<PinSlab>,
52
76
  /// Host-header or :authority bytes -> frozen host values, and peer
53
77
  /// IP -> frozen REMOTE_ADDR value. Real traffic has low cardinality
54
78
  /// on both, so these kill 3 string allocations per request.
55
- /// LRU-bounded: entries are BoxValue-rooted (registered with the GC on
56
- /// insert, UNregistered on eviction-drop), so a rotating-host attack
57
- /// recycles cache slots instead of leaking immortal strings.
58
- pub hosts: Mutex<lru::LruCache<Vec<u8>, HostEntry, ahash::RandomState>>,
59
- pub addrs: Mutex<lru::LruCache<IpAddr, CachedStr, ahash::RandomState>>,
79
+ /// LRU-bounded, so a rotating-host attack recycles cache slots
80
+ /// instead of accumulating immortal strings.
81
+ pub hosts: Mutex<LruCache<Vec<u8>, HostEntry>>,
82
+ pub addrs: Mutex<LruCache<IpAddr, CachedStr>>,
60
83
  /// Interned values of low-cardinality headers (see
61
84
  /// [`INTERNABLE_VALUES`]): value bytes -> frozen RString, shared
62
85
  /// across headers that happen to carry the same bytes. Same rooting
63
86
  /// and locking contract as `hosts`/`addrs`.
64
- pub values: Mutex<lru::LruCache<Vec<u8>, CachedStr, ahash::RandomState>>,
87
+ pub values: Mutex<LruCache<Vec<u8>, CachedStr>>,
65
88
  /// The names whose values go through the `values` cache.
66
89
  pub internable: HashSet<&'static str>,
67
90
  /// Ractor-shareable defaults provided by the Ruby layer at boot:
@@ -74,6 +97,14 @@ const HOST_CACHE_CAP: usize = 256;
74
97
  const ADDR_CACHE_CAP: usize = 1024;
75
98
  const VALUE_CACHE_CAP: usize = 512;
76
99
 
100
+ /// Slab slots for every string the three caches can hold at once: up to
101
+ /// three per host entry, one per address and per interned value, plus
102
+ /// one entry per cache for the insert that precedes an eviction. A full
103
+ /// slab (which this bound rules out) degrades to an uncached string,
104
+ /// never to an unrooted one.
105
+ const CACHE_SLAB_CAPACITY: usize =
106
+ (HOST_CACHE_CAP + 1) * 3 + (ADDR_CACHE_CAP + 1) + (VALUE_CACHE_CAP + 1);
107
+
77
108
  /// Values longer than this are never interned: past it the memcpy into
78
109
  /// a fresh Ruby string is cheap relative to the bytes themselves, and
79
110
  /// unbounded keys would let one client fill the cache with garbage.
@@ -120,38 +151,75 @@ pub struct HostEntry {
120
151
  host: Option<CachedStr>,
121
152
  }
122
153
 
123
- /// A frozen RString rooted via BoxValue (GC-registered address; unregisters
124
- /// on Drop, so LRU eviction actually frees the string).
154
+ impl HostEntry {
155
+ /// Root a fresh entry; None (with nothing left rooted) when the slab
156
+ /// is full.
157
+ fn root(
158
+ slab: &Arc<PinSlab>,
159
+ name: RString,
160
+ port: RString,
161
+ host: Option<RString>,
162
+ ) -> Option<Self> {
163
+ let name = CachedStr::root(slab, name)?;
164
+ let port = CachedStr::root(slab, port)?;
165
+ let host = match host {
166
+ Some(host) => Some(CachedStr::root(slab, host)?),
167
+ None => None,
168
+ };
169
+ Some(HostEntry { name, port, host })
170
+ }
171
+
172
+ fn name_port(&self, ruby: &Ruby) -> (RString, RString) {
173
+ (self.name.get(ruby), self.port.get(ruby))
174
+ }
175
+
176
+ /// The entry's full-authority string, adopting `host` (rooted, slab
177
+ /// permitting) when it has none yet.
178
+ fn fill_host(&mut self, ruby: &Ruby, slab: &Arc<PinSlab>, host: RString) -> RString {
179
+ if self.host.is_none() {
180
+ self.host = CachedStr::root(slab, host);
181
+ }
182
+ self.host.as_ref().map_or(host, |cached| cached.get(ruby))
183
+ }
184
+ }
185
+
186
+ /// A frozen RString kept alive by one slab slot for as long as its cache
187
+ /// entry exists. Drop (LRU eviction, or an insert that lost a race)
188
+ /// clears the slot; the string then dies at the next sweep unless an
189
+ /// env still references it.
125
190
  ///
126
- /// SAFETY of Send + Sync: the contents are frozen Ruby strings (therefore
127
- /// Ractor-shareable), and creation, reads, and drops all happen while the
128
- /// calling thread holds its GVL (native method context) AND the owning
129
- /// cache Mutex; readers root the value into a live env Hash before
130
- /// releasing the lock, so an eviction on another ractor can never free a
131
- /// string that a reader still holds unrooted.
132
- pub struct CachedStr(magnus::value::BoxValue<RString>);
133
- unsafe impl Send for CachedStr {}
134
- unsafe impl Sync for CachedStr {}
191
+ /// Readers copy the VALUE out under the cache lock and set it on their
192
+ /// env only afterwards (the aset is a Ruby call, so it may not run under
193
+ /// the lock; see the module docs). That is sound even when another
194
+ /// ractor evicts the entry in between: a VALUE held in a native frame is
195
+ /// a conservative GC root, since Ruby scans every thread's machine stack
196
+ /// and saved registers on every ractor, so the string outlives the
197
+ /// window until the env roots it for good.
198
+ pub struct CachedStr {
199
+ value: Opaque<RString>,
200
+ slab: Arc<PinSlab>,
201
+ slot: usize,
202
+ }
135
203
 
136
204
  impl CachedStr {
137
- fn new(ruby: &Ruby, s: &str) -> Self {
138
- let string = ruby.str_new(s);
139
- string.freeze();
140
- CachedStr(magnus::value::BoxValue::new(string))
205
+ /// Root `string` in the slab; None when the slab is full.
206
+ fn root(slab: &Arc<PinSlab>, string: RString) -> Option<Self> {
207
+ let slot = slab.insert(string.as_raw())?;
208
+ Some(CachedStr {
209
+ value: Opaque::from(string),
210
+ slab: slab.clone(),
211
+ slot,
212
+ })
141
213
  }
142
214
 
143
- /// Header values are bytes on the wire (not guaranteed UTF-8), so
144
- /// they cache as the same binary strings `str_from_slice` builds on
145
- /// the uncached path; interning must not change the encoding an
146
- /// app observes.
147
- fn new_from_slice(ruby: &Ruby, bytes: &[u8]) -> Self {
148
- let string = ruby.str_from_slice(bytes);
149
- string.freeze();
150
- CachedStr(magnus::value::BoxValue::new(string))
215
+ fn get(&self, ruby: &Ruby) -> RString {
216
+ ruby.get_inner(self.value)
151
217
  }
218
+ }
152
219
 
153
- fn get(&self) -> RString {
154
- *self.0
220
+ impl Drop for CachedStr {
221
+ fn drop(&mut self) {
222
+ self.slab.release(self.slot);
155
223
  }
156
224
  }
157
225
 
@@ -224,13 +292,39 @@ pub fn cgi_name(lower: &str) -> String {
224
292
  key
225
293
  }
226
294
 
295
+ /// A frozen string rooted for the life of the process. Init only: the
296
+ /// registration takes the VM lock, and nothing else must exist yet.
227
297
  fn frozen(ruby: &Ruby, s: &str) -> Opaque<RString> {
228
- let string = ruby.str_new(s);
229
- string.freeze();
298
+ let string = frozen_str(ruby, s);
230
299
  gc::register_mark_object(string);
231
300
  Opaque::from(string)
232
301
  }
233
302
 
303
+ /// A fresh frozen string, rooted only by the caller's stack until a
304
+ /// cache slot or an env takes it.
305
+ fn frozen_str(ruby: &Ruby, s: &str) -> RString {
306
+ let string = ruby.str_new(s);
307
+ string.freeze();
308
+ string
309
+ }
310
+
311
+ /// Header values are bytes on the wire (not guaranteed UTF-8), so they
312
+ /// cache as the same binary strings `str_from_slice` builds on the
313
+ /// uncached path; interning must not change the encoding an app
314
+ /// observes.
315
+ fn frozen_slice(ruby: &Ruby, bytes: &[u8]) -> RString {
316
+ let string = ruby.str_from_slice(bytes);
317
+ string.freeze();
318
+ string
319
+ }
320
+
321
+ fn lru<K: Hash + Eq, V>(capacity: usize) -> Mutex<LruCache<K, V>> {
322
+ Mutex::new(LruCache::with_hasher(
323
+ std::num::NonZeroUsize::new(capacity).expect("cache capacity is non-zero"),
324
+ ahash::RandomState::new(),
325
+ ))
326
+ }
327
+
234
328
  /// Build the cache. Main ractor, GVL held, before any worker exists.
235
329
  pub fn init(ruby: &Ruby) {
236
330
  let methods = COMMON_METHODS
@@ -242,6 +336,11 @@ pub fn init(ruby: &Ruby) {
242
336
  .map(|h| (*h, frozen(ruby, &cgi_name(h))))
243
337
  .collect::<HashMap<_, _>>();
244
338
 
339
+ // The slab's GC-visible face lives for the process: registered here
340
+ // on the main ractor, before any worker ractor can exist.
341
+ let slab = Arc::new(PinSlab::with_capacity(CACHE_SLAB_CAPACITY));
342
+ gc::register_mark_object(ruby.obj_wrap(PinKeeper(slab.clone())));
343
+
245
344
  let strings = EnvStrings {
246
345
  request_method: frozen(ruby, "REQUEST_METHOD"),
247
346
  script_name: frozen(ruby, "SCRIPT_NAME"),
@@ -265,18 +364,10 @@ pub fn init(ruby: &Ruby) {
265
364
  http2: frozen(ruby, "HTTP/2"),
266
365
  methods,
267
366
  header_names,
268
- hosts: Mutex::new(lru::LruCache::with_hasher(
269
- std::num::NonZeroUsize::new(HOST_CACHE_CAP).unwrap(),
270
- ahash::RandomState::new(),
271
- )),
272
- addrs: Mutex::new(lru::LruCache::with_hasher(
273
- std::num::NonZeroUsize::new(ADDR_CACHE_CAP).unwrap(),
274
- ahash::RandomState::new(),
275
- )),
276
- values: Mutex::new(lru::LruCache::with_hasher(
277
- std::num::NonZeroUsize::new(VALUE_CACHE_CAP).unwrap(),
278
- ahash::RandomState::new(),
279
- )),
367
+ slab,
368
+ hosts: lru(HOST_CACHE_CAP),
369
+ addrs: lru(ADDR_CACHE_CAP),
370
+ values: lru(VALUE_CACHE_CAP),
280
371
  internable: INTERNABLE_VALUES.iter().copied().collect(),
281
372
  errors_stream: RwLock::new(None),
282
373
  null_input: RwLock::new(None),
@@ -288,18 +379,24 @@ pub fn get() -> &'static EnvStrings {
288
379
  ENV_STRINGS.get().expect("env_strings::init not called")
289
380
  }
290
381
 
291
- /// Called once from lib/kino.rb (main ractor) with the frozen,
292
- /// Ractor-shareable singletons the Ruby layer owns.
382
+ /// Called once from lib/kino.rb (main ractor) with the Ractor-shareable
383
+ /// singletons the Ruby layer owns. Shareability is the real requirement
384
+ /// (every worker ractor reads them out of its envs), and it is stricter
385
+ /// than frozen: a frozen object whose state is not itself shareable
386
+ /// fails it. `Ractor.shareable?` is `rb_ractor_shareable_p`, which rb-sys
387
+ /// does not bind (it is a static inline).
293
388
  pub fn register_defaults(
294
389
  ruby: &Ruby,
295
390
  errors: Value,
296
391
  null_input: Value,
297
392
  ) -> Result<(), magnus::Error> {
393
+ let ractor: Value = ruby.class_object().const_get("Ractor")?;
298
394
  for value in [errors, null_input] {
299
- if !value.is_frozen() {
395
+ let shareable: bool = ractor.funcall("shareable?", (value,))?;
396
+ if !shareable {
300
397
  return Err(magnus::Error::new(
301
398
  ruby.exception_arg_error(),
302
- "register_defaults expects frozen objects",
399
+ "register_defaults expects Ractor-shareable objects",
303
400
  ));
304
401
  }
305
402
  }
@@ -311,9 +408,41 @@ pub fn register_defaults(
311
408
  Ok(())
312
409
  }
313
410
 
411
+ /// The cached value for `key`, or a freshly built one, inserted for the
412
+ /// next caller. `build` runs with the lock released (it allocates Ruby
413
+ /// strings; see the module docs); `read` copies a cached entry's value
414
+ /// out; `root` turns a fresh value into an entry, or None when the slab
415
+ /// is full, in which case the value is used uncached. An entry a racing
416
+ /// ractor inserted meanwhile wins over ours, whose string is then plain
417
+ /// garbage.
418
+ fn get_or_insert<K, Q, V, T>(
419
+ cache: &Mutex<LruCache<K, V>>,
420
+ key: &Q,
421
+ read: impl Fn(&V) -> T,
422
+ build: impl FnOnce() -> T,
423
+ root: impl FnOnce(&T) -> Option<V>,
424
+ own_key: impl FnOnce() -> K,
425
+ ) -> T
426
+ where
427
+ K: Hash + Eq + Borrow<Q>,
428
+ Q: Hash + Eq + ?Sized,
429
+ {
430
+ if let Some(found) = cache.lock().get(key) {
431
+ return read(found);
432
+ }
433
+ let fresh = build();
434
+ let mut cache = cache.lock();
435
+ if let Some(found) = cache.get(key) {
436
+ return read(found);
437
+ }
438
+ if let Some(entry) = root(&fresh) {
439
+ cache.put(own_key(), entry); // may evict: the entry's Drop clears its slots
440
+ }
441
+ fresh
442
+ }
443
+
314
444
  /// Set SERVER_NAME/SERVER_PORT on `env` from the LRU host cache, building
315
- /// (and caching) frozen values on miss. The aset happens UNDER the cache
316
- /// lock; see CachedStr's safety contract.
445
+ /// (and caching) frozen values on miss.
317
446
  pub fn set_host_env(
318
447
  ruby: &Ruby,
319
448
  env: magnus::RHash,
@@ -321,21 +450,17 @@ pub fn set_host_env(
321
450
  make: impl FnOnce() -> (String, u16),
322
451
  ) -> Result<(), magnus::Error> {
323
452
  let s = get();
324
- let mut hosts = s.hosts.lock();
325
- let (name, port) = match hosts.get(host) {
326
- Some(entry) => (entry.name.get(), entry.port.get()),
327
- None => {
328
- let (name_s, port_n) = make();
329
- let entry = HostEntry {
330
- name: CachedStr::new(ruby, &name_s),
331
- port: CachedStr::new(ruby, &port_n.to_string()),
332
- host: None,
333
- };
334
- let values = (entry.name.get(), entry.port.get());
335
- hosts.put(host.to_vec(), entry); // may evict + free an old entry
336
- values
337
- }
338
- };
453
+ let (name, port) = get_or_insert(
454
+ &s.hosts,
455
+ host,
456
+ |entry| entry.name_port(ruby),
457
+ || {
458
+ let (name, port) = make();
459
+ (frozen_str(ruby, &name), frozen_str(ruby, &port.to_string()))
460
+ },
461
+ |&(name, port)| HostEntry::root(&s.slab, name, port, None),
462
+ || host.to_vec(),
463
+ );
339
464
  env.aset(ruby.get_inner(s.server_name), name)?;
340
465
  env.aset(ruby.get_inner(s.server_port), port)?;
341
466
  Ok(())
@@ -354,32 +479,44 @@ pub fn set_authority_env(
354
479
  make: impl FnOnce() -> (String, u16),
355
480
  ) -> Result<(), magnus::Error> {
356
481
  let s = get();
357
- let mut hosts = s.hosts.lock();
358
- let (name, port, host) = match hosts.get_mut(authority.as_bytes()) {
359
- Some(entry) => {
360
- if entry.host.is_none() {
361
- entry.host = Some(CachedStr::new(ruby, authority));
482
+ let key = authority.as_bytes();
483
+ let read = |entry: &HostEntry| {
484
+ let (name, port) = entry.name_port(ruby);
485
+ (name, port, entry.host.as_ref().map(|host| host.get(ruby)))
486
+ };
487
+ // Bound to a local so the lock guard (a temporary of this statement)
488
+ // is gone before the arms below take the lock again.
489
+ let hit = s.hosts.lock().get(key).map(read);
490
+ let (name, port, host) = match hit {
491
+ Some((name, port, Some(host))) => (name, port, host),
492
+ Some((name, port, None)) => {
493
+ let host = frozen_str(ruby, authority);
494
+ let mut hosts = s.hosts.lock();
495
+ match hosts.get_mut(key) {
496
+ Some(entry) => (name, port, entry.fill_host(ruby, &s.slab, host)),
497
+ // Evicted meanwhile; the strings in hand are still valid.
498
+ None => (name, port, host),
362
499
  }
363
- (
364
- entry.name.get(),
365
- entry.port.get(),
366
- entry.host.as_ref().expect("just filled").get(),
367
- )
368
500
  }
369
501
  None => {
370
- let (name_s, port_n) = make();
371
- let entry = HostEntry {
372
- name: CachedStr::new(ruby, &name_s),
373
- port: CachedStr::new(ruby, &port_n.to_string()),
374
- host: Some(CachedStr::new(ruby, authority)),
375
- };
376
- let values = (
377
- entry.name.get(),
378
- entry.port.get(),
379
- entry.host.as_ref().expect("just built").get(),
380
- );
381
- hosts.put(authority.as_bytes().to_vec(), entry);
382
- values
502
+ let (name, port) = make();
503
+ let name = frozen_str(ruby, &name);
504
+ let port = frozen_str(ruby, &port.to_string());
505
+ let host = frozen_str(ruby, authority);
506
+ let mut hosts = s.hosts.lock();
507
+ match hosts.get_mut(key) {
508
+ // A racing ractor got there first; share its entry.
509
+ Some(entry) => {
510
+ let (name, port) = entry.name_port(ruby);
511
+ (name, port, entry.fill_host(ruby, &s.slab, host))
512
+ }
513
+ None => {
514
+ if let Some(entry) = HostEntry::root(&s.slab, name, port, Some(host)) {
515
+ hosts.put(key.to_vec(), entry);
516
+ }
517
+ (name, port, host)
518
+ }
519
+ }
383
520
  }
384
521
  };
385
522
  env.aset(ruby.get_inner(s.server_name), name)?;
@@ -391,8 +528,7 @@ pub fn set_authority_env(
391
528
 
392
529
  /// Set one header's value on `env` under `key`: through the interned
393
530
  /// value cache when the header qualifies (low-cardinality name, bounded
394
- /// length), else a fresh per-request string. The cached aset happens
395
- /// under the cache lock; see CachedStr's safety contract.
531
+ /// length), else a fresh per-request string.
396
532
  pub fn set_value_env(
397
533
  ruby: &Ruby,
398
534
  env: magnus::RHash,
@@ -404,36 +540,29 @@ pub fn set_value_env(
404
540
  if value.len() > VALUE_INTERN_MAX_LEN || !s.internable.contains(name) {
405
541
  return env.aset(key, ruby.str_from_slice(value));
406
542
  }
407
- let mut values = s.values.lock();
408
- let cached = match values.get(value) {
409
- Some(cached) => cached.get(),
410
- None => {
411
- let entry = CachedStr::new_from_slice(ruby, value);
412
- let string = entry.get();
413
- values.put(value.to_vec(), entry); // may evict + free an old value
414
- string
415
- }
416
- };
417
- env.aset(key, cached)?;
418
- Ok(())
543
+ let cached = get_or_insert(
544
+ &s.values,
545
+ value,
546
+ |cached| cached.get(ruby),
547
+ || frozen_slice(ruby, value),
548
+ |&fresh| CachedStr::root(&s.slab, fresh),
549
+ || value.to_vec(),
550
+ );
551
+ env.aset(key, cached)
419
552
  }
420
553
 
421
- /// Set REMOTE_ADDR on `env` from the LRU peer-IP cache; same locking
422
- /// contract as set_host_env.
554
+ /// Set REMOTE_ADDR on `env` from the LRU peer-IP cache.
423
555
  pub fn set_addr_env(ruby: &Ruby, env: magnus::RHash, ip: IpAddr) -> Result<(), magnus::Error> {
424
556
  let s = get();
425
- let mut addrs = s.addrs.lock();
426
- let value = match addrs.get(&ip) {
427
- Some(cached) => cached.get(),
428
- None => {
429
- let entry = CachedStr::new(ruby, &ip.to_string());
430
- let value = entry.get();
431
- addrs.put(ip, entry);
432
- value
433
- }
434
- };
435
- env.aset(ruby.get_inner(s.remote_addr), value)?;
436
- Ok(())
557
+ let value = get_or_insert(
558
+ &s.addrs,
559
+ &ip,
560
+ |cached| cached.get(ruby),
561
+ || frozen_str(ruby, &ip.to_string()),
562
+ |&fresh| CachedStr::root(&s.slab, fresh),
563
+ || ip,
564
+ );
565
+ env.aset(ruby.get_inner(s.remote_addr), value)
437
566
  }
438
567
 
439
568
  #[cfg(test)]
data/ext/kino/src/lib.rs CHANGED
@@ -49,6 +49,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
49
49
  native.define_singleton_method("worker_stats", function!(server::worker_stats, 1))?;
50
50
  native.define_singleton_method("queue_time", function!(server::queue_time, 1))?;
51
51
  native.define_singleton_method("quarantine_slot", function!(server::quarantine_slot, 2))?;
52
+ native.define_singleton_method("retire_slot", function!(server::retire_slot, 2))?;
53
+ native.define_singleton_method("reset_slot", function!(server::reset_slot, 2))?;
54
+ native.define_singleton_method(
55
+ "set_active_workers",
56
+ function!(server::set_active_workers, 2),
57
+ )?;
58
+ native.define_singleton_method("record_scale_up", function!(server::record_scale_up, 1))?;
59
+ native.define_singleton_method("record_scale_down", function!(server::record_scale_down, 1))?;
60
+ native.define_singleton_method("pool_stats", function!(server::pool_stats, 1))?;
52
61
  native.define_singleton_method(
53
62
  "record_quarantine_replacement",
54
63
  function!(server::record_quarantine_replacement, 1),
@@ -106,6 +115,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
106
115
  // two worker ractors is the failure mode we must rule out.
107
116
  let _ = <Request as magnus::TypedData>::class(ruby);
108
117
  let _ = <queue::Worker as magnus::TypedData>::class(ruby);
118
+ let _ = <pin::PinKeeper as magnus::TypedData>::class(ruby);
109
119
 
110
120
  // Frozen env key/value cache: built once here (main ractor, GVL held),
111
121
  // shared by every worker ractor afterwards.
@@ -116,6 +126,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
116
126
  native.define_singleton_method("_test_take", function!(test_support::take, 1))?;
117
127
  native.define_singleton_method("_test_close", function!(test_support::close, 1))?;
118
128
  native.define_singleton_method("_test_panic", function!(test_support::panic_in_release, 0))?;
129
+ native.define_singleton_method("_test_env_probe", function!(test_support::env_probe, 4))?;
119
130
 
120
131
  Ok(())
121
132
  }
data/ext/kino/src/pin.rs CHANGED
@@ -22,6 +22,10 @@
22
22
  //! GVL held), release is a single atomic store from whatever tokio
23
23
  //! thread drops the buffer, and the mark hook only loads atomics, so
24
24
  //! no path takes a lock. A full slab degrades to the copy path.
25
+ //!
26
+ //! The slab is also how env_strings.rs roots its cached env strings (a
27
+ //! second slab, marked through its own keeper), for the same reason: no
28
+ //! GC registration API may be called from parallel ractors.
25
29
 
26
30
  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27
31
  use std::sync::Arc;
@@ -50,8 +54,9 @@ extern "C" {
50
54
  fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
51
55
  }
52
56
 
53
- /// GC roots for in-flight response buffers. Slot value 0 = empty (a
54
- /// VALUE of 0 is Qfalse, which can never be a pinned string).
57
+ /// A fixed slab of atomic VALUE slots, each a pinning GC root while it
58
+ /// is non-zero. Slot value 0 = empty (a VALUE of 0 is Qfalse, which can
59
+ /// never be a rooted string).
55
60
  pub struct PinSlab {
56
61
  slots: Box<[AtomicU64]>,
57
62
  /// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
@@ -59,15 +64,21 @@ pub struct PinSlab {
59
64
  }
60
65
 
61
66
  impl PinSlab {
67
+ /// A slab for one server's in-flight response buffers.
62
68
  pub fn new() -> Self {
69
+ Self::with_capacity(SLAB_CAPACITY)
70
+ }
71
+
72
+ pub fn with_capacity(capacity: usize) -> Self {
63
73
  PinSlab {
64
- slots: (0..SLAB_CAPACITY).map(|_| AtomicU64::new(0)).collect(),
74
+ slots: (0..capacity).map(|_| AtomicU64::new(0)).collect(),
65
75
  cursor: AtomicUsize::new(0),
66
76
  }
67
77
  }
68
78
 
69
- /// Root `value`; None when the slab is full (caller copies instead).
70
- fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
79
+ /// Root `value`; None when the slab is full (the caller falls back to
80
+ /// a copy, or to an uncached string).
81
+ pub(crate) fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
71
82
  let start = self.cursor.fetch_add(1, Ordering::Relaxed);
72
83
  for offset in 0..self.slots.len() {
73
84
  let index = (start + offset) % self.slots.len();
@@ -83,7 +94,7 @@ impl PinSlab {
83
94
 
84
95
  /// Drop the root. Called from tokio threads: a plain atomic store,
85
96
  /// no Ruby API. The string stays alive until the next GC sweep.
86
- fn release(&self, index: usize) {
97
+ pub(crate) fn release(&self, index: usize) {
87
98
  self.slots[index].store(0, Ordering::SeqCst);
88
99
  }
89
100
 
@@ -113,9 +124,10 @@ impl PinSlab {
113
124
  }
114
125
  }
115
126
 
116
- /// The GC-visible face of a server's PinSlab: `Kino::Server` holds one
117
- /// for its lifetime (surviving worker-ractor crashes), and Ruby's GC
118
- /// marks every pinned string through it.
127
+ /// The GC-visible face of a PinSlab: Ruby's GC marks every rooted string
128
+ /// through it. `Kino::Server` holds one for its response-buffer slab for
129
+ /// the server's lifetime (surviving worker-ractor crashes); env_strings
130
+ /// registers one for its cache slab as an immortal root at init.
119
131
  #[derive(magnus::TypedData)]
120
132
  #[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
121
133
  pub struct PinKeeper(pub Arc<PinSlab>);