rusty_racer 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebbfddd8204b62eed228d53d476247e3b76bf08a894b02fb3e8ffe78836b6b02
4
- data.tar.gz: 77d0190f0387cbdf240d49ef4ac3b75c1550a18a74de8ceed78a8c97c1956d96
3
+ metadata.gz: 214a3eb41745b4ed7c93662fa1cc1e1ecd11bc4678d5633eeab9a2075f5b4965
4
+ data.tar.gz: 87d473be7421ac6ff8d2c5ef7d2e5039ab75987660c313a37916d3f81def2477
5
5
  SHA512:
6
- metadata.gz: e0a3df4af6404d9357a008e2adcb4d8303a39db35ad9d3d4ef0240217222bd0fd0be40c9b18b36b9778e30e9eac8a5ce9b20514ecc962975cc25b1466d504aac
7
- data.tar.gz: 497c28687de4bc14c7b666e99a3205406e84384459b923dca24b8d2080db552d3266bbffbfbecfb0a4e61bde9c17aa4413f39e321d1c866339df6944782c3930
6
+ metadata.gz: 8bd2a0dbfd742b01ceb21447a7028401362d3f9cf55ed449e1eae66aae88fa020c9a80edcdd49e606395880d9c54e4a3d3e8af4b20449387ab6246a234fcd6f6
7
+ data.tar.gz: 0147def7b482ce78b420bfff341f5575a045ba806c1ea853ad3d24bbe2557735a2e672ed9a1510da8727ce9ae0f01ce7409460d8362b6819f3cc2cc30bffbbd8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7
4
+
5
+ ### Changed
6
+
7
+ - An operation on a Ruby Fiber looks its stack's bounds up once per stack
8
+ rather than once per operation that a 16-entry cache had dropped. Capybara
9
+ filters each result set in an Enumerator left suspended until GC frees it, so
10
+ a page of finds runs on hundreds of distinct fiber stacks, and each lookup read
11
+ all of `/proc/self/maps` — a fifth of a find loop, and slower the larger
12
+ Ruby's fiber pool grew. Every stack an operation has run on is now kept (Ruby
13
+ hands its pooled stacks out again and never unmaps them), and on Linux 6.11+ a
14
+ lookup asks the kernel for the one mapping (`PROCMAP_QUERY`) instead of
15
+ reading the whole map. A call into V8 from a fresh fiber, steady state: 160 µs
16
+ → 1.0 µs beside a 2,200-line map, 521 µs → 1.0 µs beside 8,300 lines.
17
+
3
18
  ## 0.2.6
4
19
 
5
20
  ### Added
@@ -5,7 +5,7 @@
5
5
  # links and loads with no custom archive.
6
6
  [package]
7
7
  name = "rusty_racer"
8
- version = "0.2.6"
8
+ version = "0.2.7"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -29,8 +29,9 @@ rb-sys = "0.9"
29
29
  # (which is built with rusty_v8_enable_simdutf=true) on every platform.
30
30
  v8 = { version = "150.4.0", features = ["simdutf"] }
31
31
  # pthread stack-bounds query, to set V8's stack limit per op relative to the
32
- # current native thread (in-thread V8 runs on the Ruby thread's stack).
33
- libc = "0.2"
32
+ # current native thread (in-thread V8 runs on the Ruby thread's stack). 0.2.172
33
+ # for `_IOWR`, which builds the PROCMAP_QUERY ioctl number per architecture.
34
+ libc = "0.2.172"
34
35
 
35
36
  # `cargo test` links libruby: the crate calls into it, though the marshalling tests
36
37
  # that run without a VM never do.
@@ -151,33 +151,24 @@ fn native_stack_bounds() -> (usize, usize) {
151
151
  (0, 0)
152
152
  }
153
153
 
154
- // Lower bound (and upper, for the cache test) of the memory region containing
155
- // `addr` — i.e. the BOTTOM of the stack `addr` is on. Used for a Ruby Fiber,
156
- // whose mmap'd stack pthread can't see: V8's limit must sit ABOVE this bottom or
157
- // a deep fiber recursion overflows the real stack and SEGVs the unmapped guard.
158
- // (0, 0) if unknown.
154
+ // The BOTTOM of the stack `addr` is on, and the highest address an op has been
155
+ // seen running at on it. Used for a Ruby Fiber, whose mmap'd stack pthread can't
156
+ // see: V8's limit must sit ABOVE this bottom or a deep fiber recursion overflows
157
+ // the real stack and SEGVs the unmapped guard. (0, 0) if unknown.
159
158
  //
160
- // Cached per native thread, several regions at a time, because a miss asks the
161
- // OS for the whole memory map and costs far more than the op it serves: with ONE
162
- // slot
163
- // a workload that alternates between fibers missed on every single op, and an
164
- // eval went from 1.2us to 31us as soon as two fibers took turns (measured on a
165
- // 212-line maps; a Rails process has many times that). Ruby pools fiber stacks,
166
- // so the live set is small and stable — one Enumerator per Capybara result, say
167
- // — and a handful of slots turns that back into hits.
159
+ // Cached per native thread, for EVERY stack an op has run on, because a miss
160
+ // asks the OS and a workload can run each op on a stack it has never used before:
161
+ // Capybara filters each result set in an Enumerator, left suspended until GC
162
+ // frees it, so a thousand finds run on a thousand distinct fiber stacks. A cache
163
+ // of sixteen slots missed on nearly every one of them, and where a miss reads all
164
+ // of /proc/self/maps — whose length grows with Ruby's fiber pool — those reads
165
+ // were a fifth of the find loop (1003 per 1000 finds). Keeping every stack makes
166
+ // the steady state free: the pool hands the same stacks out again (its free list
167
+ // is LIFO, and it never unmaps them — below), so once each has had an op on it
168
+ // nothing misses. The table is as large as the set of stacks ops have run on,
169
+ // which that pool bounds.
168
170
  //
169
- // Eviction is RANDOM, not round-robin (or LRU, which behaves the same here):
170
- // ops cycling through more fibers than there are slots are exactly the pattern
171
- // those policies handle worst, since each eviction takes the entry needed next
172
- // and the hit rate collapses to zero — one fiber over the size measured as slow
173
- // as having no cache at all. Evicting at random keeps roughly
174
- // slots/live-fibers of the accesses, so going over the size degrades gradually
175
- // instead of falling off a cliff. Empty slots are filled before anything is
176
- // evicted. Entries are never invalidated: a dead fiber's keeps its slot until
177
- // some miss draws it, and with a live set that fits there are no misses at all
178
- // — which is why a hit has to be self-justifying rather than merely plausible.
179
- //
180
- // So the mapping's top is never used, or even kept, as an upper bound. A fiber
171
+ // The mapping's top is never used, or even kept, as an upper bound. A fiber
181
172
  // stack's mapping can extend far above the stack itself, where the kernel merged
182
173
  // it with an adjacent anonymous mapping of the same protection that has nothing
183
174
  // to do with the fiber pool (measured: a 644KB stack inside an 8840KB mapping,
@@ -207,94 +198,160 @@ fn native_stack_bounds() -> (usize, usize) {
207
198
  // Within that window a cached bottom is the fiber's real bottom: Ruby brackets
208
199
  // every fiber stack with PROT_NONE guard pages, so the mapping cannot start
209
200
  // below the stack, and a pooled slot is handed to the next fiber with identical
210
- // bounds.
211
- const FIBER_REGION_SLOTS: usize = 16;
212
-
213
- #[derive(Clone, Copy)]
214
- struct FiberRegion {
215
- // The mapping's bottom, and the key: 0 marks the slot unused.
201
+ // bounds. Entries are never invalidated, and that rests on the pool too: Ruby
202
+ // frees a pool allocation only when built with FIBER_POOL_ALLOCATION_FREE (cont.c
203
+ // defines it for a limited address space, i.e. 32-bit, which V8 doesn't ship for,
204
+ // and for RB_EXPERIMENTAL_FIBER_POOL), so the bounds an op saw on a stack stay
205
+ // the bounds of every later fiber there. Under a build that does unmap, an entry
206
+ // can outlive its memory; a hit is then still right when the memory is mapped
207
+ // again as the same pool's stacks at the same address — the same stride, so the
208
+ // same guards — and wrong, with a bottom below the new one, when a later mapping
209
+ // puts a guard page inside the old window. The same goes for a stack that is not
210
+ // Ruby's: a C extension's own coroutine stack, unmapped once it is done with and
211
+ // its addresses reused, can leave an entry that answers for memory it no longer
212
+ // describes. Ruby's M:N thread stacks are safe the way its fibers are: a freed
213
+ // one is only madvised, never unmapped.
214
+ #[derive(Clone, Copy, Debug, PartialEq)]
215
+ struct FiberStack {
216
+ // The bottom of the stack's mapping.
216
217
  lo: usize,
217
218
  // Highest address a V8 op has run at on this stack.
218
219
  seen: usize,
219
220
  }
220
221
 
221
- #[derive(Clone, Copy)]
222
- struct FiberRegions {
223
- slots: [FiberRegion; FIBER_REGION_SLOTS],
224
- // xorshift64 state for picking a victim. Seeded to a fixed nonzero constant
225
- // (the golden-ratio one): nothing here needs unpredictability, only that the
226
- // eviction order doesn't line up with the caller's rotation through fibers.
227
- rng: u64,
222
+ // Sorted by `lo`, with disjoint [lo, seen] windows — so `seen` ascends too, and
223
+ // both lookups below are binary searches.
224
+ struct FiberStacks(Vec<FiberStack>);
225
+
226
+ impl FiberStacks {
227
+ // The entry whose window holds `addr`.
228
+ fn find(&self, addr: usize) -> Option<FiberStack> {
229
+ let below = self.0.partition_point(|s| s.lo <= addr);
230
+ let stack = *self.0.get(below.checked_sub(1)?)?;
231
+ (addr <= stack.seen).then_some(stack)
232
+ }
233
+
234
+ // (bottom, highest-address-seen) of the stack `addr` is on: the entry's when
235
+ // one holds it, else `query`'s answer for `addr`, recorded — 0 there being
236
+ // "unknown", which is recorded nowhere.
237
+ fn bounds(&mut self, addr: usize, query: impl FnOnce(usize) -> usize) -> (usize, usize) {
238
+ if let Some(stack) = self.find(addr) {
239
+ return (stack.lo, stack.seen);
240
+ }
241
+ let lo = query(addr);
242
+ if lo == 0 {
243
+ return (0, 0);
244
+ }
245
+ self.record(lo, addr);
246
+ (lo, addr)
247
+ }
248
+
249
+ // Record an op at `addr` on the stack whose bottom was just looked up as
250
+ // `lo`. Being fresh, that replaces every entry overlapping [lo, addr]: the
251
+ // same stack's narrower window, or one describing memory since mapped afresh.
252
+ // Every query checks that its mapping holds `addr`, so `lo <= addr`.
253
+ fn record(&mut self, lo: usize, addr: usize) {
254
+ debug_assert!(lo <= addr);
255
+ let from = self.0.partition_point(|s| s.seen < lo);
256
+ let to = self.0.partition_point(|s| s.lo <= addr);
257
+ self.0.splice(from..to, [FiberStack { lo, seen: addr }]);
258
+ }
228
259
  }
229
260
 
230
261
  thread_local! {
231
- static FIBER_REGIONS: std::cell::RefCell<FiberRegions> = const {
232
- std::cell::RefCell::new(FiberRegions {
233
- slots: [FiberRegion { lo: 0, seen: 0 }; FIBER_REGION_SLOTS],
234
- rng: 0x9E37_79B9_7F4A_7C15,
235
- })
236
- };
262
+ static FIBER_STACKS: std::cell::RefCell<FiberStacks> =
263
+ const { std::cell::RefCell::new(FiberStacks(Vec::new())) };
237
264
  }
238
265
 
239
266
  // (bottom, highest-address-seen) of the stack `addr` is on. `addr` must be an
240
267
  // address the CALLER knows is on that stack and as high up it as it can offer —
241
268
  // it both answers the lookup and becomes the entry's `seen` on a miss, so a
242
- // caller that passes its topmost frame gets the widest sound window. A RefCell
243
- // rather than a Cell so a hit reads the slots in place instead of copying the
244
- // whole table out on every fiber op.
269
+ // caller that passes its topmost frame gets the widest sound window. An op run
270
+ // while the thread's locals are being torn down gets the fail-safe (0, 0).
245
271
  fn current_region_bounds_cached(addr: usize) -> (usize, usize) {
246
- FIBER_REGIONS.with(|c| {
247
- if let Some(r) = c
248
- .borrow()
249
- .slots
250
- .iter()
251
- .find(|r| r.lo != 0 && addr >= r.lo && addr <= r.seen)
252
- {
253
- return (r.lo, r.seen);
254
- }
255
- let (lo, _) = query_region_bounds(addr);
256
- if lo == 0 {
257
- return (0, 0);
258
- }
259
- let mut cache = c.borrow_mut();
260
- // Prefer the entry for a stack we already know (a frame above anything
261
- // seen on it so far, which just widens the window), then an empty slot,
262
- // and only then evict.
263
- let slot = cache
264
- .slots
265
- .iter()
266
- .position(|r| r.lo == lo)
267
- .or_else(|| cache.slots.iter().position(|r| r.lo == 0))
268
- .unwrap_or_else(|| {
269
- cache.rng ^= cache.rng << 13;
270
- cache.rng ^= cache.rng >> 7;
271
- cache.rng ^= cache.rng << 17;
272
- (cache.rng % FIBER_REGION_SLOTS as u64) as usize
273
- });
274
- cache.slots[slot] = FiberRegion { lo, seen: addr };
275
- (lo, addr)
276
- })
272
+ FIBER_STACKS
273
+ .try_with(|stacks| stacks.borrow_mut().bounds(addr, query_region_bottom))
274
+ .unwrap_or((0, 0))
277
275
  }
278
276
 
279
- // The [start, end) of the mapping containing `addr`, or (0, 0) when it can't be
280
- // established. Only called on a cache miss (a new fiber), because it is slow.
277
+ // The bottom of the mapping containing `addr`, or 0 when it can't be
278
+ // established. Only called on a cache miss: a stack no op has run on yet, or a
279
+ // frame above any seen on it.
281
280
  //
282
- // (0, 0) is the fail-safe answer, not an error: every caller falls back to a
283
- // fixed budget below the SP, which is what this gem did on every platform before
284
- // any of these lookups existed. So a platform without an implementation, or a
285
- // lookup that returns something we can't vouch for, degrades to a smaller JS
286
- // stack rather than to a wrong one.
281
+ // 0 is the fail-safe answer, not an error: every caller falls back to a fixed
282
+ // budget below the SP, which is what this gem did on every platform before any
283
+ // of these lookups existed. So a platform without an implementation, or a lookup
284
+ // that returns something we can't vouch for, degrades to a smaller JS stack
285
+ // rather than to a wrong one.
287
286
  //
288
- // Linux reads /proc/self/maps fresh each time.
287
+ // Linux asks the kernel for the one mapping (PROCMAP_QUERY, 6.11+: ~0.65us for
288
+ // the open, the ioctl and the close, measured beside 3000 mappings) and reads
289
+ // all of /proc/self/maps only where the ioctl isn't there — ~25-200us, growing
290
+ // with the map. The file is opened per call rather than kept: an open file
291
+ // would go on describing the PARENT's address space in a forked child.
292
+ #[cfg(target_os = "linux")]
293
+ fn query_region_bottom(addr: usize) -> usize {
294
+ let Ok(mut maps) = std::fs::File::open("/proc/self/maps") else {
295
+ return 0;
296
+ };
297
+ procmap_query_bottom(&maps, addr).unwrap_or_else(|| read_maps_bottom(&mut maps, addr))
298
+ }
299
+
300
+ // The bottom of the mapping containing `addr`, through the PROCMAP_QUERY ioctl
301
+ // on an open /proc/self/maps; None when the ioctl fails, which on a live stack
302
+ // address means a kernel without it (ENOTTY), or answers with a mapping that
303
+ // does not hold `addr`, which the kernel's never does but an emulated /proc
304
+ // might.
289
305
  #[cfg(target_os = "linux")]
290
- fn query_region_bounds(addr: usize) -> (usize, usize) {
306
+ fn procmap_query_bottom(maps: &std::fs::File, addr: usize) -> Option<usize> {
307
+ use std::os::fd::AsRawFd;
308
+ // struct procmap_query, from linux/fs.h — not in libc. Only `vma_start` is
309
+ // read back, but the ioctl number encodes the whole struct's size.
310
+ #[repr(C)]
311
+ #[derive(Default)]
312
+ struct ProcmapQuery {
313
+ size: u64,
314
+ query_flags: u64,
315
+ query_addr: u64,
316
+ vma_start: u64,
317
+ vma_end: u64,
318
+ vma_flags: u64,
319
+ vma_page_size: u64,
320
+ vma_offset: u64,
321
+ inode: u64,
322
+ dev_major: u32,
323
+ dev_minor: u32,
324
+ vma_name_size: u32,
325
+ build_id_size: u32,
326
+ vma_name_addr: u64,
327
+ build_id_addr: u64,
328
+ }
329
+ // Flags 0 ask for the mapping COVERING query_addr, not the next one above it.
330
+ const PROCMAP_QUERY: libc::Ioctl = libc::_IOWR::<ProcmapQuery>(b'f' as u32, 17);
331
+ let mut query = ProcmapQuery {
332
+ size: std::mem::size_of::<ProcmapQuery>() as u64,
333
+ query_addr: addr as u64,
334
+ ..Default::default()
335
+ };
336
+ let rc = unsafe {
337
+ libc::ioctl(
338
+ maps.as_raw_fd(),
339
+ PROCMAP_QUERY,
340
+ &mut query as *mut ProcmapQuery,
341
+ )
342
+ };
343
+ let (lo, hi) = (query.vma_start as usize, query.vma_end as usize);
344
+ (rc == 0 && lo <= addr && addr < hi).then_some(lo)
345
+ }
346
+
347
+ // The bottom of the mapping containing `addr`, from the text of
348
+ // /proc/self/maps, or 0 if no line covers it.
349
+ #[cfg(target_os = "linux")]
350
+ fn read_maps_bottom(maps: &mut std::fs::File, addr: usize) -> usize {
291
351
  use std::io::Read;
292
352
  let mut buf = String::new();
293
- if std::fs::File::open("/proc/self/maps")
294
- .and_then(|mut f| f.read_to_string(&mut buf))
295
- .is_err()
296
- {
297
- return (0, 0);
353
+ if maps.read_to_string(&mut buf).is_err() {
354
+ return 0;
298
355
  }
299
356
  for line in buf.lines() {
300
357
  // e.g. "7f6a...000-7f6a...000 rw-p 00000000 00:00 0 ..."
@@ -309,10 +366,10 @@ fn query_region_bounds(addr: usize) -> (usize, usize) {
309
366
  continue;
310
367
  };
311
368
  if addr >= lo && addr < hi {
312
- return (lo, hi);
369
+ return lo;
313
370
  }
314
371
  }
315
- (0, 0)
372
+ 0
316
373
  }
317
374
 
318
375
  // macOS has no /proc, so ask the Mach VM directly. mach_vm_region reports the
@@ -322,7 +379,7 @@ fn query_region_bounds(addr: usize) -> (usize, usize) {
322
379
  // that isn't tells us we either walked into something that is not a stack or
323
380
  // misread the reply, in which case the fixed-budget fallback is the safer answer.
324
381
  #[cfg(target_os = "macos")]
325
- fn query_region_bounds(addr: usize) -> (usize, usize) {
382
+ fn query_region_bottom(addr: usize) -> usize {
326
383
  // Not in libc, which points at the `mach2` crate instead; these are the only
327
384
  // entry points we need, so declare them here rather than take the dependency.
328
385
  // (libc does carry mach_task_self_, but deprecated for the same reason.)
@@ -370,20 +427,20 @@ fn query_region_bounds(addr: usize) -> (usize, usize) {
370
427
  mach_port_deallocate(mach_task_self_, object_name);
371
428
  }
372
429
  if kr != KERN_SUCCESS || size == 0 || info[0] & VM_PROT_READ_WRITE != VM_PROT_READ_WRITE {
373
- return (0, 0);
430
+ return 0;
374
431
  }
375
432
  let (lo, hi) = (start as usize, start.saturating_add(size) as usize);
376
433
  if addr >= lo && addr < hi {
377
- (lo, hi)
434
+ lo
378
435
  } else {
379
- (0, 0) // `addr` was in a hole; this is the region after it
436
+ 0 // `addr` was in a hole; this is the region after it
380
437
  }
381
438
  }
382
439
  }
383
440
 
384
441
  #[cfg(not(any(target_os = "linux", target_os = "macos")))]
385
- fn query_region_bounds(_addr: usize) -> (usize, usize) {
386
- (0, 0)
442
+ fn query_region_bottom(_addr: usize) -> usize {
443
+ 0
387
444
  }
388
445
 
389
446
  // Set from RUSTY_RACER_STACK_DEBUG at init; gates the per-op stack diagnostics.
@@ -517,7 +574,7 @@ impl<'a> StackScope<'a> {
517
574
  } else {
518
575
  // Region unknown. Both shipped platforms implement the lookup,
519
576
  // so reaching this on one of them means the lookup FAILED (see
520
- // query_region_bounds for what it refuses to vouch for) — worth
577
+ // query_region_bottom for what it refuses to vouch for) — worth
521
578
  // chasing rather than assuming, since the budget here is only a
522
579
  // guess: a fixed reserve below the SP, hoping the fiber is that
523
580
  // deep. It usually is for an OUTERMOST fiber op, whose SP sits
@@ -596,12 +653,15 @@ impl<'a> StackScope<'a> {
596
653
  // stack (the cache's `seen`, NOT the mapping top, which can reach far
597
654
  // into an unrelated neighbour — see current_region_bounds_cached);
598
655
  // otherwise we cannot tell it is on our stack at all, and adopting it
599
- // would send the scan up through our guard page. Since that window
600
- // lives in a cache with random eviction, the test can also fail
601
- // because the entry was evicted between the two ops — the scan then
602
- // narrows to this op's own frame, i.e. the same residual documented
603
- // there for a real stack switch, now reachable at random once more
604
- // fiber stacks are live than the cache holds.
656
+ // would send the scan up through our guard page. The enclosing op
657
+ // ran on this stack at or below `seen` — it widened the entry to its
658
+ // own `stack_top` if nothing had — and an entry is only ever replaced
659
+ // by a fresh lookup overlapping it, which for a stack still mapped is
660
+ // the same stack with a higher `seen`. So the test holds for every
661
+ // enclosing op on our stack whose lookup went through this thread's
662
+ // table; one that ran on another native thread (an M:N Ruby thread
663
+ // that resumed elsewhere inside a host callback) is unknown here, and
664
+ // the scan narrows to this op's own frame — the residual below.
605
665
  let new_scan_start = if on_native {
606
666
  unsafe { v8__base__Stack__GetStackStart() }
607
667
  } else if nested && region.0 != 0 && prev_scan_start > sp && prev_scan_start <= region.1 {
@@ -655,3 +715,219 @@ impl Drop for StackScope<'_> {
655
715
  }
656
716
  }
657
717
  }
718
+
719
+ #[cfg(test)]
720
+ mod tests {
721
+ use super::*;
722
+
723
+ fn stacks(entries: &[(usize, usize)]) -> FiberStacks {
724
+ FiberStacks(
725
+ entries
726
+ .iter()
727
+ .map(|&(lo, seen)| FiberStack { lo, seen })
728
+ .collect(),
729
+ )
730
+ }
731
+
732
+ #[test]
733
+ fn a_lookup_hits_only_inside_a_window() {
734
+ let table = stacks(&[(0x1000, 0x1800), (0x3000, 0x3400)]);
735
+ assert_eq!(table.find(0x0fff), None);
736
+ assert_eq!(
737
+ table.find(0x1000),
738
+ Some(FiberStack {
739
+ lo: 0x1000,
740
+ seen: 0x1800
741
+ })
742
+ );
743
+ assert_eq!(table.find(0x1800).map(|s| s.lo), Some(0x1000));
744
+ // Above `seen` is not known to be on that stack, though no other entry
745
+ // claims it.
746
+ assert_eq!(table.find(0x1801), None);
747
+ assert_eq!(table.find(0x3200).map(|s| s.lo), Some(0x3000));
748
+ assert_eq!(table.find(0x3401), None);
749
+ }
750
+
751
+ #[test]
752
+ fn a_stack_is_looked_up_once_and_again_only_above_its_window() {
753
+ let mut table = stacks(&[]);
754
+ let queries = std::cell::Cell::new(0);
755
+ let mut bounds = |addr| {
756
+ table.bounds(addr, |_| {
757
+ queries.set(queries.get() + 1);
758
+ 0x1000
759
+ })
760
+ };
761
+ assert_eq!(bounds(0x1800), (0x1000, 0x1800));
762
+ assert_eq!(bounds(0x1400), (0x1000, 0x1800));
763
+ assert_eq!(bounds(0x1800), (0x1000, 0x1800));
764
+ assert_eq!(queries.get(), 1);
765
+ assert_eq!(bounds(0x1c00), (0x1000, 0x1c00));
766
+ assert_eq!(bounds(0x1900), (0x1000, 0x1c00));
767
+ assert_eq!(queries.get(), 2);
768
+ }
769
+
770
+ #[test]
771
+ fn an_unknown_bottom_is_not_recorded() {
772
+ let mut table = stacks(&[]);
773
+ assert_eq!(table.bounds(0x1800, |_| 0), (0, 0));
774
+ assert_eq!(table.0, Vec::new());
775
+ }
776
+
777
+ #[test]
778
+ fn a_frame_above_the_window_widens_the_same_entry() {
779
+ let mut table = stacks(&[(0x1000, 0x1800), (0x3000, 0x3400)]);
780
+ table.record(0x1000, 0x1c00);
781
+ assert_eq!(table.0, stacks(&[(0x1000, 0x1c00), (0x3000, 0x3400)]).0);
782
+ }
783
+
784
+ #[test]
785
+ fn a_new_stack_is_inserted_in_order() {
786
+ let mut table = stacks(&[(0x1000, 0x1800), (0x5000, 0x5400)]);
787
+ table.record(0x3000, 0x3100);
788
+ table.record(0x0100, 0x0200);
789
+ table.record(0x9000, 0x9100);
790
+ assert_eq!(
791
+ table.0,
792
+ stacks(&[
793
+ (0x0100, 0x0200),
794
+ (0x1000, 0x1800),
795
+ (0x3000, 0x3100),
796
+ (0x5000, 0x5400),
797
+ (0x9000, 0x9100)
798
+ ])
799
+ .0
800
+ );
801
+ }
802
+
803
+ #[test]
804
+ fn a_fresh_bottom_replaces_every_entry_it_overlaps() {
805
+ // Memory once holding two stacks, now one mapping from 0x1400: both old
806
+ // windows reach into [0x1400, 0x3200], so neither may answer again.
807
+ let mut table = stacks(&[
808
+ (0x0100, 0x0200),
809
+ (0x1000, 0x1800),
810
+ (0x3000, 0x3400),
811
+ (0x5000, 0x5400),
812
+ ]);
813
+ table.record(0x1400, 0x3200);
814
+ assert_eq!(
815
+ table.0,
816
+ stacks(&[(0x0100, 0x0200), (0x1400, 0x3200), (0x5000, 0x5400)]).0
817
+ );
818
+ assert_eq!(table.find(0x1200), None);
819
+ }
820
+
821
+ // A mapping laid out as Ruby's fiber pool lays a stack out: read-write
822
+ // pages with PROT_NONE guard pages among them. Unmapped on drop.
823
+ #[cfg(target_os = "linux")]
824
+ struct GuardedStack {
825
+ base: usize,
826
+ len: usize,
827
+ }
828
+
829
+ #[cfg(target_os = "linux")]
830
+ impl GuardedStack {
831
+ fn page() -> usize {
832
+ unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }
833
+ }
834
+
835
+ // `pages` pages, of which those at the `guards` indexes are PROT_NONE.
836
+ fn map(pages: usize, guards: &[usize]) -> Self {
837
+ let len = pages * Self::page();
838
+ let stack = Self {
839
+ base: Self::mmap(0, len, 0),
840
+ len,
841
+ };
842
+ stack.protect(guards);
843
+ stack
844
+ }
845
+
846
+ // Map the same addresses afresh (MAP_FIXED replaces the old mapping in
847
+ // one step, so no other thread's mmap can land in between), with other
848
+ // guards.
849
+ fn remap(&self, guards: &[usize]) {
850
+ assert_eq!(Self::mmap(self.base, self.len, libc::MAP_FIXED), self.base);
851
+ self.protect(guards);
852
+ }
853
+
854
+ fn mmap(at: usize, len: usize, flags: libc::c_int) -> usize {
855
+ let base = unsafe {
856
+ libc::mmap(
857
+ at as *mut c_void,
858
+ len,
859
+ libc::PROT_READ | libc::PROT_WRITE,
860
+ libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | flags,
861
+ -1,
862
+ 0,
863
+ )
864
+ };
865
+ assert_ne!(base, libc::MAP_FAILED);
866
+ base as usize
867
+ }
868
+
869
+ fn protect(&self, guards: &[usize]) {
870
+ for &guard in guards {
871
+ let page = self.page_at(guard) as *mut c_void;
872
+ assert_eq!(
873
+ unsafe { libc::mprotect(page, Self::page(), libc::PROT_NONE) },
874
+ 0
875
+ );
876
+ }
877
+ }
878
+
879
+ fn page_at(&self, index: usize) -> usize {
880
+ self.base + index * Self::page()
881
+ }
882
+ }
883
+
884
+ #[cfg(target_os = "linux")]
885
+ impl Drop for GuardedStack {
886
+ fn drop(&mut self) {
887
+ unsafe { libc::munmap(self.base as *mut c_void, self.len) };
888
+ }
889
+ }
890
+
891
+ // Both ways of asking agree with each other and with the layout.
892
+ #[cfg(target_os = "linux")]
893
+ fn assert_bottom(addr: usize, expected: usize) {
894
+ assert_eq!(query_region_bottom(addr), expected);
895
+ let mut maps = std::fs::File::open("/proc/self/maps").unwrap();
896
+ assert_eq!(read_maps_bottom(&mut maps, addr), expected);
897
+ // Older kernels have no PROCMAP_QUERY; where there is one it must agree.
898
+ if let Some(bottom) = procmap_query_bottom(&maps, addr) {
899
+ assert_eq!(bottom, expected);
900
+ }
901
+ }
902
+
903
+ #[cfg(target_os = "linux")]
904
+ #[test]
905
+ fn the_bottom_is_the_page_above_the_guard() {
906
+ let stack = GuardedStack::map(8, &[0]);
907
+ assert_bottom(stack.page_at(5) + 17, stack.page_at(1));
908
+ assert_bottom(stack.page_at(1), stack.page_at(1));
909
+ }
910
+
911
+ #[cfg(target_os = "linux")]
912
+ #[test]
913
+ fn a_stack_mapped_again_with_other_guards_has_its_new_bottom() {
914
+ // Unmap a fiber's stack and map the same addresses again with a guard
915
+ // inside the old one: a lookup is a fresh question, so it answers with
916
+ // the new bottom, never the one the old mapping had.
917
+ let stack = GuardedStack::map(8, &[0]);
918
+ assert_bottom(stack.page_at(6), stack.page_at(1));
919
+ stack.remap(&[0, 4]);
920
+ assert_bottom(stack.page_at(6), stack.page_at(5));
921
+ }
922
+
923
+ #[cfg(target_os = "linux")]
924
+ #[test]
925
+ fn a_live_stack_is_found_where_it_runs() {
926
+ // This thread's own stack: the frame is somewhere above its bottom.
927
+ let marker = 0u8;
928
+ let addr = &marker as *const u8 as usize;
929
+ let bottom = query_region_bottom(addr);
930
+ assert_ne!(bottom, 0);
931
+ assert!(bottom <= addr);
932
+ }
933
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.6"
4
+ VERSION = "0.2.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima