File
Blob: src/rust/jsg-test/tests/gc.rs
| 1 | // Copyright (c) 2026 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | //! GC (garbage collection) tests for Rust resources. |
| 6 | //! |
| 7 | //! These tests verify that Rust resources are properly cleaned up when: |
| 8 | //! 1. All Rust `Ref` handles are dropped and no JavaScript wrapper exists (immediate cleanup) |
| 9 | //! 2. All Rust `Ref` handles are dropped and V8 garbage collects the JS wrapper |
| 10 | //! |
| 11 | //! Note: Circular references through `Ref<T>` are NOT collected, matching the behavior |
| 12 | //! of C++ `jsg::Rc<T>` which uses `kj::Own<T>` cross-references. |
| 13 | |
| 14 | use std::cell::Cell; |
| 15 | use std::sync::atomic::AtomicUsize; |
| 16 | use std::sync::atomic::Ordering; |
| 17 | |
| 18 | use jsg::ToJS; |
| 19 | use jsg_macros::jsg_method; |
| 20 | use jsg_macros::jsg_resource; |
| 21 | |
| 22 | /// Counter to track how many `SimpleResource` instances have been dropped. |
| 23 | static SIMPLE_RESOURCE_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 24 | |
| 25 | #[jsg_resource] |
| 26 | struct SimpleResource { |
| 27 | pub name: String, |
| 28 | } |
| 29 | |
| 30 | impl Drop for SimpleResource { |
| 31 | fn drop(&mut self) { |
| 32 | SIMPLE_RESOURCE_DROPS.fetch_add(1, Ordering::SeqCst); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | #[jsg_resource] |
| 37 | #[expect(clippy::unnecessary_wraps)] |
| 38 | impl SimpleResource { |
| 39 | #[jsg_method] |
| 40 | fn get_name(&self) -> Result<String, jsg::Error> { |
| 41 | Ok(self.name.clone()) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Counter to track how many `ParentResource` instances have been dropped. |
| 46 | static PARENT_RESOURCE_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 47 | |
| 48 | #[jsg_resource] |
| 49 | struct ParentResource { |
| 50 | pub child: jsg::Rc<SimpleResource>, |
| 51 | pub optional_child: Option<jsg::Rc<SimpleResource>>, |
| 52 | } |
| 53 | |
| 54 | impl Drop for ParentResource { |
| 55 | fn drop(&mut self) { |
| 56 | PARENT_RESOURCE_DROPS.fetch_add(1, Ordering::SeqCst); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | #[jsg_resource] |
| 61 | impl ParentResource {} |
| 62 | |
| 63 | /// Tests that resources are dropped immediately when all Rust Refs are dropped |
| 64 | /// and no JS wrapper exists. |
| 65 | /// |
| 66 | /// In the Wrappable model, dropping the last Ref decrements the kj refcount to 0, |
| 67 | /// which immediately destroys the Wrappable (and thus the Rust resource). |
| 68 | /// No GC is needed. |
| 69 | #[test] |
| 70 | fn supports_gc_via_ref_drop() { |
| 71 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 72 | |
| 73 | let harness = crate::Harness::new(); |
| 74 | harness.run_in_context(|_lock, _ctx| { |
| 75 | let resource = jsg::Rc::new(SimpleResource { |
| 76 | name: "test".to_owned(), |
| 77 | }); |
| 78 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 79 | std::mem::drop(resource); |
| 80 | // In the Wrappable model, no wrapper means immediate cleanup when refcount hits 0 |
| 81 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 82 | Ok(()) |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | /// Tests that resources are dropped via V8 GC when JS wrapper is collected. |
| 87 | /// |
| 88 | /// When a resource is wrapped for JavaScript: |
| 89 | /// 1. Dropping all Rust `Ref` handles calls `removeStrongRef()` but the `CppgcShim` |
| 90 | /// still holds a `kj::Own` keeping the object alive |
| 91 | /// 2. V8 GC collects the wrapper, `CppgcShim` is destroyed, `kj::Own` is dropped |
| 92 | /// 3. kj refcount reaches 0, Wrappable is destroyed |
| 93 | #[test] |
| 94 | fn supports_gc_via_weak_callback() { |
| 95 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 96 | |
| 97 | let harness = crate::Harness::new(); |
| 98 | harness.run_in_context(|lock, _ctx| { |
| 99 | let resource = jsg::Rc::new(SimpleResource { |
| 100 | name: "test".to_owned(), |
| 101 | }); |
| 102 | let _wrapped = resource.clone().to_js(lock); |
| 103 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 104 | std::mem::drop(resource); |
| 105 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 106 | Ok(()) |
| 107 | }); |
| 108 | |
| 109 | harness.run_in_context(|lock, _ctx| { |
| 110 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 111 | crate::Harness::request_gc(lock); |
| 112 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 113 | Ok(()) |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn resource_with_traced_ref_field() { |
| 119 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 120 | PARENT_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 121 | |
| 122 | let harness = crate::Harness::new(); |
| 123 | harness.run_in_context(|lock, _ctx| { |
| 124 | let child = jsg::Rc::new(SimpleResource { |
| 125 | name: "child".to_owned(), |
| 126 | }); |
| 127 | let optional_child = jsg::Rc::new(SimpleResource { |
| 128 | name: "optional_child".to_owned(), |
| 129 | }); |
| 130 | |
| 131 | let parent = jsg::Rc::new(ParentResource { |
| 132 | child: child.clone(), |
| 133 | optional_child: Some(optional_child.clone()), |
| 134 | }); |
| 135 | |
| 136 | let _wrapped = parent.clone().to_js(lock); |
| 137 | |
| 138 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 139 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 140 | |
| 141 | std::mem::drop(child); |
| 142 | std::mem::drop(optional_child); |
| 143 | std::mem::drop(parent); |
| 144 | Ok(()) |
| 145 | }); |
| 146 | |
| 147 | harness.run_in_context(|lock, _ctx| { |
| 148 | crate::Harness::request_gc(lock); |
| 149 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 150 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 2); |
| 151 | Ok(()) |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | #[test] |
| 156 | fn child_traced_ref_kept_alive_by_parent() { |
| 157 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 158 | PARENT_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 159 | |
| 160 | let harness = crate::Harness::new(); |
| 161 | harness.run_in_context(|lock, _ctx| { |
| 162 | let child = jsg::Rc::new(SimpleResource { |
| 163 | name: "child".to_owned(), |
| 164 | }); |
| 165 | |
| 166 | let parent = jsg::Rc::new(ParentResource { |
| 167 | child: child.clone(), |
| 168 | optional_child: None, |
| 169 | }); |
| 170 | |
| 171 | // Wrap the parent so it has a JS object, then let the Local go out of scope |
| 172 | // by not storing it. The wrapper is now only held weakly by cppgc. |
| 173 | let _ = parent.clone().to_js(lock); |
| 174 | |
| 175 | // Child not collected because parent still holds a Ref (which holds a kj::Own) |
| 176 | std::mem::drop(child); |
| 177 | crate::Harness::request_gc(lock); |
| 178 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 179 | |
| 180 | // Drop the Rust strong ref. The parent still has a wrapper, but with no |
| 181 | // strong refs and no JS references, the wrapper is eligible for GC. |
| 182 | std::mem::drop(parent); |
| 183 | Ok(()) |
| 184 | }); |
| 185 | |
| 186 | // GC in a separate context so the Local handle from wrap() is gone |
| 187 | harness.run_in_context(|lock, _ctx| { |
| 188 | crate::Harness::request_gc(lock); |
| 189 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 190 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 191 | Ok(()) |
| 192 | }); |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn weak_ref_upgrade() { |
| 197 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 198 | |
| 199 | let harness = crate::Harness::new(); |
| 200 | harness.run_in_context(|_lock, _ctx| { |
| 201 | let strong = jsg::Rc::new(SimpleResource { |
| 202 | name: "test".to_owned(), |
| 203 | }); |
| 204 | let weak = strong.downgrade(); |
| 205 | |
| 206 | assert!(weak.is_alive()); |
| 207 | let upgraded = weak.upgrade(); |
| 208 | assert!(upgraded.is_some()); |
| 209 | assert!(weak.is_alive()); |
| 210 | |
| 211 | std::mem::drop(upgraded); |
| 212 | assert!(weak.is_alive()); |
| 213 | |
| 214 | std::mem::drop(strong); |
| 215 | // No wrapper, so resource is destroyed immediately when last strong ref drops |
| 216 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 217 | assert!(!weak.is_alive()); |
| 218 | assert!(weak.upgrade().is_none()); |
| 219 | Ok(()) |
| 220 | }); |
| 221 | } |
| 222 | |
| 223 | /// Tests that a wrapped resource stays alive as long as JS wrapper exists. |
| 224 | #[test] |
| 225 | fn wrapped_resource_kept_alive_by_js() { |
| 226 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 227 | |
| 228 | let harness = crate::Harness::new(); |
| 229 | harness.run_in_context(|lock, _ctx| { |
| 230 | let strong = jsg::Rc::new(SimpleResource { |
| 231 | name: "test".to_owned(), |
| 232 | }); |
| 233 | let _wrapped = strong.clone().to_js(lock); |
| 234 | std::mem::drop(strong); |
| 235 | // CppgcShim still holds kj::Own, keeping it alive |
| 236 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 237 | Ok(()) |
| 238 | }); |
| 239 | |
| 240 | harness.run_in_context(|lock, _ctx| { |
| 241 | crate::Harness::request_gc(lock); |
| 242 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 243 | Ok(()) |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | /// Tests weak ref behavior with wrapped resources. |
| 248 | #[test] |
| 249 | fn weak_ref_with_wrapped_resource() { |
| 250 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 251 | |
| 252 | let harness = crate::Harness::new(); |
| 253 | harness.run_in_context(|lock, _ctx| { |
| 254 | let strong = jsg::Rc::new(SimpleResource { |
| 255 | name: "test".to_owned(), |
| 256 | }); |
| 257 | let _wrapped = strong.clone().to_js(lock); |
| 258 | let weak = strong.downgrade(); |
| 259 | |
| 260 | assert!(weak.upgrade().is_some()); |
| 261 | std::mem::drop(strong); |
| 262 | // CppgcShim keeps it alive even after dropping the strong ref |
| 263 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 264 | Ok(()) |
| 265 | }); |
| 266 | |
| 267 | harness.run_in_context(|lock, _ctx| { |
| 268 | crate::Harness::request_gc(lock); |
| 269 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 270 | Ok(()) |
| 271 | }); |
| 272 | } |
| 273 | |
| 274 | /// Tests parent-child GC with traced refs. |
| 275 | #[test] |
| 276 | fn traced_ref_in_gc() { |
| 277 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 278 | PARENT_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 279 | |
| 280 | let harness = crate::Harness::new(); |
| 281 | harness.run_in_context(|lock, _ctx| { |
| 282 | let child = jsg::Rc::new(SimpleResource { |
| 283 | name: "child".to_owned(), |
| 284 | }); |
| 285 | let _child_wrapped = child.clone().to_js(lock); |
| 286 | |
| 287 | let parent = jsg::Rc::new(ParentResource { |
| 288 | child: child.clone(), |
| 289 | optional_child: None, |
| 290 | }); |
| 291 | let _parent_wrapped = parent.clone().to_js(lock); |
| 292 | |
| 293 | std::mem::drop(child); |
| 294 | std::mem::drop(parent); |
| 295 | |
| 296 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 297 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 298 | Ok(()) |
| 299 | }); |
| 300 | |
| 301 | harness.run_in_context(|lock, _ctx| { |
| 302 | crate::Harness::request_gc(lock); |
| 303 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 304 | crate::Harness::request_gc(lock); |
| 305 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 306 | Ok(()) |
| 307 | }); |
| 308 | } |
| 309 | |
| 310 | // ============================================================================= |
| 311 | // WeakRef tests |
| 312 | // ============================================================================= |
| 313 | |
| 314 | /// Tests that `WeakRef::get()` returns the resource data while the resource is alive. |
| 315 | #[test] |
| 316 | fn weak_ref_get_returns_resource_data() { |
| 317 | let harness = crate::Harness::new(); |
| 318 | harness.run_in_context(|_lock, _ctx| { |
| 319 | let strong = jsg::Rc::new(SimpleResource { |
| 320 | name: "hello".to_owned(), |
| 321 | }); |
| 322 | let weak = strong.downgrade(); |
| 323 | |
| 324 | // get() should return a reference to the resource |
| 325 | let resource = weak.upgrade().expect("weak ref should be alive"); |
| 326 | assert_eq!(resource.name, "hello"); |
| 327 | Ok(()) |
| 328 | }); |
| 329 | } |
| 330 | |
| 331 | /// Tests that `WeakRef::get()` returns `None` after the resource is dropped. |
| 332 | #[test] |
| 333 | fn weak_ref_get_returns_none_after_drop() { |
| 334 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 335 | |
| 336 | let harness = crate::Harness::new(); |
| 337 | harness.run_in_context(|_lock, _ctx| { |
| 338 | let strong = jsg::Rc::new(SimpleResource { |
| 339 | name: "ephemeral".to_owned(), |
| 340 | }); |
| 341 | let weak = strong.downgrade(); |
| 342 | assert!(weak.upgrade().is_some()); |
| 343 | |
| 344 | std::mem::drop(strong); |
| 345 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 346 | |
| 347 | // get() must return None without touching freed memory |
| 348 | assert!(weak.upgrade().is_none()); |
| 349 | Ok(()) |
| 350 | }); |
| 351 | } |
| 352 | |
| 353 | /// Tests that `WeakRef::default()` creates a dead weak reference. |
| 354 | /// |
| 355 | /// A default `WeakRef` has `wrappable: None` and an expired `Weak<R>`. |
| 356 | /// All operations must be safe: `get()` -> `None`, `upgrade()` -> `None`, `is_alive()` -> `false`. |
| 357 | #[test] |
| 358 | fn weak_ref_default_is_dead() { |
| 359 | let harness = crate::Harness::new(); |
| 360 | harness.run_in_context(|_lock, _ctx| { |
| 361 | let weak: jsg::Weak<SimpleResource> = jsg::Weak::default(); |
| 362 | |
| 363 | assert!(!weak.is_alive()); |
| 364 | assert!(weak.upgrade().is_none()); |
| 365 | assert!(weak.upgrade().is_none()); |
| 366 | Ok(()) |
| 367 | }); |
| 368 | } |
| 369 | |
| 370 | /// Tests that cloning a `WeakRef` shares the alive marker. |
| 371 | /// |
| 372 | /// When the resource dies, ALL clones must see `is_alive() == false` simultaneously. |
| 373 | #[test] |
| 374 | fn weak_ref_clone_shares_alive_marker() { |
| 375 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 376 | |
| 377 | let harness = crate::Harness::new(); |
| 378 | harness.run_in_context(|_lock, _ctx| { |
| 379 | let strong = jsg::Rc::new(SimpleResource { |
| 380 | name: "shared".to_owned(), |
| 381 | }); |
| 382 | |
| 383 | let weak1 = strong.downgrade(); |
| 384 | let weak2 = weak1.clone(); |
| 385 | let weak3 = weak2.clone(); |
| 386 | |
| 387 | assert!(weak1.is_alive()); |
| 388 | assert!(weak2.is_alive()); |
| 389 | assert!(weak3.is_alive()); |
| 390 | |
| 391 | std::mem::drop(strong); |
| 392 | |
| 393 | // All clones see death simultaneously |
| 394 | assert!(!weak1.is_alive()); |
| 395 | assert!(!weak2.is_alive()); |
| 396 | assert!(!weak3.is_alive()); |
| 397 | assert!(weak1.upgrade().is_none()); |
| 398 | assert!(weak2.upgrade().is_none()); |
| 399 | Ok(()) |
| 400 | }); |
| 401 | } |
| 402 | |
| 403 | /// Tests that `WeakRef::upgrade()` with a wrapped resource creates a strong ref |
| 404 | /// that prevents GC collection. |
| 405 | #[test] |
| 406 | fn weak_ref_upgrade_with_wrapped_resource_prevents_gc() { |
| 407 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 408 | |
| 409 | let harness = crate::Harness::new(); |
| 410 | harness.run_in_context(|lock, _ctx| { |
| 411 | let strong = jsg::Rc::new(SimpleResource { |
| 412 | name: "persistent".to_owned(), |
| 413 | }); |
| 414 | let _wrapped = strong.clone().to_js(lock); |
| 415 | let weak = strong.downgrade(); |
| 416 | |
| 417 | // Drop the original strong ref, but upgrade from weak creates a new one |
| 418 | std::mem::drop(strong); |
| 419 | let upgraded = weak.upgrade().expect("should be alive via wrapper"); |
| 420 | assert_eq!(upgraded.name, "persistent"); |
| 421 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 422 | |
| 423 | // The upgraded Ref keeps the resource alive |
| 424 | std::mem::drop(upgraded); |
| 425 | Ok(()) |
| 426 | }); |
| 427 | |
| 428 | harness.run_in_context(|lock, _ctx| { |
| 429 | crate::Harness::request_gc(lock); |
| 430 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 431 | Ok(()) |
| 432 | }); |
| 433 | } |
| 434 | |
| 435 | /// Tests that `WeakRef::trace()` is a no-op and doesn't prevent GC collection. |
| 436 | /// |
| 437 | /// A resource holding a `WeakRef` to another resource should not keep it alive through tracing. |
| 438 | #[test] |
| 439 | fn weak_ref_trace_does_not_prevent_gc() { |
| 440 | static HOLDER_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 441 | |
| 442 | #[jsg_resource] |
| 443 | struct WeakRefHolder { |
| 444 | weak: jsg::Weak<SimpleResource>, |
| 445 | } |
| 446 | |
| 447 | impl Drop for WeakRefHolder { |
| 448 | fn drop(&mut self) { |
| 449 | HOLDER_DROPS.fetch_add(1, Ordering::SeqCst); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | #[jsg_resource] |
| 454 | impl WeakRefHolder {} |
| 455 | |
| 456 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 457 | HOLDER_DROPS.store(0, Ordering::SeqCst); |
| 458 | |
| 459 | let harness = crate::Harness::new(); |
| 460 | harness.run_in_context(|lock, _ctx| { |
| 461 | let target = jsg::Rc::new(SimpleResource { |
| 462 | name: "target".to_owned(), |
| 463 | }); |
| 464 | let _target_wrapped = target.clone().to_js(lock); |
| 465 | |
| 466 | let holder = jsg::Rc::new(WeakRefHolder { |
| 467 | weak: target.downgrade(), |
| 468 | }); |
| 469 | let _holder_wrapped = holder.clone().to_js(lock); |
| 470 | |
| 471 | // WeakRef should be upgradable while the target is alive |
| 472 | assert!(holder.weak.upgrade().is_some()); |
| 473 | |
| 474 | // Drop all Rust refs — both are only held by JS wrappers |
| 475 | std::mem::drop(target); |
| 476 | std::mem::drop(holder); |
| 477 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 478 | Ok(()) |
| 479 | }); |
| 480 | |
| 481 | // GC should collect the target — the WeakRef in holder doesn't keep it alive |
| 482 | harness.run_in_context(|lock, _ctx| { |
| 483 | crate::Harness::request_gc(lock); |
| 484 | // Both should be collected (holder's WeakRef doesn't prevent target's collection) |
| 485 | assert_eq!(HOLDER_DROPS.load(Ordering::SeqCst), 1); |
| 486 | crate::Harness::request_gc(lock); |
| 487 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 488 | Ok(()) |
| 489 | }); |
| 490 | } |
| 491 | |
| 492 | // ============================================================================= |
| 493 | // Ref tests |
| 494 | // ============================================================================= |
| 495 | |
| 496 | /// Tests that `Ref::clone()` keeps the resource alive after the original is dropped. |
| 497 | #[test] |
| 498 | fn ref_clone_keeps_resource_alive() { |
| 499 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 500 | |
| 501 | let harness = crate::Harness::new(); |
| 502 | harness.run_in_context(|_lock, _ctx| { |
| 503 | let original = jsg::Rc::new(SimpleResource { |
| 504 | name: "cloned".to_owned(), |
| 505 | }); |
| 506 | let clone1 = original.clone(); |
| 507 | let clone2 = original.clone(); |
| 508 | |
| 509 | std::mem::drop(original); |
| 510 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 511 | assert_eq!(clone1.name, "cloned"); |
| 512 | |
| 513 | std::mem::drop(clone1); |
| 514 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 515 | assert_eq!(clone2.name, "cloned"); |
| 516 | |
| 517 | std::mem::drop(clone2); |
| 518 | // Only now all refs are gone — resource is destroyed |
| 519 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 520 | Ok(()) |
| 521 | }); |
| 522 | } |
| 523 | |
| 524 | /// Tests that dropping multiple Refs only triggers destruction on the last one. |
| 525 | #[test] |
| 526 | fn multiple_ref_drops_only_last_triggers_destruction() { |
| 527 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 528 | |
| 529 | let harness = crate::Harness::new(); |
| 530 | harness.run_in_context(|_lock, _ctx| { |
| 531 | let original = jsg::Rc::new(SimpleResource { |
| 532 | name: "multi".to_owned(), |
| 533 | }); |
| 534 | |
| 535 | // Create several clones |
| 536 | let clones: Vec<_> = (0..5).map(|_| original.clone()).collect(); |
| 537 | std::mem::drop(original); |
| 538 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 539 | |
| 540 | // Drop one by one |
| 541 | for (i, c) in clones.into_iter().enumerate() { |
| 542 | std::mem::drop(c); |
| 543 | if i < 4 { |
| 544 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 545 | } |
| 546 | } |
| 547 | // Last clone dropped — resource is destroyed |
| 548 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 549 | Ok(()) |
| 550 | }); |
| 551 | } |
| 552 | |
| 553 | /// Tests that `Ref::deref()` returns the correct resource data. |
| 554 | #[test] |
| 555 | fn ref_deref_returns_correct_resource_data() { |
| 556 | let harness = crate::Harness::new(); |
| 557 | harness.run_in_context(|_lock, _ctx| { |
| 558 | let r = jsg::Rc::new(SimpleResource { |
| 559 | name: "deref-test".to_owned(), |
| 560 | }); |
| 561 | |
| 562 | // Deref should return the resource with correct data |
| 563 | assert_eq!(r.name, "deref-test"); |
| 564 | |
| 565 | // Clone should also deref to the same data |
| 566 | #[expect(clippy::redundant_clone)] |
| 567 | let clone = r.clone(); |
| 568 | assert_eq!(clone.name, "deref-test"); |
| 569 | Ok(()) |
| 570 | }); |
| 571 | } |
| 572 | |
| 573 | // ============================================================================= |
| 574 | // unwrap / FromJS tests |
| 575 | // ============================================================================= |
| 576 | |
| 577 | /// Tests that `FromJS for Ref<R>` creates a strong reference from a JS wrapper. |
| 578 | /// |
| 579 | /// The returned `Ref<R>` must keep the resource alive even after dropping the original. |
| 580 | #[test] |
| 581 | fn from_js_creates_strong_reference_from_js_wrapper() { |
| 582 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 583 | |
| 584 | let harness = crate::Harness::new(); |
| 585 | harness.run_in_context(|lock, ctx| { |
| 586 | let resource = jsg::Rc::new(SimpleResource { |
| 587 | name: "unwrap-me".to_owned(), |
| 588 | }); |
| 589 | let wrapped = resource.clone().to_js(lock); |
| 590 | ctx.set_global("obj", wrapped); |
| 591 | |
| 592 | // Get the JS value back and use FromJS to create a new strong Ref |
| 593 | let js_val = ctx.eval_raw("obj").unwrap(); |
| 594 | let new_ref: jsg::Rc<SimpleResource> = |
| 595 | <jsg::Rc<SimpleResource> as jsg::FromJS>::from_js(lock, js_val) |
| 596 | .expect("FromJS should succeed for a wrapped resource"); |
| 597 | assert_eq!(new_ref.name, "unwrap-me"); |
| 598 | |
| 599 | // Drop the original Ref — the unwrapped ref should keep it alive |
| 600 | std::mem::drop(resource); |
| 601 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 602 | |
| 603 | // The unwrapped ref is still valid |
| 604 | assert_eq!(new_ref.name, "unwrap-me"); |
| 605 | |
| 606 | // Dropping the unwrapped ref (with wrapper still alive) should not destroy |
| 607 | std::mem::drop(new_ref); |
| 608 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 609 | Ok(()) |
| 610 | }); |
| 611 | |
| 612 | // GC the JS wrapper — now the resource can be collected |
| 613 | harness.run_in_context(|lock, _ctx| { |
| 614 | crate::Harness::request_gc(lock); |
| 615 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 616 | Ok(()) |
| 617 | }); |
| 618 | } |
| 619 | |
| 620 | /// Tests that `FromJS` returns `Err` for a plain JS object. |
| 621 | /// |
| 622 | /// A plain `{}` object has no internal fields and no wrappable tag. |
| 623 | /// `FromJS::from_js` must return `Err` without crashing. |
| 624 | #[test] |
| 625 | fn from_js_returns_err_for_plain_js_object() { |
| 626 | let harness = crate::Harness::new(); |
| 627 | harness.run_in_context(|lock, ctx| { |
| 628 | let plain_obj = ctx.eval_raw("({})").unwrap(); |
| 629 | let result = <jsg::Rc<SimpleResource> as jsg::FromJS>::from_js(lock, plain_obj); |
| 630 | assert!( |
| 631 | result.is_err(), |
| 632 | "FromJS should return Err for a plain JS object" |
| 633 | ); |
| 634 | Ok(()) |
| 635 | }); |
| 636 | } |
| 637 | |
| 638 | // ============================================================================= |
| 639 | // Wrap/template caching tests |
| 640 | // ============================================================================= |
| 641 | |
| 642 | /// Tests that wrapping multiple instances of the same type uses the same template. |
| 643 | /// |
| 644 | /// Verifies that `Realm::get_constructor()` caches the template on first use |
| 645 | /// and returns the same one on subsequent calls. |
| 646 | #[test] |
| 647 | fn wrap_multiple_instances_of_same_type() { |
| 648 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 649 | |
| 650 | let harness = crate::Harness::new(); |
| 651 | harness.run_in_context(|lock, ctx| { |
| 652 | let r1 = jsg::Rc::new(SimpleResource { |
| 653 | name: "first".to_owned(), |
| 654 | }); |
| 655 | let r2 = jsg::Rc::new(SimpleResource { |
| 656 | name: "second".to_owned(), |
| 657 | }); |
| 658 | |
| 659 | let w1 = r1.to_js(lock); |
| 660 | let w2 = r2.to_js(lock); |
| 661 | ctx.set_global("r1", w1); |
| 662 | ctx.set_global("r2", w2); |
| 663 | |
| 664 | // Both should be instances of the same constructor (same prototype chain) |
| 665 | let same_proto: bool = ctx |
| 666 | .eval( |
| 667 | lock, |
| 668 | "Object.getPrototypeOf(r1) === Object.getPrototypeOf(r2)", |
| 669 | ) |
| 670 | .unwrap(); |
| 671 | assert!(same_proto); |
| 672 | |
| 673 | // Both should work independently |
| 674 | let n1: String = ctx.eval(lock, "r1.getName()").unwrap(); |
| 675 | let n2: String = ctx.eval(lock, "r2.getName()").unwrap(); |
| 676 | assert_eq!(n1, "first"); |
| 677 | assert_eq!(n2, "second"); |
| 678 | Ok(()) |
| 679 | }); |
| 680 | } |
| 681 | |
| 682 | /// Tests that wrapping the same resource twice returns the same JS object. |
| 683 | /// |
| 684 | /// `Wrappable::tryGetHandle()` should detect an existing wrapper and return it. |
| 685 | #[test] |
| 686 | fn wrap_same_resource_twice_returns_same_object() { |
| 687 | let harness = crate::Harness::new(); |
| 688 | harness.run_in_context(|lock, ctx| { |
| 689 | let resource = jsg::Rc::new(SimpleResource { |
| 690 | name: "singleton".to_owned(), |
| 691 | }); |
| 692 | |
| 693 | let w1 = resource.clone().to_js(lock); |
| 694 | ctx.set_global("w1", w1); |
| 695 | let w2 = resource.to_js(lock); |
| 696 | ctx.set_global("w2", w2); |
| 697 | |
| 698 | // Both wraps should return the exact same JS object |
| 699 | let same: bool = ctx.eval(lock, "w1 === w2").unwrap(); |
| 700 | assert!(same); |
| 701 | Ok(()) |
| 702 | }); |
| 703 | } |
| 704 | |
| 705 | // ============================================================================= |
| 706 | // Resource data integrity tests |
| 707 | // ============================================================================= |
| 708 | |
| 709 | /// Tests that resource data survives a GC cycle when held by a JS global. |
| 710 | #[test] |
| 711 | fn resource_data_survives_gc_via_js_global() { |
| 712 | let harness = crate::Harness::new(); |
| 713 | harness.run_in_context(|lock, ctx| { |
| 714 | let resource = jsg::Rc::new(SimpleResource { |
| 715 | name: "survivor".to_owned(), |
| 716 | }); |
| 717 | let wrapped = resource.to_js(lock); |
| 718 | ctx.set_global("obj", wrapped); |
| 719 | |
| 720 | // Force GC — the global reference should keep it alive |
| 721 | crate::Harness::request_gc(lock); |
| 722 | |
| 723 | // Data should still be correct |
| 724 | let name: String = ctx.eval(lock, "obj.getName()").unwrap(); |
| 725 | assert_eq!(name, "survivor"); |
| 726 | Ok(()) |
| 727 | }); |
| 728 | } |
| 729 | |
| 730 | /// Tests parent-child relationships where parent is only held by JS. |
| 731 | /// |
| 732 | /// When the parent is wrapped and held by a JS global, its `Ref<SimpleResource>` child |
| 733 | /// should be traced during GC and kept alive even without any Rust strong refs. |
| 734 | #[test] |
| 735 | fn parent_ref_keeps_child_alive_through_gc() { |
| 736 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 737 | PARENT_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 738 | |
| 739 | let harness = crate::Harness::new(); |
| 740 | harness.run_in_context(|lock, ctx| { |
| 741 | let child = jsg::Rc::new(SimpleResource { |
| 742 | name: "child".to_owned(), |
| 743 | }); |
| 744 | let parent = jsg::Rc::new(ParentResource { |
| 745 | child: child.clone(), |
| 746 | optional_child: None, |
| 747 | }); |
| 748 | let wrapped = parent.clone().to_js(lock); |
| 749 | ctx.set_global("parent", wrapped); |
| 750 | |
| 751 | // Drop all Rust refs |
| 752 | std::mem::drop(child); |
| 753 | std::mem::drop(parent); |
| 754 | |
| 755 | // GC should NOT collect the parent (held by global) or child (held by parent's Ref) |
| 756 | crate::Harness::request_gc(lock); |
| 757 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 758 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 759 | Ok(()) |
| 760 | }); |
| 761 | |
| 762 | // New context — global is gone |
| 763 | harness.run_in_context(|lock, _ctx| { |
| 764 | crate::Harness::request_gc(lock); |
| 765 | assert_eq!(PARENT_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 766 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 767 | Ok(()) |
| 768 | }); |
| 769 | } |
| 770 | |
| 771 | /// Tests that dropping all strong refs correctly invalidates ALL weak refs. |
| 772 | /// |
| 773 | /// Creates multiple `WeakRef`s from different `Ref`s, verifies they all see death at the same time. |
| 774 | #[test] |
| 775 | fn instance_drop_invalidates_all_weak_refs() { |
| 776 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 777 | |
| 778 | let harness = crate::Harness::new(); |
| 779 | harness.run_in_context(|_lock, _ctx| { |
| 780 | let original = jsg::Rc::new(SimpleResource { |
| 781 | name: "shared-target".to_owned(), |
| 782 | }); |
| 783 | let clone1 = original.clone(); |
| 784 | let clone2 = original.clone(); |
| 785 | |
| 786 | // Create weak refs from different strong refs |
| 787 | let weak_from_original = original.downgrade(); |
| 788 | let weak_from_clone1 = clone1.downgrade(); |
| 789 | let weak_from_clone2 = clone2.downgrade(); |
| 790 | |
| 791 | // All alive |
| 792 | assert!(weak_from_original.is_alive()); |
| 793 | assert!(weak_from_clone1.is_alive()); |
| 794 | assert!(weak_from_clone2.is_alive()); |
| 795 | |
| 796 | // Drop all strong refs — resource is destroyed |
| 797 | std::mem::drop(original); |
| 798 | std::mem::drop(clone1); |
| 799 | std::mem::drop(clone2); |
| 800 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 801 | |
| 802 | // ALL weak refs see death |
| 803 | assert!(!weak_from_original.is_alive()); |
| 804 | assert!(!weak_from_clone1.is_alive()); |
| 805 | assert!(!weak_from_clone2.is_alive()); |
| 806 | assert!(weak_from_original.upgrade().is_none()); |
| 807 | assert!(weak_from_clone1.upgrade().is_none()); |
| 808 | assert!(weak_from_clone2.upgrade().is_none()); |
| 809 | Ok(()) |
| 810 | }); |
| 811 | } |
| 812 | |
| 813 | // ============================================================================= |
| 814 | // Nullable<Ref<T>> tracing tests |
| 815 | // ============================================================================= |
| 816 | |
| 817 | /// Counter to track how many `NullableParent` instances have been dropped. |
| 818 | static NULLABLE_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 819 | |
| 820 | #[jsg_resource] |
| 821 | struct NullableParent { |
| 822 | pub child: jsg::Nullable<jsg::Rc<SimpleResource>>, |
| 823 | } |
| 824 | |
| 825 | impl Drop for NullableParent { |
| 826 | fn drop(&mut self) { |
| 827 | NULLABLE_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); |
| 828 | } |
| 829 | } |
| 830 | |
| 831 | #[jsg_resource] |
| 832 | impl NullableParent {} |
| 833 | |
| 834 | /// Tests that `Nullable<Ref<T>>` with `Nullable::Some` keeps the child alive through GC tracing. |
| 835 | #[test] |
| 836 | fn nullable_ref_some_keeps_child_alive_through_gc() { |
| 837 | SIMPLE_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 838 | NULLABLE_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 839 | |
| 840 | let harness = crate::Harness::new(); |
| 841 | harness.run_in_context(|lock, ctx| { |
| 842 | let child = jsg::Rc::new(SimpleResource { |
| 843 | name: "nullable_child".to_owned(), |
| 844 | }); |
| 845 | let parent = jsg::Rc::new(NullableParent { |
| 846 | child: jsg::Nullable::Some(child.clone()), |
| 847 | }); |
| 848 | let wrapped = parent.clone().to_js(lock); |
| 849 | ctx.set_global("parent", wrapped); |
| 850 | |
| 851 | // Drop all Rust refs |
| 852 | std::mem::drop(child); |
| 853 | std::mem::drop(parent); |
| 854 | |
| 855 | // GC should NOT collect the parent (held by global) or child (traced via Nullable::Some) |
| 856 | crate::Harness::request_gc(lock); |
| 857 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 858 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 859 | Ok(()) |
| 860 | }); |
| 861 | |
| 862 | // New context — global is gone |
| 863 | harness.run_in_context(|lock, _ctx| { |
| 864 | crate::Harness::request_gc(lock); |
| 865 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 866 | assert_eq!(SIMPLE_RESOURCE_DROPS.load(Ordering::SeqCst), 1); |
| 867 | Ok(()) |
| 868 | }); |
| 869 | } |
| 870 | |
| 871 | /// Tests that `Nullable<Ref<T>>` with `Nullable::Null` doesn't cause issues during GC. |
| 872 | #[test] |
| 873 | fn nullable_ref_null_does_not_crash_during_gc() { |
| 874 | NULLABLE_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 875 | |
| 876 | let harness = crate::Harness::new(); |
| 877 | harness.run_in_context(|lock, ctx| { |
| 878 | let parent = jsg::Rc::new(NullableParent { |
| 879 | child: jsg::Nullable::Null, |
| 880 | }); |
| 881 | let wrapped = parent.clone().to_js(lock); |
| 882 | ctx.set_global("parent", wrapped); |
| 883 | |
| 884 | std::mem::drop(parent); |
| 885 | |
| 886 | // GC with Nullable::Null should not crash |
| 887 | crate::Harness::request_gc(lock); |
| 888 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 889 | Ok(()) |
| 890 | }); |
| 891 | |
| 892 | harness.run_in_context(|lock, _ctx| { |
| 893 | crate::Harness::request_gc(lock); |
| 894 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 895 | Ok(()) |
| 896 | }); |
| 897 | } |
| 898 | |
| 899 | /// Tests that `Nullable<Ref<T>>` with `Nullable::Undefined` doesn't cause issues during GC. |
| 900 | #[test] |
| 901 | fn nullable_ref_undefined_does_not_crash_during_gc() { |
| 902 | NULLABLE_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 903 | |
| 904 | let harness = crate::Harness::new(); |
| 905 | harness.run_in_context(|lock, ctx| { |
| 906 | let parent = jsg::Rc::new(NullableParent { |
| 907 | child: jsg::Nullable::Undefined, |
| 908 | }); |
| 909 | let wrapped = parent.clone().to_js(lock); |
| 910 | ctx.set_global("parent", wrapped); |
| 911 | |
| 912 | std::mem::drop(parent); |
| 913 | |
| 914 | // GC with Nullable::Undefined should not crash |
| 915 | crate::Harness::request_gc(lock); |
| 916 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 917 | Ok(()) |
| 918 | }); |
| 919 | |
| 920 | harness.run_in_context(|lock, _ctx| { |
| 921 | crate::Harness::request_gc(lock); |
| 922 | assert_eq!(NULLABLE_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 923 | Ok(()) |
| 924 | }); |
| 925 | } |
| 926 | |
| 927 | // --------------------------------------------------------------------------- |
| 928 | // Rc<NativeState> shared ownership — native object outlives Ref but is |
| 929 | // dropped when V8 GC collects the JS wrapper. |
| 930 | // --------------------------------------------------------------------------- |
| 931 | |
| 932 | static NATIVE_STATE_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 933 | |
| 934 | struct NativeState { |
| 935 | value: u64, |
| 936 | } |
| 937 | |
| 938 | impl jsg::Traced for NativeState {} |
| 939 | |
| 940 | impl Drop for NativeState { |
| 941 | fn drop(&mut self) { |
| 942 | NATIVE_STATE_DROPS.fetch_add(1, Ordering::SeqCst); |
| 943 | } |
| 944 | } |
| 945 | |
| 946 | #[jsg_resource] |
| 947 | struct NativeOwnerResource { |
| 948 | state: std::rc::Rc<NativeState>, |
| 949 | } |
| 950 | |
| 951 | #[jsg_resource] |
| 952 | impl NativeOwnerResource { |
| 953 | #[jsg_method] |
| 954 | fn get_value(&self) -> jsg::Number { |
| 955 | #[expect(clippy::cast_precision_loss)] |
| 956 | jsg::Number::new(self.state.value as f64) |
| 957 | } |
| 958 | } |
| 959 | |
| 960 | /// Regression test for the `strong` flag bug in `wrappable_remove_strong_ref`. |
| 961 | /// |
| 962 | /// When a parent resource (with a JS wrapper) holds a `Ref<Child>`, GC tracing |
| 963 | /// transitions the child's ref from strong→weak via `visitRef`, which calls |
| 964 | /// `removeStrongRef()` once. |
| 965 | /// |
| 966 | /// **Bug**: `wrappable_remove_strong_ref` always passed `strong=true` to |
| 967 | /// `maybeDeferDestruction`, so when the parent was later collected and the |
| 968 | /// child `Ref` dropped, `~RefToDelete` called `removeStrongRef()` a second |
| 969 | /// time — underflowing the strong refcount. |
| 970 | /// |
| 971 | /// This test keeps a direct Rust ref to the child alive so we can observe |
| 972 | /// the strong refcount after the parent's traced ref is dropped by GC. |
| 973 | /// With the bug: `strong_refcount()` returns 0 instead of 1. |
| 974 | #[test] |
| 975 | #[cfg(debug_assertions)] |
| 976 | fn traced_ref_drop_respects_strong_flag() { |
| 977 | let harness = crate::Harness::new(); |
| 978 | let mut child_holder: Option<jsg::Rc<SimpleResource>> = None; |
| 979 | |
| 980 | harness.run_in_context(|lock, _ctx| { |
| 981 | let child = jsg::Rc::new(SimpleResource { |
| 982 | name: "traced_child".to_owned(), |
| 983 | }); |
| 984 | |
| 985 | // strongRefcount = 1 (our ref). |
| 986 | assert_eq!(child.strong_refcount(), 1); |
| 987 | |
| 988 | let parent = jsg::Rc::new(ParentResource { |
| 989 | child: child.clone(), |
| 990 | optional_child: None, |
| 991 | }); |
| 992 | |
| 993 | // strongRefcount = 2 (our ref + parent's ref). |
| 994 | assert_eq!(child.strong_refcount(), 2); |
| 995 | |
| 996 | // Wrap the parent — child is reachable only through the parent's Ref. |
| 997 | let _wrapped = parent.clone().to_js(lock); |
| 998 | |
| 999 | // GC traces parent wrapper → visitRef on child → parent's ref |
| 1000 | // transitions strong→weak, removeStrongRef() called once. |
| 1001 | // strongRefcount = 1 (only our direct ref remains strong). |
| 1002 | crate::Harness::request_gc(lock); |
| 1003 | assert_eq!(child.strong_refcount(), 1); |
| 1004 | |
| 1005 | // Drop parent Rust ref. JS wrapper keeps parent alive. |
| 1006 | std::mem::drop(parent); |
| 1007 | |
| 1008 | // Move child out so we can observe it after GC collects the parent. |
| 1009 | child_holder = Some(child); |
| 1010 | Ok(()) |
| 1011 | }); |
| 1012 | |
| 1013 | let child = child_holder.expect("child not set"); |
| 1014 | |
| 1015 | // Second GC: parent's JS wrapper is unreachable → parent collected → |
| 1016 | // parent's child Ref drops → wrappable_remove_strong_ref called. |
| 1017 | // |
| 1018 | // With the bug: parent's ref has strong=false but remove_strong_ref |
| 1019 | // always passes true → removeStrongRef() called → strongRefcount |
| 1020 | // goes from 1 to 0, but our ref is still strong → should be 1! |
| 1021 | // |
| 1022 | // With the fix: strong=false is threaded through → no removeStrongRef |
| 1023 | // call → strongRefcount stays at 1 (correct). |
| 1024 | harness.run_in_context(|lock, _ctx| { |
| 1025 | crate::Harness::request_gc(lock); |
| 1026 | assert_eq!( |
| 1027 | child.strong_refcount(), |
| 1028 | 1, |
| 1029 | "strong refcount underflowed — \ |
| 1030 | wrappable_remove_strong_ref passed strong=true for a traced (weak) ref" |
| 1031 | ); |
| 1032 | Ok(()) |
| 1033 | }); |
| 1034 | } |
| 1035 | |
| 1036 | /// A Rust resource holds an `Rc<NativeState>`. After wrapping for JS and |
| 1037 | /// dropping the Rust `Ref`, the native state is kept alive by the `CppgcShim`. |
| 1038 | /// Minor (young-generation) GC must collect the wrapper and drop the native |
| 1039 | /// state — proving the `ResetRoot` / `detachLater` path works for Rust resources. |
| 1040 | #[test] |
| 1041 | fn rc_native_object_dropped_on_minor_gc() { |
| 1042 | NATIVE_STATE_DROPS.store(0, Ordering::SeqCst); |
| 1043 | |
| 1044 | let harness = crate::Harness::new(); |
| 1045 | let state = std::rc::Rc::new(NativeState { value: 99 }); |
| 1046 | let state_clone = state.clone(); |
| 1047 | |
| 1048 | harness.run_in_context(|lock, _ctx| { |
| 1049 | let resource = jsg::Rc::new(NativeOwnerResource { state: state_clone }); |
| 1050 | |
| 1051 | // Wrap for JS so CppgcShim takes ownership of the Wrappable. |
| 1052 | let _wrapped = resource.clone().to_js(lock); |
| 1053 | |
| 1054 | // Drop the Rust Ref. CppgcShim still keeps the resource alive. |
| 1055 | std::mem::drop(resource); |
| 1056 | assert_eq!(NATIVE_STATE_DROPS.load(Ordering::SeqCst), 0); |
| 1057 | Ok(()) |
| 1058 | }); |
| 1059 | |
| 1060 | // Drop the external Rc clone. The resource's Rc inside the Wrappable |
| 1061 | // is the last one, but the Wrappable is alive (held by CppgcShim). |
| 1062 | std::mem::drop(state); |
| 1063 | assert_eq!(NATIVE_STATE_DROPS.load(Ordering::SeqCst), 0); |
| 1064 | |
| 1065 | // Minor GC collects the young-generation wrapper via the ResetRoot path, |
| 1066 | // which detaches the CppgcShim → drops kj::Own<Wrappable> → drops the |
| 1067 | // resource → drops Rc<NativeState> → NativeState is dropped. |
| 1068 | harness.run_in_context(|lock, _ctx| { |
| 1069 | crate::Harness::request_minor_gc(lock); |
| 1070 | assert_eq!(NATIVE_STATE_DROPS.load(Ordering::SeqCst), 1); |
| 1071 | Ok(()) |
| 1072 | }); |
| 1073 | } |
| 1074 | |
| 1075 | // ============================================================================= |
| 1076 | // Global<Value> back-reference cycle — collected via visit_global tracing |
| 1077 | // ============================================================================= |
| 1078 | // |
| 1079 | // `jsg::v8::Global<T>` fields on Rust resources participate in GC tracing via |
| 1080 | // `GcVisitor::visit_global`, which implements the same strong↔traced dual-mode |
| 1081 | // switching as `jsg::Data` / `jsg::V8Ref<T>` in C++. |
| 1082 | // |
| 1083 | // When the parent Wrappable has strong Rust refs the handle stays strong. |
| 1084 | // Once all Rust refs are dropped and only the JS wrapper keeps it alive, |
| 1085 | // `visit_global` downgrades the handle to a `v8::TracedReference` that cppgc |
| 1086 | // can follow — allowing the GC to detect and collect the cycle. |
| 1087 | |
| 1088 | static CYCLIC_RESOURCE_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 1089 | |
| 1090 | /// A resource that stores a `Global<Value>` via `Cell` so the back-reference |
| 1091 | /// to the resource's own JS wrapper can be installed after wrapping. |
| 1092 | /// |
| 1093 | /// `Cell<Option<…>>` provides interior mutability through `&self`, matching |
| 1094 | /// the access pattern of `Traced::trace(&self)`. |
| 1095 | #[jsg_resource] |
| 1096 | struct CyclicResource { |
| 1097 | /// The stored "callback". Uses `Cell` so it can be set after `to_js()` |
| 1098 | /// returns the wrapper `Local`, without requiring `&mut self`. |
| 1099 | callback: std::cell::Cell<Option<jsg::v8::Global<jsg::v8::Value>>>, |
| 1100 | } |
| 1101 | |
| 1102 | impl Drop for CyclicResource { |
| 1103 | fn drop(&mut self) { |
| 1104 | CYCLIC_RESOURCE_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1105 | } |
| 1106 | } |
| 1107 | |
| 1108 | #[jsg_resource] |
| 1109 | impl CyclicResource {} |
| 1110 | |
| 1111 | /// Verifies that a `Global<Value>` back-reference cycle is collected by GC. |
| 1112 | /// |
| 1113 | /// The cycle: |
| 1114 | /// CyclicResource.callback (Global<Value>) → JS wrapper |
| 1115 | /// JS wrapper → `CppgcShim` → `Wrappable` → `CyclicResource` (same object) |
| 1116 | /// |
| 1117 | /// `visit_global` downgrades the `Global` to a `v8::TracedReference` once all |
| 1118 | /// strong Rust refs are dropped, making the cycle visible to cppgc so it can |
| 1119 | /// be collected on the next full GC. |
| 1120 | #[test] |
| 1121 | fn global_value_back_ref_is_collected_by_gc() { |
| 1122 | CYCLIC_RESOURCE_DROPS.store(0, Ordering::SeqCst); |
| 1123 | |
| 1124 | let harness = crate::Harness::new(); |
| 1125 | harness.run_in_context(|lock, _ctx| { |
| 1126 | let resource = jsg::Rc::new(CyclicResource { |
| 1127 | callback: std::cell::Cell::new(None), |
| 1128 | }); |
| 1129 | |
| 1130 | // Wrap to produce the JS object, then immediately promote to a |
| 1131 | // Global so we can store it back into the resource's own field. |
| 1132 | // `to_js` consumes the Rc clone but the original `resource` still |
| 1133 | // holds a strong ref, so the resource stays alive. |
| 1134 | let wrapper_local = resource.clone().to_js(lock); |
| 1135 | let wrapper_global = wrapper_local.to_global(lock); |
| 1136 | |
| 1137 | // Install the back-reference: resource now holds a Global pointing |
| 1138 | // to its own JS wrapper, closing the cycle. |
| 1139 | resource.callback.set(Some(wrapper_global)); |
| 1140 | |
| 1141 | // Drop the only Rust Rc. The cycle is now closed but the Global |
| 1142 | // will be downgraded to a TracedReference by visit_global, making |
| 1143 | // it visible to cppgc. |
| 1144 | std::mem::drop(resource); |
| 1145 | assert_eq!(CYCLIC_RESOURCE_DROPS.load(Ordering::SeqCst), 0); |
| 1146 | Ok(()) |
| 1147 | }); |
| 1148 | |
| 1149 | // Full GC can now detect and collect the cycle because visit_global |
| 1150 | // downgrades the Global to a TracedReference during tracing. |
| 1151 | harness.run_in_context(|lock, _ctx| { |
| 1152 | crate::Harness::request_gc(lock); |
| 1153 | assert_eq!( |
| 1154 | CYCLIC_RESOURCE_DROPS.load(Ordering::SeqCst), |
| 1155 | 1, |
| 1156 | "full GC should collect the cyclic resource via visit_global tracing" |
| 1157 | ); |
| 1158 | Ok(()) |
| 1159 | }); |
| 1160 | } |
| 1161 | |
| 1162 | // ============================================================================= |
| 1163 | // Cell<T> tracing tests |
| 1164 | // ============================================================================= |
| 1165 | |
| 1166 | static CELL_CHILD_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 1167 | |
| 1168 | #[jsg_resource] |
| 1169 | struct CellChild; |
| 1170 | |
| 1171 | impl Drop for CellChild { |
| 1172 | fn drop(&mut self) { |
| 1173 | CELL_CHILD_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1174 | } |
| 1175 | } |
| 1176 | |
| 1177 | #[jsg_resource] |
| 1178 | impl CellChild {} |
| 1179 | |
| 1180 | static CELL_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); |
| 1181 | |
| 1182 | /// Resource with a `Cell<jsg::Rc<T>>` field. |
| 1183 | #[jsg_resource] |
| 1184 | struct CellParent { |
| 1185 | pub child: Cell<jsg::Rc<CellChild>>, |
| 1186 | } |
| 1187 | |
| 1188 | impl Drop for CellParent { |
| 1189 | fn drop(&mut self) { |
| 1190 | CELL_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1191 | } |
| 1192 | } |
| 1193 | |
| 1194 | #[jsg_resource] |
| 1195 | impl CellParent {} |
| 1196 | |
| 1197 | /// Resource with a `Cell<Option<jsg::Rc<T>>>` field. |
| 1198 | #[jsg_resource] |
| 1199 | struct CellOptionParent { |
| 1200 | pub child: Cell<Option<jsg::Rc<CellChild>>>, |
| 1201 | } |
| 1202 | |
| 1203 | impl Drop for CellOptionParent { |
| 1204 | fn drop(&mut self) { |
| 1205 | CELL_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1206 | } |
| 1207 | } |
| 1208 | |
| 1209 | #[jsg_resource] |
| 1210 | impl CellOptionParent {} |
| 1211 | |
| 1212 | /// Resource with a `Cell<jsg::Nullable<jsg::Rc<T>>>` field. |
| 1213 | #[jsg_resource] |
| 1214 | struct CellNullableParent { |
| 1215 | pub child: Cell<jsg::Nullable<jsg::Rc<CellChild>>>, |
| 1216 | } |
| 1217 | |
| 1218 | impl Drop for CellNullableParent { |
| 1219 | fn drop(&mut self) { |
| 1220 | CELL_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1221 | } |
| 1222 | } |
| 1223 | |
| 1224 | #[jsg_resource] |
| 1225 | impl CellNullableParent {} |
| 1226 | |
| 1227 | /// Resource using the fully-qualified `std::cell::Cell<jsg::Rc<T>>` syntax. |
| 1228 | #[jsg_resource] |
| 1229 | struct StdCellParent { |
| 1230 | pub child: std::cell::Cell<jsg::Rc<CellChild>>, |
| 1231 | } |
| 1232 | |
| 1233 | impl Drop for StdCellParent { |
| 1234 | fn drop(&mut self) { |
| 1235 | CELL_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); |
| 1236 | } |
| 1237 | } |
| 1238 | |
| 1239 | #[jsg_resource] |
| 1240 | impl StdCellParent {} |
| 1241 | |
| 1242 | /// `Cell<jsg::Rc<T>>` keeps the child alive through GC. |
| 1243 | #[test] |
| 1244 | fn cell_ref_keeps_child_alive_through_gc() { |
| 1245 | CELL_CHILD_DROPS.store(0, Ordering::SeqCst); |
| 1246 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1247 | |
| 1248 | let harness = crate::Harness::new(); |
| 1249 | harness.run_in_context(|lock, ctx| { |
| 1250 | let child = jsg::Rc::new(CellChild); |
| 1251 | let parent = jsg::Rc::new(CellParent { |
| 1252 | child: Cell::new(child.clone()), |
| 1253 | }); |
| 1254 | let wrapped = parent.clone().to_js(lock); |
| 1255 | ctx.set_global("parent", wrapped); |
| 1256 | |
| 1257 | // Drop all Rust refs. |
| 1258 | std::mem::drop(child); |
| 1259 | std::mem::drop(parent); |
| 1260 | |
| 1261 | // Parent is JS-global-held; child is kept alive via Cell<Rc<T>> tracing. |
| 1262 | crate::Harness::request_gc(lock); |
| 1263 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1264 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 0); |
| 1265 | Ok(()) |
| 1266 | }); |
| 1267 | |
| 1268 | harness.run_in_context(|lock, _ctx| { |
| 1269 | crate::Harness::request_gc(lock); |
| 1270 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1271 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 1); |
| 1272 | Ok(()) |
| 1273 | }); |
| 1274 | } |
| 1275 | |
| 1276 | /// `Cell<Option<jsg::Rc<T>>>` with `Some` keeps the child alive through GC. |
| 1277 | #[test] |
| 1278 | fn cell_option_ref_some_keeps_child_alive_through_gc() { |
| 1279 | CELL_CHILD_DROPS.store(0, Ordering::SeqCst); |
| 1280 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1281 | |
| 1282 | let harness = crate::Harness::new(); |
| 1283 | harness.run_in_context(|lock, ctx| { |
| 1284 | let child = jsg::Rc::new(CellChild); |
| 1285 | let parent = jsg::Rc::new(CellOptionParent { |
| 1286 | child: Cell::new(Some(child.clone())), |
| 1287 | }); |
| 1288 | let wrapped = parent.clone().to_js(lock); |
| 1289 | ctx.set_global("parent", wrapped); |
| 1290 | |
| 1291 | std::mem::drop(child); |
| 1292 | std::mem::drop(parent); |
| 1293 | |
| 1294 | crate::Harness::request_gc(lock); |
| 1295 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1296 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 0); |
| 1297 | Ok(()) |
| 1298 | }); |
| 1299 | |
| 1300 | harness.run_in_context(|lock, _ctx| { |
| 1301 | crate::Harness::request_gc(lock); |
| 1302 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1303 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 1); |
| 1304 | Ok(()) |
| 1305 | }); |
| 1306 | } |
| 1307 | |
| 1308 | /// `Cell<Option<jsg::Rc<T>>>` with `None` does not crash during GC. |
| 1309 | #[test] |
| 1310 | fn cell_option_ref_none_does_not_crash_during_gc() { |
| 1311 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1312 | |
| 1313 | let harness = crate::Harness::new(); |
| 1314 | harness.run_in_context(|lock, ctx| { |
| 1315 | let parent = jsg::Rc::new(CellOptionParent { |
| 1316 | child: Cell::new(None), |
| 1317 | }); |
| 1318 | let wrapped = parent.clone().to_js(lock); |
| 1319 | ctx.set_global("parent", wrapped); |
| 1320 | |
| 1321 | std::mem::drop(parent); |
| 1322 | |
| 1323 | crate::Harness::request_gc(lock); |
| 1324 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1325 | Ok(()) |
| 1326 | }); |
| 1327 | |
| 1328 | harness.run_in_context(|lock, _ctx| { |
| 1329 | crate::Harness::request_gc(lock); |
| 1330 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1331 | Ok(()) |
| 1332 | }); |
| 1333 | } |
| 1334 | |
| 1335 | /// `Cell<jsg::Nullable<jsg::Rc<T>>>` with `Nullable::Some` keeps child alive. |
| 1336 | #[test] |
| 1337 | fn cell_nullable_ref_some_keeps_child_alive_through_gc() { |
| 1338 | CELL_CHILD_DROPS.store(0, Ordering::SeqCst); |
| 1339 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1340 | |
| 1341 | let harness = crate::Harness::new(); |
| 1342 | harness.run_in_context(|lock, ctx| { |
| 1343 | let child = jsg::Rc::new(CellChild); |
| 1344 | let parent = jsg::Rc::new(CellNullableParent { |
| 1345 | child: Cell::new(jsg::Nullable::Some(child.clone())), |
| 1346 | }); |
| 1347 | let wrapped = parent.clone().to_js(lock); |
| 1348 | ctx.set_global("parent", wrapped); |
| 1349 | |
| 1350 | std::mem::drop(child); |
| 1351 | std::mem::drop(parent); |
| 1352 | |
| 1353 | crate::Harness::request_gc(lock); |
| 1354 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1355 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 0); |
| 1356 | Ok(()) |
| 1357 | }); |
| 1358 | |
| 1359 | harness.run_in_context(|lock, _ctx| { |
| 1360 | crate::Harness::request_gc(lock); |
| 1361 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1362 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 1); |
| 1363 | Ok(()) |
| 1364 | }); |
| 1365 | } |
| 1366 | |
| 1367 | /// `Cell<jsg::Nullable<jsg::Rc<T>>>` with `Nullable::Null` does not crash during GC. |
| 1368 | #[test] |
| 1369 | fn cell_nullable_ref_null_does_not_crash_during_gc() { |
| 1370 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1371 | |
| 1372 | let harness = crate::Harness::new(); |
| 1373 | harness.run_in_context(|lock, ctx| { |
| 1374 | let parent = jsg::Rc::new(CellNullableParent { |
| 1375 | child: Cell::new(jsg::Nullable::Null), |
| 1376 | }); |
| 1377 | let wrapped = parent.clone().to_js(lock); |
| 1378 | ctx.set_global("parent", wrapped); |
| 1379 | |
| 1380 | std::mem::drop(parent); |
| 1381 | |
| 1382 | crate::Harness::request_gc(lock); |
| 1383 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1384 | Ok(()) |
| 1385 | }); |
| 1386 | |
| 1387 | harness.run_in_context(|lock, _ctx| { |
| 1388 | crate::Harness::request_gc(lock); |
| 1389 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1390 | Ok(()) |
| 1391 | }); |
| 1392 | } |
| 1393 | |
| 1394 | /// `std::cell::Cell<jsg::Rc<T>>` (fully-qualified path) keeps child alive through GC. |
| 1395 | #[test] |
| 1396 | fn std_cell_ref_keeps_child_alive_through_gc() { |
| 1397 | CELL_CHILD_DROPS.store(0, Ordering::SeqCst); |
| 1398 | CELL_PARENT_DROPS.store(0, Ordering::SeqCst); |
| 1399 | |
| 1400 | let harness = crate::Harness::new(); |
| 1401 | harness.run_in_context(|lock, ctx| { |
| 1402 | let child = jsg::Rc::new(CellChild); |
| 1403 | let parent = jsg::Rc::new(StdCellParent { |
| 1404 | child: std::cell::Cell::new(child.clone()), |
| 1405 | }); |
| 1406 | let wrapped = parent.clone().to_js(lock); |
| 1407 | ctx.set_global("parent", wrapped); |
| 1408 | |
| 1409 | std::mem::drop(child); |
| 1410 | std::mem::drop(parent); |
| 1411 | |
| 1412 | crate::Harness::request_gc(lock); |
| 1413 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 0); |
| 1414 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 0); |
| 1415 | Ok(()) |
| 1416 | }); |
| 1417 | |
| 1418 | harness.run_in_context(|lock, _ctx| { |
| 1419 | crate::Harness::request_gc(lock); |
| 1420 | assert_eq!(CELL_PARENT_DROPS.load(Ordering::SeqCst), 1); |
| 1421 | assert_eq!(CELL_CHILD_DROPS.load(Ordering::SeqCst), 1); |
| 1422 | Ok(()) |
| 1423 | }); |
| 1424 | } |