rusty_racer 0.2.6 → 0.2.8

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: 6eedc8d154897325a2990e5ed6999a368b7c81c64528d5d406ed469e5d808fc4
4
+ data.tar.gz: f0c356c5b22a4574323e7efe73c56bd07267d5428438d22dadf2c4a08953b468
5
5
  SHA512:
6
- metadata.gz: e0a3df4af6404d9357a008e2adcb4d8303a39db35ad9d3d4ef0240217222bd0fd0be40c9b18b36b9778e30e9eac8a5ce9b20514ecc962975cc25b1466d504aac
7
- data.tar.gz: 497c28687de4bc14c7b666e99a3205406e84384459b923dca24b8d2080db552d3266bbffbfbecfb0a4e61bde9c17aa4413f39e321d1c866339df6944782c3930
6
+ metadata.gz: 02acd6e295f14544a9f146f0b916ac1ac585b63634bb12c353d39059dc7ffef50337e76cd81c318463060c2e8f901a19667f384cbd1967ed776290dfd1d7c0cb
7
+ data.tar.gz: bc2513ed51e0a7ed2c45bc4c8cc85a051a7d83f9d1cdc56675cc2e1a3fcfd915c7b77bede14f91ac10e475d877e43563cea38c734d5cab36a2be6b806200312c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.8
4
+
5
+ ### Added
6
+
7
+ - A **host object** per realm: an object with no prototype that no global
8
+ holds (its global keeps it under a V8 private key), for the host and the
9
+ realm's own bindings to share where a page's script can neither read, call nor
10
+ replace what they put there. Every path the host names starts there when it
11
+ begins with `@`: `Context#attach('@fetch', proc)` / `#attach_many` put a host
12
+ function there (intermediates with no prototype, every property defined, not
13
+ assigned), `#function('@steps.query')` / `#call` / `#call_void` find one
14
+ there, `Module#evaluate(report: '@reportError')` reports to one there, and
15
+ `Isolate.new(host_namespace: '@RustyRacer')` installs rusty_racer's own
16
+ functions (`contextGlobal`, `contextOf`, `drainMicrotasks`, …) there instead
17
+ of on the global. `RustyRacer::HOST_OBJECT`, as an argument (or a host
18
+ function's result) crossing into JS, is the realm's host object — how the
19
+ bindings are handed it, and the realm's host object crosses back into Ruby as
20
+ `HOST_OBJECT`; an embedder linking rusty_racer as a library reaches it
21
+ natively with `rusty_racer::host_object(scope)`, from its realm init hook.
22
+ An `@` path is walked through own properties alone, so nothing a page put on
23
+ a prototype sees or answers the walk. A namespace on the host object has
24
+ `hostObjectOf(id)` besides: another realm's host object, for one realm's
25
+ bindings to reach another's. Made as a realm first uses it, made anew with the
26
+ realm by `reset`.
27
+
28
+ ### Changed
29
+
30
+ - A path beginning with `@` no longer names a property of the global whose name
31
+ begins with `@` (`globalThis["@x"]`): it names the host object's.
32
+ - `Module#evaluate(report:)` looks the reporter up as before — one property,
33
+ dots and all — on the global, or, for an `@` path, as `Context#function` finds
34
+ it on the host object.
35
+
36
+ ## 0.2.7
37
+
38
+ ### Changed
39
+
40
+ - An operation on a Ruby Fiber looks its stack's bounds up once per stack
41
+ rather than once per operation that a 16-entry cache had dropped. Capybara
42
+ filters each result set in an Enumerator left suspended until GC frees it, so
43
+ a page of finds runs on hundreds of distinct fiber stacks, and each lookup read
44
+ all of `/proc/self/maps` — a fifth of a find loop, and slower the larger
45
+ Ruby's fiber pool grew. Every stack an operation has run on is now kept (Ruby
46
+ hands its pooled stacks out again and never unmaps them), and on Linux 6.11+ a
47
+ lookup asks the kernel for the one mapping (`PROCMAP_QUERY`) instead of
48
+ reading the whole map. A call into V8 from a fresh fiber, steady state: 160 µs
49
+ → 1.0 µs beside a 2,200-line map, 521 µs → 1.0 µs beside 8,300 lines.
50
+
3
51
  ## 0.2.6
4
52
 
5
53
  ### Added
data/README.md CHANGED
@@ -123,6 +123,62 @@ realm (`frames[0].fn` in an embedder whose realms see each other) holds that
123
123
  realm's function for as long as the handle's own realm lives, as any reference
124
124
  kept there would.
125
125
 
126
+ ### The host object
127
+
128
+ An embedder that runs a page's script beside its own bindings wants the host's
129
+ functions where the bindings can call them and the page can't. Every realm has a
130
+ **host object** for that: an object of the realm with no prototype, which no
131
+ global holds — the global keeps it under a V8 private key, which no script can
132
+ name. A path the host names that begins with `@` starts there instead of at
133
+ `globalThis`:
134
+
135
+ ```ruby
136
+ # rusty_racer's own functions (contextGlobal, contextOf, drainMicrotasks, …)
137
+ # there too, rather than on a global
138
+ iso = RustyRacer::Isolate.new(host_namespace: "@RustyRacer")
139
+ ctx = iso.context
140
+
141
+ # Host functions the page never sees: no global, nothing to read, call or replace.
142
+ ctx.attach("@fetch", ->(url) { fetch(url) })
143
+ ctx.attach_many("@net.open" => open_proc, "@net.close" => close_proc)
144
+
145
+ # The bindings are handed the object as an argument — HOST_OBJECT crosses into
146
+ # JS as the realm's host object — and keep it in a closure or a module binding.
147
+ ctx.call("installBindings", RustyRacer::HOST_OBJECT)
148
+
149
+ # What they put there, the host calls by its path, with no global name.
150
+ query = ctx.function("@steps.query") # host.steps.query, `this` = host.steps
151
+ query.call("#main")
152
+ ctx.call("@steps.flush")
153
+ mod.evaluate(report: "@reportError") # Module#evaluate's reporter, too
154
+ ```
155
+
156
+ An `@` path is walked through **own** properties alone: the intermediates an
157
+ `@` attach makes (`@net` above) have no prototype either, every property it
158
+ makes is *defined*, and a segment an object lacks reads as missing rather than
159
+ off its prototype — so no accessor a page put on `Object.prototype` or
160
+ `Function.prototype` sees the walk, takes the host's function, or hands the host
161
+ one of its own. A `Module#evaluate(report:)` `@` path is walked the same way.
162
+
163
+ Which realm's: each realm has its own, made as it is first used, and made anew
164
+ with the realm by `reset` (what was attached there goes as a global's does).
165
+ `HOST_OBJECT` is the host object of the realm the operation runs in — a
166
+ Context's, a `Function` handle's (whatever realm the function it calls is of),
167
+ or, as a host function's result, the realm that host function was attached to —
168
+ and that realm's host object crosses back into Ruby as `HOST_OBJECT`; another
169
+ realm's reads as a plain object. With `host_namespace: "@…"` the namespace has
170
+ one member more, `hostObjectOf(id)`: realm `id`'s host object, for one realm's
171
+ bindings to reach another's (a namespace on the global never has it).
172
+
173
+ Hand it over before any script of the page's runs: `call` reaches the bindings
174
+ by a global name, which a page could otherwise have replaced — and have the
175
+ bindings delete that name as they take it. An embedder linking rusty_racer as a
176
+ library reaches it natively with `rusty_racer::host_object(scope)` instead —
177
+ from its realm init hook, say, to hand it to bindings baked into a snapshot
178
+ before any script runs. The object has no prototype, so V8 keeps it as a
179
+ dictionary: bindings that call through it in a hot loop do better to keep the
180
+ functions they read off it.
181
+
126
182
  ### Releasing handles
127
183
 
128
184
  A `Function`, `Script` or `Module` handle is released when it is `#dispose`d —
@@ -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.8"
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.
@@ -1027,6 +1027,9 @@ struct V8State {
1027
1027
  retiring: Vec<(v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>)>,
1028
1028
  next_context_id: i32,
1029
1029
  host_namespace: Option<String>,
1030
+ // The private key every realm's global holds its host object under
1031
+ // (host_object_key), once something has asked for one.
1032
+ host_key: Option<v8::Global<v8::Private>>,
1030
1033
  // One security token shared by every realm of this isolate: the
1031
1034
  // embedder's frames are same-origin, so cross-context access (e.g.
1032
1035
  // NS.contextGlobal) must pass V8's access checks, which compare the
@@ -1998,6 +2001,30 @@ fn context_global(
1998
2001
  }
1999
2002
  }
2000
2003
 
2004
+ // NS.hostObjectOf(id) -> the host object of context |id| (host_object), made
2005
+ // there if it has none yet — for one realm's bindings to reach another's, as
2006
+ // contextGlobal reaches its global. Only on a namespace that is itself on the
2007
+ // host object (`host_namespace: '@…'`): a namespace on the global would hand
2008
+ // every realm's host object to any script. Throws on an unknown/disposed id.
2009
+ fn host_object_of(
2010
+ scope: &mut v8::PinScope<'_, '_>,
2011
+ args: v8::FunctionCallbackArguments<'_>,
2012
+ mut rv: v8::ReturnValue<'_, v8::Value>,
2013
+ ) {
2014
+ let Some(id) = args.get(0).int32_value(scope) else {
2015
+ throw_js_error(scope, "hostObjectOf expects a context id");
2016
+ return;
2017
+ };
2018
+ match context_for(istate!(scope), id) {
2019
+ Some(g) => {
2020
+ let context = v8::Local::new(scope, &g);
2021
+ let scope = &mut v8::ContextScope::new(scope, context);
2022
+ rv.set(host_object(scope).into());
2023
+ }
2024
+ None => throw_js_error(scope, &format!("unknown context {id}")),
2025
+ }
2026
+ }
2027
+
2001
2028
  // NS.contextOf(value) -> the id of the context |value| was created in, or
2002
2029
  // undefined for primitives and for objects whose context is no longer a live
2003
2030
  // realm (e.g. it was reset away). Lets the embedder attribute a function or
@@ -2269,6 +2296,97 @@ pub fn set_realm_init_hook(hook: RealmInitHook) {
2269
2296
  let _ = REALM_INIT_HOOK.set(hook);
2270
2297
  }
2271
2298
 
2299
+ // The HOST OBJECT of the realm |scope| has entered: an object of the realm's,
2300
+ // with no prototype, that no property of any global holds — the realm's global
2301
+ // keeps it under a private key, which no script can name — so the host and the
2302
+ // realm's own bindings share it and a page's script neither reads, calls nor
2303
+ // replaces what it holds. A path that begins `@` starts from it rather than from
2304
+ // the global: `attach('@x.y', proc)` puts a host function there,
2305
+ // `Context#function('@x.y')` / `#call('@x.y')` find one there, `host_namespace:
2306
+ // '@NS'` installs the namespace there, `evaluate(report: '@r')` reports to one
2307
+ // there; and `RustyRacer::HOST_OBJECT`, as an argument crossing into JS, is this
2308
+ // object — how the bindings are handed it, where an embedder's realm init hook
2309
+ // (which calls this) does not hand it over itself. Made as it is first asked
2310
+ // for, so a realm that never uses it carries none.
2311
+ //
2312
+ // The private key lives with the global, so the object goes with its realm: a
2313
+ // reset or a disposal needs nothing of it, and no context slot is used (see
2314
+ // id_of_context for why realms keep none).
2315
+ pub fn host_object<'s>(scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Object> {
2316
+ if let Some(object) = existing_host_object(scope) {
2317
+ return object;
2318
+ }
2319
+ let object = null_prototype_object(scope);
2320
+ let global = scope.get_current_context().global(scope);
2321
+ let key = host_object_key(scope);
2322
+ global.set_private(scope, key, object.into());
2323
+ object
2324
+ }
2325
+
2326
+ // The current realm's host object if it has made one yet, without making it.
2327
+ pub(crate) fn existing_host_object<'s>(
2328
+ scope: &mut v8::PinScope<'s, '_>,
2329
+ ) -> Option<v8::Local<'s, v8::Object>> {
2330
+ let global = scope.get_current_context().global(scope);
2331
+ let key = host_object_key(scope);
2332
+ v8::Local::<v8::Object>::try_from(global.get_private(scope, key)?).ok()
2333
+ }
2334
+
2335
+ // The private key a global holds its host object under: an API private, so it
2336
+ // is one key per isolate whoever asks — the binding's own isolates keep it
2337
+ // (V8State::host_key) rather than looking it up by name at every use.
2338
+ fn host_object_key<'s>(scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Private> {
2339
+ let kept = scope
2340
+ .get_slot::<IsolateState>()
2341
+ .and_then(|state| state.realms.host_key.clone());
2342
+ if let Some(key) = kept {
2343
+ return v8::Local::new(scope, &key);
2344
+ }
2345
+ let name = v8::String::new(scope, "rusty_racer:host_object").expect("a short string");
2346
+ let key = v8::Private::for_api(scope, Some(name));
2347
+ let kept = v8::Global::new(scope, key);
2348
+ if let Some(state) = scope.get_slot_mut::<IsolateState>() {
2349
+ state.realms.host_key = Some(kept);
2350
+ }
2351
+ key
2352
+ }
2353
+
2354
+ // An object of the current realm with no prototype: a read of a name it lacks
2355
+ // ends there, never in an Object.prototype a page may have put a getter on.
2356
+ fn null_prototype_object<'s>(scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Object> {
2357
+ let null = v8::null(scope).into();
2358
+ v8::Object::with_prototype_and_properties(scope, null, &[], &[])
2359
+ }
2360
+
2361
+ // Where a path starts, and the rest of it: `@a.b` is the host object's `a.b`, any
2362
+ // other path the global's.
2363
+ pub(crate) fn path_root<'s, 'p>(
2364
+ scope: &mut v8::PinScope<'s, '_>,
2365
+ path: &'p str,
2366
+ ) -> (v8::Local<'s, v8::Object>, &'p str, bool) {
2367
+ match path.strip_prefix('@') {
2368
+ Some(rest) => (host_object(scope), rest, true),
2369
+ None => (scope.get_current_context().global(scope), path, false),
2370
+ }
2371
+ }
2372
+
2373
+ // One segment of a path read off |holder|: an ordinary [[Get]] on the global's
2374
+ // side; on the host object's (|hidden|), its OWN property alone — undefined
2375
+ // where it has none, so no prototype of an object along the way (Object's, a
2376
+ // function's) is ever consulted, and a getter a page put there neither sees
2377
+ // the walk nor hands it an object of the page's. None when the read threw.
2378
+ pub(crate) fn path_segment<'s>(
2379
+ scope: &mut v8::PinScope<'s, '_>,
2380
+ holder: v8::Local<v8::Object>,
2381
+ key: v8::Local<v8::String>,
2382
+ hidden: bool,
2383
+ ) -> Option<v8::Local<'s, v8::Value>> {
2384
+ if hidden && !holder.has_own_property(scope, key.into())? {
2385
+ return Some(v8::undefined(scope).into());
2386
+ }
2387
+ holder.get(scope, key.into())
2388
+ }
2389
+
2272
2390
  fn new_realm(
2273
2391
  scope: &mut v8::PinScope<'_, '_, ()>,
2274
2392
  context_id: i32,
@@ -2373,9 +2491,12 @@ fn flush_retiring(scope: &mut v8::PinScope<'_, '_, ()>) {
2373
2491
  drop(retiring);
2374
2492
  }
2375
2493
 
2376
- // Inject globalThis.<name> = { drainMicrotasks } into a context. Re-run on
2377
- // reset_realm so the fresh realm keeps the namespace. Takes a scope (not the
2378
- // isolate) so service_request can call it from nested servicing too.
2494
+ // Inject globalThis.<name> = { drainMicrotasks, ... } into a context — or, for
2495
+ // an `@name`, put it on the realm's host object instead, with no prototype, where
2496
+ // only the host and the realm's bindings reach it, and with one member more
2497
+ // there: hostObjectOf(id), another realm's host object. Re-run on reset_realm so the
2498
+ // fresh realm keeps the namespace. Takes a scope (not the isolate) so
2499
+ // service_request can call it from nested servicing too.
2379
2500
  fn install_host_namespace(
2380
2501
  scope: &mut v8::PinScope<'_, '_, ()>,
2381
2502
  ctx: &v8::Global<v8::Context>,
@@ -2384,7 +2505,12 @@ fn install_host_namespace(
2384
2505
  v8::scope!(let scope, &mut *scope);
2385
2506
  let context = v8::Local::new(scope, ctx);
2386
2507
  let scope = &mut v8::ContextScope::new(scope, context);
2387
- let ns = v8::Object::new(scope);
2508
+ let (holder, name, hidden) = path_root(scope, name);
2509
+ let ns = if hidden {
2510
+ null_prototype_object(scope)
2511
+ } else {
2512
+ v8::Object::new(scope)
2513
+ };
2388
2514
  let members: [(&str, Option<v8::Local<v8::Function>>); 9] = [
2389
2515
  (
2390
2516
  "drainMicrotasks",
@@ -2402,40 +2528,58 @@ fn install_host_namespace(
2402
2528
  ("shareIn", v8::Function::new(scope, share_in)),
2403
2529
  ("transferDrop", v8::Function::new(scope, transfer_drop)),
2404
2530
  ];
2405
- for (member, function) in members {
2531
+ let hidden_members: &[(&str, Option<v8::Local<v8::Function>>)] = if hidden {
2532
+ &[("hostObjectOf", v8::Function::new(scope, host_object_of))]
2533
+ } else {
2534
+ &[]
2535
+ };
2536
+ for (member, function) in members.into_iter().chain(hidden_members.iter().copied()) {
2406
2537
  if let (Some(f), Some(k)) = (function, v8::String::new(scope, member)) {
2407
2538
  ns.create_data_property(scope, k.into(), f.into());
2408
2539
  }
2409
2540
  }
2410
2541
  if let Some(key) = v8::String::new(scope, name) {
2411
- let global = context.global(scope);
2412
- global.create_data_property(scope, key.into(), ns.into());
2542
+ holder.create_data_property(scope, key.into(), ns.into());
2413
2543
  }
2414
2544
  }
2415
2545
 
2416
2546
  // Set |function| at the dotted property path |name| on the global, creating
2417
2547
  // intermediate plain objects as needed — so attach("MiniRacer.foo", ...) lands
2418
2548
  // under the namespace whether or not globalThis.MiniRacer already exists, while
2419
- // a bare "foo" still attaches on the global.
2549
+ // a bare "foo" still attaches on the global. An `@` path lands on the realm's
2550
+ // host object instead: walked through own properties alone (path_segment), its
2551
+ // missing intermediates made with no prototype, and every property DEFINED
2552
+ // there — nothing a page's script left on a prototype runs or is written to.
2420
2553
  fn attach_at_path(
2421
2554
  scope: &mut v8::PinScope<'_, '_>,
2422
- context: v8::Local<v8::Context>,
2423
2555
  name: &str,
2424
2556
  function: v8::Local<v8::Function>,
2425
2557
  ) -> Result<JsVal, VmError> {
2426
- let mut parts: Vec<&str> = name.split('.').collect();
2558
+ let (mut holder, path, hidden) = path_root(scope, name);
2559
+ let mut parts: Vec<&str> = path.split('.').collect();
2427
2560
  let leaf = parts
2428
2561
  .pop()
2429
2562
  .filter(|s| !s.is_empty())
2430
2563
  .ok_or_else(|| VmError::Runtime(format!("invalid attach name `{name}`")))?;
2431
- let mut holder = context.global(scope);
2564
+ // Make a property of |holder|: defined on the host object's side, assigned on
2565
+ // the global's (as attach always has, so an accessor there still sees it).
2566
+ let put = |scope: &mut v8::PinScope<'_, '_>,
2567
+ holder: v8::Local<v8::Object>,
2568
+ key: v8::Local<v8::String>,
2569
+ value: v8::Local<v8::Value>| {
2570
+ if hidden {
2571
+ holder.create_data_property(scope, key.into(), value)
2572
+ } else {
2573
+ holder.set(scope, key.into(), value)
2574
+ }
2575
+ };
2432
2576
  for part in parts {
2433
2577
  if part.is_empty() {
2434
2578
  return Err(VmError::Runtime(format!("invalid attach name `{name}`")));
2435
2579
  }
2436
2580
  let key = v8::String::new(scope, part)
2437
2581
  .ok_or_else(|| VmError::Runtime("name too large".into()))?;
2438
- holder = match holder.get(scope, key.into()) {
2582
+ holder = match path_segment(scope, holder, key, hidden) {
2439
2583
  Some(v) if v.is_object() => v8::Local::<v8::Object>::try_from(v).unwrap(),
2440
2584
  // Don't clobber an existing non-object (e.g. a primitive global that
2441
2585
  // collides with the namespace name) — fail loudly instead.
@@ -2445,8 +2589,12 @@ fn attach_at_path(
2445
2589
  )));
2446
2590
  }
2447
2591
  _ => {
2448
- let obj = v8::Object::new(scope);
2449
- if holder.set(scope, key.into(), obj.into()) != Some(true) {
2592
+ let obj = if hidden {
2593
+ null_prototype_object(scope)
2594
+ } else {
2595
+ v8::Object::new(scope)
2596
+ };
2597
+ if put(scope, holder, key, obj.into()) != Some(true) {
2450
2598
  return Err(VmError::Runtime(format!(
2451
2599
  "`{name}`: cannot create `{part}`"
2452
2600
  )));
@@ -2457,7 +2605,7 @@ fn attach_at_path(
2457
2605
  }
2458
2606
  let key =
2459
2607
  v8::String::new(scope, leaf).ok_or_else(|| VmError::Runtime("name too large".into()))?;
2460
- if holder.set(scope, key.into(), function.into()) != Some(true) {
2608
+ if put(scope, holder, key, function.into()) != Some(true) {
2461
2609
  return Err(VmError::Runtime(format!(
2462
2610
  "`{name}`: target is not writable/extensible"
2463
2611
  )));
@@ -4346,6 +4494,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
4346
4494
  // same surface.
4347
4495
  pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
4348
4496
  let module = ruby.define_module("RustyRacer")?;
4497
+ // The argument that crosses into JS as the realm's host object (host_object).
4498
+ module.const_set("HOST_OBJECT", ruby.get_inner(&marshal::HOST_OBJECT))?;
4349
4499
 
4350
4500
  // The isolate (VM) + its isolate-level ops; hands out Contexts.
4351
4501
  let isolate = module.define_class("Isolate", ruby.class_object())?;
@@ -9,7 +9,7 @@ use std::collections::HashMap;
9
9
  use std::ffi::c_void;
10
10
 
11
11
  use magnus::rb_sys::{AsRawValue, FromRawValue};
12
- use magnus::value::ReprValue;
12
+ use magnus::value::{Lazy, ReprValue};
13
13
  use magnus::{
14
14
  Error, ExceptionClass, IntoValue, RArray, RHash, RString, Ruby, TryConvert, Value, prelude::*,
15
15
  };
@@ -78,6 +78,10 @@ pub(crate) enum JsVal {
78
78
  // Back-reference to an already-emitted container (preserves identity; makes
79
79
  // cycles representable instead of truncating at a depth cap).
80
80
  Ref(u32),
81
+ // Ruby RustyRacer::HOST_OBJECT <-> a realm's host object (`crate::host_object`):
82
+ // that of the realm an op runs in — a Context's, a Function handle's, a host
83
+ // function's own — both ways. Another realm's host object reads as an object.
84
+ HostObject,
81
85
  }
82
86
 
83
87
  // Cycles and sharing are handled by the Ref table (see JsVal::Ref), so this is
@@ -340,6 +344,14 @@ fn js_to_jsval_d(
340
344
  // their toString (the spike's primitive escape hatch).
341
345
  if value.is_object() && !value.is_function() {
342
346
  let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
347
+ // The realm's own host object crosses back as RustyRacer::HOST_OBJECT,
348
+ // not as a Hash of what the bindings keep there. Only an object with no
349
+ // prototype can be it, which keeps the private read off the common path.
350
+ if obj.get_prototype(scope).is_some_and(|p| p.is_null())
351
+ && crate::existing_host_object(scope).is_some_and(|host| host == obj)
352
+ {
353
+ return JsVal::HostObject;
354
+ }
343
355
  let id = match js_container_id(scope, seen, value, obj, depth) {
344
356
  Ok(id) => id,
345
357
  Err(jsval) => return jsval,
@@ -539,6 +551,7 @@ fn jsval_to_js_d<'s>(
539
551
  JsVal::Ref(id) => built
540
552
  .get(scope, id)
541
553
  .unwrap_or_else(|| v8::undefined(scope).into()),
554
+ JsVal::HostObject => crate::host_object(scope).into(),
542
555
  }
543
556
  }
544
557
 
@@ -669,9 +682,32 @@ fn jsval_to_ruby_d(
669
682
  .get(id)
670
683
  .copied()
671
684
  .unwrap_or_else(|| ruby.qnil().as_value()),
685
+ JsVal::HostObject => ruby.get_inner(&HOST_OBJECT),
672
686
  })
673
687
  }
674
688
 
689
+ // RustyRacer::HOST_OBJECT: the Ruby value that stands for a realm's host object
690
+ // as it crosses into JS (JsVal::HostObject). The one instance of
691
+ // RustyRacer::HostObject, whose allocator is undefined once it is made, so no
692
+ // other can be; marked for good (Lazy), so it never moves either.
693
+ fn host_object_inspect(_host: Value) -> &'static str {
694
+ "RustyRacer::HOST_OBJECT"
695
+ }
696
+
697
+ pub(crate) static HOST_OBJECT: Lazy<Value> = Lazy::new(|ruby| {
698
+ let class = ruby
699
+ .define_module("RustyRacer")
700
+ .and_then(|module| module.define_class("HostObject", ruby.class_object()))
701
+ .expect("RustyRacer::HostObject is definable");
702
+ class
703
+ .define_method("inspect", magnus::method!(host_object_inspect, 0))
704
+ .expect("HostObject#inspect is definable");
705
+ let host: Value = class.new_instance(()).expect("a HostObject is allocatable");
706
+ host.freeze();
707
+ class.undef_default_alloc_func();
708
+ host
709
+ });
710
+
675
711
  // A Ruby String marshalled by its encoding TAG (the tag is the type):
676
712
  // - ASCII-8BIT (binary) -> JsVal::Bytes (a JS Uint8Array);
677
713
  // - any text encoding -> JsVal::Str (UTF-8). Already-UTF-8 text is taken
@@ -842,6 +878,11 @@ fn ruby_to_jsval_d(val: Value, seen: &mut RbSeen, depth: u32) -> Result<JsVal, E
842
878
  }
843
879
  // What only a class, or a conversion method, can say.
844
880
  //
881
+ // RustyRacer::HOST_OBJECT -> the realm's host object, by identity.
882
+ if val.as_raw() == ruby.get_inner(&HOST_OBJECT).as_raw() {
883
+ return Ok(JsVal::HostObject);
884
+ }
885
+ //
845
886
  // Ruby Time -> JS Date. Before the Numeric fallback: magnus's f64 TryConvert
846
887
  // coerces a Time via to_f, so it would otherwise marshal as a bare epoch
847
888
  // number. Time#to_f is epoch seconds; Date wants ms.
@@ -1148,4 +1189,38 @@ mod tests {
1148
1189
  },
1149
1190
  );
1150
1191
  }
1192
+
1193
+ #[test]
1194
+ fn the_host_object_is_one_per_realm_and_no_property_of_its_global() {
1195
+ in_context(
1196
+ "globalThis.keys = Reflect.ownKeys(globalThis).length",
1197
+ |scope| {
1198
+ let value = jsval_to_js(
1199
+ scope,
1200
+ JsVal::Array {
1201
+ id: 0,
1202
+ items: vec![JsVal::HostObject, JsVal::HostObject],
1203
+ },
1204
+ );
1205
+ let held = crate::host_object(scope);
1206
+ let key = v8::String::new(scope, "held").unwrap();
1207
+ held.create_data_property(scope, key.into(), v8::Integer::new(scope, 7).into());
1208
+ global_set(scope, "v", value);
1209
+ // (…the global's keys grown by `keys` and `v` alone)
1210
+ assert_eq!(
1211
+ as_string(
1212
+ scope,
1213
+ "[v[0] === v[1], v[0].held, Object.getPrototypeOf(v[0]), Reflect.ownKeys(globalThis).length - keys].join()"
1214
+ ),
1215
+ "true,7,,2"
1216
+ );
1217
+ // (…another realm's is its own)
1218
+ let other = v8::Context::new(scope, Default::default());
1219
+ let scope = &mut v8::ContextScope::new(scope, other);
1220
+ let fresh = crate::host_object(scope);
1221
+ assert!(fresh != held);
1222
+ assert!(crate::host_object(scope) == fresh);
1223
+ },
1224
+ );
1225
+ }
1151
1226
  }
@@ -157,9 +157,9 @@ pub(crate) enum Request {
157
157
  EvaluateModule {
158
158
  module_id: i32,
159
159
  timeout_ms: u64,
160
- // The name of a function on the module's context's global that reports its evaluation's rejection — HTML "run
161
- // a module script": upon rejection, report the exception — now or as a top-level-await module settles; none:
162
- // the rejection is the op's error, as far as the drain gets.
160
+ // The name of a function on the module's context's global (an `@` path: on its host object) that reports its
161
+ // evaluation's rejection — HTML "run a module script": upon rejection, report the exception — now or as a
162
+ // top-level-await module settles; none: the rejection is the op's error, as far as the drain gets.
163
163
  report: Option<String>,
164
164
  },
165
165
  ModuleNamespace {
@@ -294,19 +294,20 @@ pub(crate) fn run_source(
294
294
  }
295
295
  }
296
296
 
297
- // Resolve a dotted property path on the current realm's globalThis to a
298
- // function and the holder it was read off — its `this`, so `a.b.f` gets the right
299
- // receiver. Each segment is an ordinary [[Get]], so a getter or Proxy trap on the
300
- // path runs; when one throws, the error is Err(None): the caller's TryCatch holds
301
- // it (`caught!`).
297
+ // Resolve a dotted property path on the current realm's globalThis — or, for an
298
+ // `@` path, on its host object (`host_object`) — to a function and the holder it
299
+ // was read off — its `this`, so `a.b.f` gets the right receiver. Each segment is
300
+ // an ordinary [[Get]] (an `@` path's of an own property alone: path_segment), so
301
+ // a getter or Proxy trap on the path runs; when one throws, the error is
302
+ // Err(None): the caller's TryCatch holds it (`caught!`).
302
303
  fn resolve_function<'s>(
303
304
  scope: &mut v8::PinScope<'s, '_>,
304
305
  path: &str,
305
306
  ) -> Result<(v8::Local<'s, v8::Function>, v8::Local<'s, v8::Value>), Option<VmError>> {
306
- let global = scope.get_current_context().global(scope);
307
- let mut holder: v8::Local<v8::Value> = global.into();
308
- let mut target: v8::Local<v8::Value> = global.into();
309
- for part in path.split('.') {
307
+ let (root, rest, hidden) = path_root(scope, path);
308
+ let mut holder: v8::Local<v8::Value> = root.into();
309
+ let mut target: v8::Local<v8::Value> = root.into();
310
+ for part in rest.split('.') {
310
311
  let Some(obj) = target.to_object(scope) else {
311
312
  // The holder of `part` (a preceding segment) was null/undefined, so
312
313
  // there's nothing to read `part` from — name the holder, not `part`.
@@ -317,7 +318,7 @@ fn resolve_function<'s>(
317
318
  let Some(key) = v8::String::new(scope, part) else {
318
319
  return Err(Some(VmError::Runtime("property name too large".into())));
319
320
  };
320
- let Some(next) = obj.get(scope, key.into()) else {
321
+ let Some(next) = path_segment(scope, obj, key, hidden) else {
321
322
  return Err(None);
322
323
  };
323
324
  holder = target;
@@ -883,8 +884,8 @@ fn op_attach(
883
884
  timeout_ms: u64,
884
885
  outermost: bool,
885
886
  ) -> VmReply {
886
- // attach_at_path writes onto globalThis (and walks a dotted
887
- // path), which can fire a user-defined accessor or Proxy trap —
887
+ // attach_at_path writes onto globalThis or the host object (and walks
888
+ // a dotted path), which can fire a user-defined accessor or Proxy trap —
888
889
  // arbitrary JS. So it goes through the same bracket as Eval: a
889
890
  // host fn the trap calls routes back, and a looping trap is
890
891
  // time-capped.
@@ -908,7 +909,7 @@ fn op_attach(
908
909
  // under a namespace object, creating missing
909
910
  // intermediates, so host fns needn't pollute the
910
911
  // bare global.
911
- Some(function) => attach_at_path(scope, context, &name, function),
912
+ Some(function) => attach_at_path(scope, &name, function),
912
913
  None => Err(VmError::Runtime("failed to build function".into())),
913
914
  };
914
915
  auto_drain(scope, outermost);
@@ -955,7 +956,7 @@ fn op_attach_many(
955
956
  .data(external.into())
956
957
  .build(scope)
957
958
  {
958
- Some(function) => attach_at_path(scope, context, name, function),
959
+ Some(function) => attach_at_path(scope, name, function),
959
960
  None => Err(VmError::Runtime(format!(
960
961
  "failed to build function for `{name}`"
961
962
  ))),
@@ -1272,7 +1273,7 @@ fn op_evaluate_module(
1272
1273
  did_eval = true;
1273
1274
  v8::tc_scope!(let tc, scope);
1274
1275
  let reporter =
1275
- report.as_deref().and_then(|name| global_function(tc, name));
1276
+ report.as_deref().and_then(|name| named_function(tc, name));
1276
1277
  match module.evaluate(tc) {
1277
1278
  // A synchronous top-level throw yields a
1278
1279
  // *rejected* promise (not None); a pending
@@ -1339,11 +1340,18 @@ fn op_evaluate_module(
1339
1340
  VmReply::Done(outcome)
1340
1341
  }
1341
1342
 
1342
- // The function the current context's global has under `name`, or None.
1343
- fn global_function<'s>(
1343
+ // The function the current context's global has under `name` (one property,
1344
+ // dots and all, as it always was) — or, for an `@` path, the one the path names
1345
+ // on its host object, as `Context#function` finds it — or None.
1346
+ fn named_function<'s>(
1344
1347
  scope: &mut v8::PinScope<'s, '_>,
1345
1348
  name: &str,
1346
1349
  ) -> Option<v8::Local<'s, v8::Function>> {
1350
+ if name.starts_with('@') {
1351
+ return resolve_function(scope, name)
1352
+ .ok()
1353
+ .map(|(function, _)| function);
1354
+ }
1347
1355
  let key = v8::String::new(scope, name)?;
1348
1356
  let global = scope.get_current_context().global(scope);
1349
1357
  v8::Local::<v8::Function>::try_from(global.get(scope, key.into())?).ok()
@@ -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.8"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -91,10 +91,15 @@ module RustyRacer
91
91
  # the microtask queue when the outermost eval/call/run/evaluate completes
92
92
  # (the standard embedder contract); :explicit drains only on
93
93
  # #perform_microtask_checkpoint.
94
+ # host_namespace names the object every realm gets rusty_racer's own
95
+ # functions in (drainMicrotasks, contextGlobal, contextOf, ...): a property
96
+ # of the global (`'MiniRacer'`), or, as `'@MiniRacer'`, of the realm's host
97
+ # object, which no script of a page's can reach (see HOST_OBJECT).
94
98
  def self.new(host_namespace: nil, snapshot: nil, timeout_ms: 0, memory_limit: 0, microtasks: :auto)
95
99
  unless %i[auto explicit].include?(microtasks)
96
100
  raise ArgumentError, "microtasks must be :auto or :explicit, got #{microtasks.inspect}"
97
101
  end
102
+ raise ArgumentError, 'host_namespace: "@" names no property of the host object' if host_namespace == '@'
98
103
 
99
104
  _new(host_namespace, snapshot, timeout_ms, memory_limit, microtasks == :explicit)
100
105
  end
@@ -116,6 +121,24 @@ module RustyRacer
116
121
  end
117
122
  end
118
123
 
124
+ # HOST_OBJECT (defined natively): an argument that crosses into JS as the
125
+ # realm's HOST OBJECT — an object with no prototype that no global holds, which
126
+ # the host and the realm's own bindings share and no other script can reach.
127
+ # Every path the host names (Context#attach / #attach_many / #call /
128
+ # #call_void / #function, Module#evaluate's report:, Isolate.new's
129
+ # host_namespace:) starts from it when it begins with `@`:
130
+ #
131
+ # ctx.attach('@fetch', ->(url) { ... }) # host.fetch, no global
132
+ # ctx.call('installBindings', RustyRacer::HOST_OBJECT)
133
+ # step = ctx.function('@steps.query') # host.steps.query
134
+ #
135
+ # An `@` path walks own properties alone. HOST_OBJECT is the host object of
136
+ # the realm the operation runs in (a Context's, a Function handle's, a host
137
+ # function's own, for its result), and that object crosses back as
138
+ # HOST_OBJECT. Each realm has its own, made as it is first used; a reset makes
139
+ # the realm, and so its host object, anew. Hand it to the bindings before any
140
+ # script of a page's runs, by a name they delete as they take it.
141
+
119
142
  # A v8::Context (realm) handed out by an Isolate: where JS actually runs.
120
143
  class Context
121
144
  # `timeout_ms` (0 = the isolate default) caps this eval; `filename` names the
@@ -180,9 +203,9 @@ module RustyRacer
180
203
 
181
204
  # Evaluate the instantiated module. Its evaluation's rejection — a top-level throw, a top-level await's — raises
182
205
  # (RustyRacer::RuntimeError, as far as the drain after it settles), or, `report:` naming a function of its context's
183
- # global, is that function's to report: called with the rejection's reason — a top-level throw's before this
184
- # returns, a top-level await's as it settles — and nothing raised (HTML "run a module script": upon rejection,
185
- # report the exception).
206
+ # global (an `@` path: on its host object, HOST_OBJECT), is that function's to report: called with the rejection's
207
+ # reason — a top-level throw's before this returns, a top-level await's as it settles — and nothing raised (HTML
208
+ # "run a module script": upon rejection, report the exception).
186
209
  def evaluate(report: nil)
187
210
  _evaluate(report&.to_s)
188
211
  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.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima