From c1d7f99f78bed9182a8474a2efb496ee6587cc52 Mon Sep 17 00:00:00 2001 From: Jordan Baczuk Date: Wed, 16 Sep 2026 16:06:02 -0600 Subject: [PATCH] fix: reset the handle when a suspended fiber is garbage-collected on V8 >= 10.4 f3cbba6 ("Make compatible with v20") registers the Fiber weak callback with WeakCallbackType::kParameter because V8 removed kFinalizer. The two types have different contracts: kFinalizer ran before the object was reclaimed and let the callback resurrect it, which Fiber::WeakCallback relied on for suspended fibers (ClearWeak(), unwind later in DestroyOrphans, MakeWeak() again). kParameter is a phantom callback: the object is already gone and V8 CHECKs that the callback reset its handle ("Handle not reset in first callback", global-handles.cc), so a yielded fiber whose JS object becomes unreachable aborted the process on node 20+: # Fatal error in , line 0 # Check failed: Handle not reset in first callback. See comments on |v8::WeakCallbackInfo|. On V8 >= 10.4 the orphan branch now resets the handle in the callback and DestroyOrphans deletes the fiber after unwinding it instead of re-weakening a handle that no longer exists. MakeWeak(), ClearWeak() and the Fiber.current getter tolerate the empty handle, which Fiber::Yield_ and JS code in the zombie's catch/finally blocks hit while the stack unwinds (the first version without the guards segfaulted in GlobalHandles::ClearWeakness). The node 18 (V8 10.2) code path is unchanged. test/orphan-gc.js garbage-collects 200 yielded fibers, forces DestroyOrphans and checks every fiber was unwound; it aborts on the unpatched build and passes here on node 24.21.0 (patched custom-v24, ucontext) together with the other 19 tests, and passes on node 18.16.1 with fibers 5.0.4. Co-Authored-By: Claude Fable 5.1 --- src/fibers.cc | 17 ++++++++++++++++- test/orphan-gc.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 test/orphan-gc.js diff --git a/src/fibers.cc b/src/fibers.cc index 1ff872a..cf86a51 100644 --- a/src/fibers.cc +++ b/src/fibers.cc @@ -480,6 +480,7 @@ class Fiber { * i.e. After fiber completes, while yielded, or before started */ void MakeWeak() { + if (handle.IsEmpty()) return; // garbage-collected fiber being unwound as a zombie uni::MakeWeak(isolate, handle, (void*)this); } @@ -488,6 +489,7 @@ class Fiber { * i.e. While running. */ void ClearWeak() { + if (handle.IsEmpty()) return; // see MakeWeak() handle.ClearWeak(); } @@ -508,7 +510,14 @@ class Fiber { if (that.started) { assert(that.yielding); orphaned_fibers.push_back(&that); +#if V8_AT_LEAST(10, 4) + // kParameter is a phantom callback: V8 has already reclaimed the JS object and CHECKs that + // the handle was reset before this callback returns ("Handle not reset in first callback"). + // The fiber is unwound and deleted by DestroyOrphans; there is no JS object to hand back. + uni::Dispose(that.isolate, that.handle); +#else that.ClearWeak(); +#endif return; } @@ -550,7 +559,12 @@ class Fiber { } uni::Dispose(that.isolate, that.yielded); +#if V8_AT_LEAST(10, 4) + // The JS object is gone (see WeakCallback); nothing can reference this fiber again. + delete &that; +#else that.MakeWeak(); +#endif } } @@ -851,7 +865,8 @@ class Fiber { } static uni::FunctionType GetCurrent(Local property, const uni::GetterCallbackInfo& info) { - if (current) { + if (current && !current->handle.IsEmpty()) { + // The handle is empty while a garbage-collected fiber is being unwound as a zombie. return uni::Return(current->handle, info); } else { return uni::Return(uni::Undefined(Isolate::GetCurrent()), info); diff --git a/test/orphan-gc.js b/test/orphan-gc.js new file mode 100644 index 0000000..eacdb07 --- /dev/null +++ b/test/orphan-gc.js @@ -0,0 +1,32 @@ +"use strict"; +// A yielded fiber whose JS object is garbage-collected must be unwound as a zombie, not crash the +// process. On V8 >= 10.4 the weak callback is a phantom (kParameter) callback and has to reset its +// handle; the old kFinalizer code path resurrected the object instead. +var Fiber = require('fibers'); +var v8 = require('v8'); +var vm = require('vm'); +v8.setFlagsFromString('--expose_gc'); +var gc = vm.runInNewContext('gc'); + +var N = 200, unwound = 0; +for (var ii = 0; ii < N; ++ii) { + var fiber = Fiber(function() { + try { + Fiber.yield(); + } catch (err) { + // zombie exception. Touch Fiber.current: on V8 >= 10.4 the JS object is already gone and + // this must return undefined instead of dereferencing an empty handle. + Fiber.current; + ++unwound; + throw err; + } + }); + fiber.run(); + fiber = null; +} +gc(); gc(); +Fiber(function() {}).run(); // Fiber::Run() calls DestroyOrphans() +gc(); gc(); +Fiber(function() {}).run(); + +console.log(unwound === N ? 'pass' : 'fail: unwound ' + unwound + '/' + N);