Skip to content

Commit f027730

Browse files
committed
Revert "Move wrapper handle to shim" and add a regression test
This reverts commit 2d08fd1.
1 parent 8352890 commit f027730

8 files changed

Lines changed: 366 additions & 420 deletions

File tree

docs/jsg.md

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2241,31 +2241,25 @@ if (jsg::HeapTracer::isInCppgcDestructor()) {
22412241
### Wrapper Lifecycle
22422242

22432243
```
2244-
1. Wrappable created (no JS wrapper yet)
2244+
1. C++ object created (no JS wrapper yet)
22452245
|
2246-
2. Wrappable passed to JavaScript
2246+
2. Object passed to JavaScript
22472247
|
2248-
3. attachWrapper() attaches the JS wrapper, allocating a cppgc shim
2249-
(or reusing one from the freelist)
2248+
3. attachWrapper() creates JS wrapper
22502249
|
2251-
4. wrapper -> shim -> Wrappable:
2252-
- the wrapper keeps the shim alive, via V8's CppHeap pointer table
2253-
- the shim holds a strong kj::Own<Wrappable>
2254-
- so the Wrappable cannot be destroyed while a wrapper exists
2250+
4. JS wrapper and C++ object linked
22552251
|
2256-
5. GC may collect the wrapper if:
2252+
5. GC may collect wrapper if:
22572253
- No JS references exist
2258-
- No strong Ref<T>s exist (one would root the wrapper)
2254+
- No strong Ref<T>s exist
22592255
- Wrapper is "unmodified"
22602256
|
2261-
6. detachWrapper() runs when the wrapper goes away, from:
2262-
- ~CppgcShim, after a major GC collected the wrapper
2263-
- ResetRoot(), when V8 drops an unmodified droppable wrapper
2264-
- clearWrappers(), at isolate shutdown
2265-
It releases the shim's reference to the Wrappable.
2257+
6. If wrapper collected but C++ object still alive:
2258+
- New wrapper created on next JS access
22662259
|
2267-
7. If other C++ references remain, the Wrappable lives on and a new
2268-
wrapper is created on the next JS access. Otherwise it is destroyed.
2260+
7. When C++ object destroyed:
2261+
- detachWrapper() called
2262+
- JS wrapper becomes empty shell
22692263
```
22702264

22712265
### Async Destructor Safety

src/workerd/jsg/condemned-wrapper-test.c++

Lines changed: 0 additions & 162 deletions
This file was deleted.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
#include "jsg-test.h"
6+
7+
namespace workerd::jsg::test {
8+
namespace {
9+
10+
// This test covers a shutdown failure involving a wrapped parent that owns an unwrapped child.
11+
// RefHolder is returned to JavaScript and therefore has a JS wrapper and a cppgc shim. Child is
12+
// never exposed to JavaScript, so it has no wrapper of its own, but tracing RefHolder propagates
13+
// the isolate pointer to it through RefHolder::visitForGc().
14+
//
15+
// A major GC can condemn RefHolder's wrapper while leaving its cppgc finalizer pending. Isolate
16+
// teardown must finalize RefHolder while the isolate lock is held so that releasing its Ref<Child>
17+
// can destroy Child immediately. If RefHolder instead remains owned by its condemned shim until
18+
// v8::Isolate::Dispose(), its finalizer runs after the deferred-destruction queue has transitioned
19+
// to DROPPED. Child still remembers the isolate and sees that the isolate lock is not held, so it
20+
// tries to enqueue its destruction and fails the queueState == ACTIVE requirement.
21+
//
22+
// Production stacks reached the same path through container objects such as Response and R2
23+
// GetResult releasing ReadableStream references from CppgcShim::~CppgcShim().
24+
V8System v8System({"--expose-gc"_kj});
25+
26+
class ContextGlobalObject: public Object, public ContextGlobal {};
27+
28+
uint childDestructions = 0;
29+
uint holderDestructions = 0;
30+
31+
class Child final: public Object {
32+
public:
33+
~Child() noexcept(false) {
34+
++childDestructions;
35+
}
36+
37+
JSG_RESOURCE_TYPE(Child) {}
38+
};
39+
40+
class RefHolder final: public Object {
41+
public:
42+
explicit RefHolder(Ref<Child> child): child(kj::mv(child)) {}
43+
44+
~RefHolder() noexcept(false) {
45+
++holderDestructions;
46+
}
47+
48+
void visitForGc(GcVisitor& visitor) {
49+
visitor.visit(child);
50+
}
51+
52+
JSG_RESOURCE_TYPE(RefHolder) {}
53+
54+
private:
55+
Ref<Child> child;
56+
};
57+
58+
struct ShutdownContext: public ContextGlobalObject {
59+
Ref<RefHolder> makeHolder(Lock& js) {
60+
return js.alloc<RefHolder>(js.alloc<Child>());
61+
}
62+
63+
JSG_RESOURCE_TYPE(ShutdownContext) {
64+
JSG_NESTED_TYPE(Child);
65+
JSG_NESTED_TYPE(RefHolder);
66+
JSG_METHOD(makeHolder);
67+
}
68+
};
69+
JSG_DECLARE_ISOLATE_TYPE(ShutdownIsolate, ShutdownContext, Child, RefHolder);
70+
71+
KJ_TEST("isolate shutdown finalizes condemned wrappers containing unwrapped children") {
72+
setPredictableModeForTest();
73+
childDestructions = 0;
74+
holderDestructions = 0;
75+
76+
{
77+
ShutdownIsolate isolate(v8System, kj::heap<IsolateObserver>());
78+
isolate.runInLockScope([&](ShutdownIsolate::Lock& lock) {
79+
JSG_WITHIN_CONTEXT_SCOPE(
80+
lock, lock.newContext<ShutdownContext>().getHandle(lock), [&](Lock& js) {
81+
js.withinHandleScope([&] {
82+
// Drop the only JS reference while the nested handle scope ensures no temporary V8
83+
// handle keeps RefHolder's wrapper alive.
84+
auto source = "let holder = makeHolder(); holder = null;"_kj;
85+
auto script = check(v8::Script::Compile(js.v8Context(), js.str(source)));
86+
check(script->Run(js.v8Context()));
87+
});
88+
89+
// Forced test GCs normally sweep atomically. Leave sweeping pending to reproduce the state
90+
// created by a natural major GC immediately before isolate shutdown.
91+
js.requestGcWithDeferredSweepForTesting();
92+
// Prove that neither cppgc finalization nor child destruction happened during the GC. This
93+
// keeps the test focused on shutdown rather than the ordinary atomic-sweep path.
94+
KJ_ASSERT(holderDestructions == 0, holderDestructions);
95+
KJ_ASSERT(childDestructions == 0, childDestructions);
96+
});
97+
});
98+
99+
// The isolate is destroyed with RefHolder's cppgc finalizer still pending. Isolate teardown
100+
// must finalize the holder and release its unwrapped child safely.
101+
}
102+
103+
KJ_ASSERT(holderDestructions == 1, holderDestructions);
104+
KJ_ASSERT(childDestructions == 1, childDestructions);
105+
}
106+
107+
} // namespace
108+
} // namespace workerd::jsg::test

src/workerd/jsg/jsg.h

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,7 +1668,7 @@ Ref<T> _jsgThis(T* obj) {
16681668
// use-after-free.
16691669
//
16701670
// - tryAddRef(js) answers "is the object still usable from JS?". It requires the isolate
1671-
// lock and returns kj::none for condemned objects (see Wrappable::isCondemned()).
1671+
// lock and returns kj::none for condemned objects (see Wrappable::wasTracedInLastGc()).
16721672
// Any JS-facing work through a WeakRef must go through tryAddRef().
16731673
//
16741674
// Use operator->() for convenient single-expression access that asserts liveness:
@@ -1772,7 +1772,7 @@ class WeakRef {
17721772

17731773
// Try to promote to a strong Ref<T>. Returns kj::none if the target has been destroyed,
17741774
// or if the target's V8 wrapper died in a major GC whose deferred cleanup has not yet
1775-
// released the target (detected via Wrappable::isCondemned();
1775+
// released the target (detected via the GC epoch check in Wrappable::wasTracedInLastGc();
17761776
// see the implementation in setup.h). In the latter case the target is condemned and this
17771777
// WeakRef is permanently invalidated.
17781778
kj::Maybe<Ref<T>> tryAddRef(Lock&) const;
@@ -3115,16 +3115,13 @@ class Lock {
31153115
void requestGcForTesting() const;
31163116

31173117
// Like requestGcForTesting(), but leaves cppgc's sweep pending rather than running it inside the
3118-
// collection. On return, wrappers unreachable at the start of the GC have been collected and
3119-
// their Wrappables condemned (see Wrappable::isCondemned()), but the ~CppgcShim that releases
3120-
// each Wrappable has not run yet. This is the state a natural major GC leaves behind, and the
3121-
// only state in which the condemned-wrapper hazard is observable.
3118+
// collection. This reproduces the delayed finalization performed by a natural major GC.
31223119
//
31233120
// Pair with finishDeferredSweepForTesting() to close the window. Testing only.
31243121
void requestGcWithDeferredSweepForTesting() const;
31253122

31263123
// Completes a sweep left pending by requestGcWithDeferredSweepForTesting(), running the deferred
3127-
// ~CppgcShim finalizers. Testing only.
3124+
// cppgc finalizers. Testing only.
31283125
void finishDeferredSweepForTesting() const;
31293126

31303127
// Runs the given function synchronously with a v8::HandleScope on the stack.

0 commit comments

Comments
 (0)