diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 88b2ca5fce..cf6114037e 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -172,6 +172,8 @@ title: "Limitations"
../limitations/runtime-differences-from-node-js.mdx
+../limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
+
### Array bounds and runtime errors
../limitations/array-bounds-and-runtime-errors.mdx
diff --git a/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
new file mode 100644
index 0000000000..49b748f65b
--- /dev/null
+++ b/docs/content/limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx
@@ -0,0 +1,3 @@
+- **Event order across native code and islands:** Timers, worker output, and other macrotasks from the program and from JavaScript it runs in a Node island share one event loop under Node.js, so their order is fixed.
+ A compiled program runs both on the event loop it hosts for Node.js, but it does not merge them into one order, so callbacks from the two sides can run in a different order than under Node.js, and the order can vary between runs.
+ Only programs that depend on the ordering between native and island callbacks are affected.
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index 5743d8106a..ad3bc544ca 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -382,6 +382,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
"scr_node_island_handle.c",
"scr_node_island_export.c",
"scr_node_island_boot.c",
+ "scr_node_island_gc.c",
),
// Readable.fromWeb reads a web ReadableStream through its reader.
unit((u) => u.nativeFetch && u.opts.stream, "scr_stream_from_web.c"),
diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts
index 40f946b184..26f9d2db08 100644
--- a/packages/compiler/src/node-islands/libnode.ts
+++ b/packages/compiler/src/node-islands/libnode.ts
@@ -41,7 +41,7 @@ import {
export const NODE_ISLAND_MIN_NODE = "24.13.1";
/** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */
-export const NODE_BRIDGE_ABI = 9;
+export const NODE_BRIDGE_ABI = 10;
/** The prefix of a recorded path relative to the executable's directory. */
export const ORIGIN = "$ORIGIN/";
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json
new file mode 100644
index 0000000000..1f981c3901
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-gc-emitter/main.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6890-node-island-gc-emitter/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json
new file mode 100644
index 0000000000..7caba2f5e5
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6891-node-island-gc-closure/main.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6891-node-island-gc-closure/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json
new file mode 100644
index 0000000000..240e978bee
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6892-node-island-gc-native-subclass/main.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6892-node-island-gc-native-subclass/main.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json
new file mode 100644
index 0000000000..2e9648874d
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6893-node-island-gc-shared-module/main.mjs.json
@@ -0,0 +1,7 @@
+{
+ "order": [
+ "/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs",
+ "/tests/corpus/6893-node-island-gc-shared-module/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json
new file mode 100644
index 0000000000..a36bcaae1f
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6894-node-island-gc-promises/main.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6894-node-island-gc-promises/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json
new file mode 100644
index 0000000000..a40319e411
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6895-node-island-gc-weakmap/main.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6895-node-island-gc-weakmap/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json
new file mode 100644
index 0000000000..e9f6e08f3c
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6896-node-island-native-method-member/main.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6896-node-island-native-method-member/main.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc
index 593e2e76ac..b6670be91f 100644
--- a/packages/libnode-common/bridge/scr_node_bridge.cc
+++ b/packages/libnode-common/bridge/scr_node_bridge.cc
@@ -20,21 +20,40 @@
// Calls into JavaScript therefore do not drain queues themselves: the
// runtime's checkpoints do, through `checkpoint`, and Node's own after
// every callback it dispatches.
+//
+// One heap, as far as cycles go (scope-B NI-8). The import table holds V8
+// objects as v8::TracedReferences that Node's CppHeap traces, and every
+// export representative carries a CppHeap object that traces what its
+// native value reaches across the boundary; see "cross-heap cycle
+// collection" below.
#include "scr_node_bridge.h"
+#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
+#include
+#include
+#include
+#include
#include
#include
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -61,6 +80,9 @@ const ScrNodeBridgeHost *g_host = nullptr;
void DropAllHandles();
void DropAllExports();
void FlushReleasedExports();
+bool StartCollector(v8::Isolate *isolate);
+void StopCollector(v8::Isolate *isolate);
+void Touch(uint32_t export_id);
std::unique_ptr g_platform;
node::EmbedderSnapshotData::Pointer g_snapshot;
std::unique_ptr g_setup;
@@ -285,6 +307,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar
g_stack_limit = stack_limit;
node::SetProcessExitHandler(g_setup->env(),
[](node::Environment *, int code) { g_host->exit(code); });
+ if (!StartCollector(isolate)) return Copy("this libnode's isolate has no CppHeap", nullptr);
HandleScope handles(isolate);
Local context = g_setup->context();
Context::Scope context_scope(context);
@@ -697,6 +720,7 @@ void Stop() {
node::Stop(g_setup->env());
g_call.Reset();
g_load.Reset();
+ StopCollector(isolate);
DropAllHandles();
DropAllExports();
for (Global *fn :
@@ -721,26 +745,42 @@ void Stop() {
// ── object handles (scope-B NI-4) ───────────────────────────────────────
-// The import table: V8 values the runtime holds, one v8::Global per value
-// and one id per value while held (found again through the value's
-// identity hash), so a V8 object crossing twice is the same runtime
-// handle. A typed array's slot also keeps its backing store, so memory the
-// runtime aliases stays valid even if JavaScript detaches the buffer.
+// The import table: V8 values the runtime holds, one entry per value and
+// one id per value while held (found again through the value's identity
+// hash), so a V8 object crossing twice is the same runtime handle. A typed
+// array's slot also keeps its backing store, so memory the runtime aliases
+// stays valid even if JavaScript detaches the buffer.
+//
+// An entry holds its value as a v8::TracedReference, which only the
+// cross-heap collector's tracing marks (NI-8): as a root while native
+// roots may use it, otherwise through the representative of an export
+// whose native value reaches it. An entry nothing traced in a major GC is
+// reclaimed by V8 and becomes dead: the runtime no longer reaches it and
+// only drops its id, and any other use is an internal error.
struct Slot {
- Global value;
+ v8::TracedReference value;
std::shared_ptr store;
int hash = 0;
- bool live = false;
+ bool live = false; // the runtime holds the id
+ bool dead = false; // V8 reclaimed the value: the reference is never touched again
+ bool rooted = true; // traced as a root under an exact analysis
+ std::atomic traced{0}; // the GC cycle that last traced it
};
-std::vector g_slots(1); // id 0 is never used
+// The GC lock guards what the collector's tracing reads, from V8's marking
+// threads: the slot vector, slot references and flags, the export GC
+// table, reach lists and the collector's mode. The main thread never holds
+// it while V8 may allocate.
+std::mutex g_gc_lock;
+std::vector> g_slots(1); // index = id; id 0 is never used
std::vector g_free_ids;
std::unordered_multimap g_ids;
uint32_t g_live_handles = 0;
// Strings of out-values: valid until the next outermost entry.
std::deque g_scratch;
int g_entry_depth = 0;
+void NoteFresh(uint32_t entry);
int IdentityHash(Local v) {
if (v->IsObject()) return v.As()->GetIdentityHash();
@@ -752,7 +792,7 @@ uint32_t Import(Isolate *isolate, Local v, std::shared_ptrsecond];
+ Slot &slot = *g_slots[it->second];
if (slot.value.Get(isolate)->StrictEquals(v)) return it->second;
}
uint32_t id;
@@ -761,36 +801,67 @@ uint32_t Import(Isolate *isolate, Local v, std::shared_ptr(g_slots.size());
- g_slots.emplace_back();
}
- Slot &slot = g_slots[id];
- slot.value.Reset(isolate, v);
- slot.store = std::move(store);
- slot.hash = hash;
- slot.live = true;
+ {
+ std::lock_guard lock(g_gc_lock);
+ if (id == g_slots.size()) g_slots.emplace_back();
+ if (!g_slots[id]) g_slots[id] = std::make_unique();
+ Slot &slot = *g_slots[id];
+ // An assigning store: a reference made while V8 marks is marked.
+ slot.value.Reset(isolate, v);
+ slot.store = std::move(store);
+ slot.hash = hash;
+ slot.live = true;
+ slot.dead = false;
+ slot.rooted = true;
+ }
+ NoteFresh(id);
g_ids.emplace(hash, id);
g_live_handles++;
return id;
}
+[[noreturn]] void CollectedInUse(uint32_t id) {
+ std::fprintf(stderr,
+ "scriptc: internal error: island handle %u was used after V8 collected its "
+ "object (cross-heap collector)\n",
+ id);
+ std::fflush(stderr);
+ std::abort();
+}
+
Local HandleValue(Isolate *isolate, uint32_t id) {
- if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return v8::Undefined(isolate);
- return g_slots[id].value.Get(isolate);
+ if (id == 0 || id >= g_slots.size() || !g_slots[id] || !g_slots[id]->live)
+ return v8::Undefined(isolate);
+ Slot &slot = *g_slots[id];
+ if (slot.dead) CollectedInUse(id);
+ return slot.value.Get(isolate);
}
-void Drop(uint32_t id) {
- if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return;
- Slot &slot = g_slots[id];
- auto range = g_ids.equal_range(slot.hash);
+void ForgetIdentity(uint32_t id, int hash) {
+ auto range = g_ids.equal_range(hash);
for (auto it = range.first; it != range.second; ++it) {
if (it->second == id) {
g_ids.erase(it);
break;
}
}
- slot.value.Reset();
- slot.store.reset();
- slot.live = false;
+}
+
+void Drop(uint32_t id) {
+ if (id == 0 || id >= g_slots.size() || !g_slots[id] || !g_slots[id]->live) return;
+ Slot &slot = *g_slots[id];
+ if (!slot.dead) ForgetIdentity(id, slot.hash);
+ std::shared_ptr store;
+ {
+ std::lock_guard lock(g_gc_lock);
+ // V8 already reclaimed a dead slot's node: forget it without touching it.
+ if (slot.dead) new (&slot.value) v8::TracedReference();
+ else slot.value.Reset();
+ store = std::move(slot.store);
+ slot.live = false;
+ slot.dead = false;
+ }
g_free_ids.push_back(id);
g_live_handles--;
}
@@ -801,6 +872,277 @@ void DropAllHandles() {
uint32_t LiveHandles() { return g_live_handles; }
+// ── cross-heap cycle collection (scope-B NI-8) ─────────────────────────
+// V8's marker decides what lives on both sides of the boundary. Before a
+// major GC marks (its prologue), the runtime analyzes the native subgraph
+// under the exported values (gc_analyze, scr_node_island_gc.c): which
+// exports its own roots hold, which imports they hold, and what every
+// other export's native value reaches (imports and other exports). Then:
+// - Roots, a CppHeap object held by a Persistent, traces the imports
+// native roots hold and the representatives of the exports they hold;
+// - every representative carries a CppHeap object (Reach, wrapped by a
+// carrier object the representative holds under a private symbol) that
+// traces what its export reaches;
+// - Fresh traces references made while V8 marks (new imports, new
+// exports, and exports JavaScript hands to the runtime or calls into
+// while V8 marks, which become roots), retraced through a Steele
+// barrier.
+// V8 reclaims every traced reference this left untraced: the imports
+// become dead (the runtime holds them only from garbage it frees next),
+// and representatives V8 did not mark die, which releases their exports
+// (export_release) so the runtime's cycle collector frees the native half.
+// Without an analysis (the runtime declined, or V8 skipped the prologue)
+// the collector is conservative: Roots traces everything.
+//
+// Node creates the isolate with its own CppHeap (NewIsolate: owned by V8)
+// and supports addons wrapping CppHeap objects with v8::Object::Wrap; the
+// bridge uses that heap. V8 13.6's EmbedderRootsHandler only resets
+// droppable references in minor GCs, and attaching a second CppHeap is no
+// longer possible, so this is the one way to put embedder edges into
+// V8's major-GC marking.
+
+struct ExportGc {
+ v8::TracedReference rep; // the representative, traced only by the collector
+ bool rooted = true;
+ std::vector reach; // what the export reaches (SCR_NODE_GC_EXPORT marks exports)
+ std::atomic traced{0};
+ uint32_t gen = 0; // tells this representative's Reach from older ones
+ uint8_t tenure = 0; // consecutive analyses that found it held natively
+};
+std::unordered_map> g_export_gc;
+uint32_t g_export_gen = 0;
+enum { kConservative = 0, kExact = 1 };
+int g_gc_mode = kConservative;
+uint32_t g_epoch = 1; // the GC cycle; a reference traced in it carries it
+std::vector g_fresh; // entries to trace with Fresh
+bool g_fresh_pending = false; // Fresh will be (re)traced and sees g_fresh
+bool g_gc_trace = false; // SCRIPTC_NODE_ISLANDS_TRACE contains "gc"
+bool g_gc_off = false; // SCRIPTC_NODE_ISLANDS_GC=off: every handle a root
+ScrNodeGcStats g_gc_stats;
+struct {
+ size_t exports, rooted, imports, free, reach, visited, touched;
+ uint64_t ns;
+ bool analyzed;
+ bool incremental; // analyzed when incremental marking started
+} g_gc_cycle;
+uint64_t g_cycle_freed_exports = 0;
+
+void TraceEntry(cppgc::Visitor *visitor, uint32_t entry, uint32_t epoch) {
+ auto *js = static_cast(visitor);
+ if (entry & SCR_NODE_GC_EXPORT) {
+ auto it = g_export_gc.find(entry & ~SCR_NODE_GC_EXPORT);
+ if (it == g_export_gc.end() || it->second->rep.IsEmptyThreadSafe()) return;
+ js->Trace(it->second->rep);
+ it->second->traced.store(epoch, std::memory_order_relaxed);
+ return;
+ }
+ if (entry == 0 || entry >= g_slots.size() || !g_slots[entry]) return;
+ Slot &slot = *g_slots[entry];
+ if (!slot.live || slot.dead) return;
+ js->Trace(slot.value);
+ slot.traced.store(epoch, std::memory_order_relaxed);
+}
+
+class Roots final : public cppgc::GarbageCollected {
+ public:
+ void Trace(cppgc::Visitor *visitor) const {
+ std::lock_guard lock(g_gc_lock);
+ bool exact = g_gc_mode == kExact;
+ uint32_t epoch = g_epoch;
+ for (uint32_t id = 1; id < g_slots.size(); id++)
+ if (g_slots[id] && (!exact || g_slots[id]->rooted)) TraceEntry(visitor, id, epoch);
+ for (auto &it : g_export_gc)
+ if (!exact || it.second->rooted) TraceEntry(visitor, it.first | SCR_NODE_GC_EXPORT, epoch);
+ }
+};
+
+class Fresh final : public cppgc::GarbageCollected {
+ public:
+ void Trace(cppgc::Visitor *visitor) const {
+ std::lock_guard lock(g_gc_lock);
+ for (uint32_t entry : g_fresh) TraceEntry(visitor, entry, g_epoch);
+ g_fresh.clear();
+ g_fresh_pending = false;
+ }
+};
+
+class Reach final : public cppgc::GarbageCollected {
+ public:
+ Reach(uint32_t id, uint32_t gen) : id_(id), gen_(gen) {}
+ void Trace(cppgc::Visitor *visitor) const {
+ std::lock_guard lock(g_gc_lock);
+ if (g_gc_mode != kExact) return; // Roots traces everything
+ auto it = g_export_gc.find(id_);
+ if (it == g_export_gc.end() || it->second->gen != gen_) return;
+ for (uint32_t entry : it->second->reach) TraceEntry(visitor, entry, g_epoch);
+ }
+
+ private:
+ uint32_t id_, gen_;
+};
+
+v8::CppHeap *g_cpp_heap = nullptr;
+// What a representative keeps alive natively, as V8 learns of it (its
+// external memory): V8 schedules its major GCs by its own heap, which the
+// native halves of cross-heap garbage do not grow. An estimate per
+// representative (the native value, its closure or record and what they
+// hold); reported lazily, never from inside a GC.
+constexpr int64_t kExportExternalBytes = 2048;
+// The estimate follows what major GCs find. A major GC that frees fewer
+// exports than an eighth of those made since the previous one drops it to
+// kExportExternalFloor: long-lived exports would otherwise schedule a major
+// GC per 32k of them, each walking every handle. One that frees more
+// restores it.
+constexpr int64_t kExportExternalFloor = 64;
+int64_t g_export_external_bytes = kExportExternalBytes;
+uint64_t g_cycle_new_exports = 0; // exports made since the last major GC
+int64_t g_external_reported = 0;
+
+void ReportExternal(Isolate *isolate) {
+ int64_t want = static_cast(g_export_gc.size()) * g_export_external_bytes;
+ if (want == g_external_reported) return;
+ isolate->AdjustAmountOfExternalAllocatedMemory(want - g_external_reported);
+ g_external_reported = want;
+}
+cppgc::Persistent *g_roots = nullptr;
+cppgc::Persistent *g_fresh_root = nullptr;
+Global g_gc_key;
+Global g_carrier_template;
+
+bool Marking() {
+ return g_cpp_heap != nullptr && cppgc::subtle::HeapState::IsMarking(g_cpp_heap->GetHeapHandle());
+}
+
+// Makes V8 trace `object` again if it already did in this marking.
+void Retrace(const void *object) {
+ cppgc::subtle::HeapConsistency::WriteBarrierParams params;
+ if (cppgc::subtle::HeapConsistency::GetWriteBarrierType(object, params) ==
+ cppgc::subtle::HeapConsistency::WriteBarrierType::kMarking)
+ cppgc::subtle::HeapConsistency::SteeleWriteBarrier(params, object);
+}
+
+// A reference made, or an export rooted, while V8 marks.
+void NoteFresh(uint32_t entry) {
+ if (g_fresh_root == nullptr || !Marking()) return;
+ bool retrace;
+ {
+ std::lock_guard lock(g_gc_lock);
+ g_fresh.push_back(entry);
+ retrace = !g_fresh_pending;
+ g_fresh_pending = true;
+ }
+ if (retrace) Retrace(g_fresh_root->Get());
+}
+
+// JavaScript handed export `export_id` to the runtime, or called into it:
+// under an exact analysis the runtime may now reach its native value, so it
+// becomes a root for the rest of this GC.
+void Touch(uint32_t export_id) {
+ if (g_gc_mode != kExact) return;
+ auto it = g_export_gc.find(export_id);
+ if (it == g_export_gc.end() || it->second->rooted) return;
+ {
+ std::lock_guard lock(g_gc_lock);
+ it->second->rooted = true;
+ }
+ g_gc_cycle.touched++;
+ NoteFresh(export_id | SCR_NODE_GC_EXPORT);
+}
+
+uint64_t NowNs() {
+ return static_cast(std::chrono::duration_cast(
+ std::chrono::steady_clock::now().time_since_epoch())
+ .count());
+}
+
+// Exports found stable (held natively, or reaching nothing across the
+// boundary) in this many analyses in a row are tenured: analyzed again
+// only at every kTenuredEvery-th analysis, roots meanwhile (a bounded cost
+// for long-lived exports; their cycles are found at the next full one).
+constexpr uint8_t kTenure = 2;
+constexpr uint64_t kTenuredEvery = 8;
+
+// The analysis for this GC (in the prologue, before V8 marks or, for an
+// incremental GC, when its marking starts). Newest exports first: they are
+// the likeliest garbage, and the analysis may stop at its budget.
+void Analyze() {
+ std::vector> order; // (gen, id)
+ order.reserve(g_export_gc.size());
+ bool full = g_gc_stats.analyses % kTenuredEvery == 0;
+ for (auto &it : g_export_gc)
+ if (!it.second->rep.IsEmpty() && (full || it.second->tenure < kTenure))
+ order.emplace_back(it.second->gen, it.first);
+ std::sort(order.begin(), order.end(), std::greater<>());
+ std::vector ids;
+ ids.reserve(order.size());
+ for (auto &o : order) ids.push_back(o.second);
+ ScrNodeGcResult r;
+ std::memset(&r, 0, sizeof r);
+ uint64_t t0 = NowNs();
+ // A host without the analysis (the smoke tests) keeps every handle a root.
+ int status = g_host->gc_analyze != nullptr && !g_gc_off
+ ? g_host->gc_analyze(ids.data(), ids.size(), &r)
+ : 1;
+ uint64_t ns = NowNs() - t0;
+ g_gc_stats.analysis_ns += ns;
+ if (ns > g_gc_stats.max_analysis_ns) g_gc_stats.max_analysis_ns = ns;
+ std::lock_guard lock(g_gc_lock);
+ for (auto &slot : g_slots)
+ if (slot) slot->rooted = true;
+ for (auto &it : g_export_gc) {
+ it.second->rooted = true;
+ it.second->reach.clear();
+ }
+ g_gc_cycle = {};
+ g_gc_cycle.ns = ns;
+ if (status != 0) {
+ g_gc_mode = kConservative;
+ return;
+ }
+ g_gc_stats.analyses++;
+ g_gc_cycle.analyzed = true;
+ g_gc_cycle.exports = ids.size();
+ g_gc_cycle.imports = g_live_handles;
+ g_gc_cycle.visited = r.visited;
+ for (size_t i = 0; i < r.nfree_imports; i++) {
+ uint32_t id = r.free_imports[i];
+ if (id < g_slots.size() && g_slots[id] && g_slots[id]->live) {
+ g_slots[id]->rooted = false;
+ g_gc_cycle.free++;
+ }
+ }
+ for (size_t i = 0; i < ids.size(); i++) {
+ ExportGc &x = *g_export_gc[ids[i]];
+ x.rooted = r.rooted[i] != 0;
+ if (x.rooted) g_gc_cycle.rooted++;
+ // Stable: held natively, or reaching nothing across the boundary
+ // (rooting such an export only delays freeing its representative).
+ bool stable = x.rooted || r.reach_len[i] == 0;
+ x.tenure = stable ? (x.tenure < 255 ? x.tenure + 1 : 255) : 0;
+ x.reach.assign(r.reach + r.reach_start[i], r.reach + r.reach_start[i] + r.reach_len[i]);
+ g_gc_cycle.reach += r.reach_len[i];
+ }
+ g_gc_mode = kExact;
+}
+
+void OnGcPrologue(Isolate *, v8::GCType type, v8::GCCallbackFlags, void *) {
+ bool marking = Marking();
+ // V8 calls the prologue of an incremental GC before its marking starts,
+ // so nothing was traced yet: a new marking cycle, whose traced references
+ // carry a new epoch.
+ if (!marking) g_epoch++;
+ // The final pause of an incremental GC keeps the analysis made when its
+ // marking started: anything the program reached since went through Touch
+ // or NoteFresh.
+ if (type == v8::kGCTypeMarkSweepCompact && marking && g_gc_mode == kExact) return;
+ Analyze();
+ g_gc_cycle.incremental = type == v8::kGCTypeIncrementalMarking;
+ if (marking && type == v8::kGCTypeIncrementalMarking && g_gc_trace)
+ std::fprintf(stderr, "[node-islands] gc: marking had started before the prologue\n");
+}
+
+
+
const char *Scratch(std::string text, size_t *len) {
g_scratch.push_back(std::move(text));
*len = g_scratch.back().size();
@@ -945,6 +1287,13 @@ void OnExportGone(const v8::WeakCallbackInfo &info) {
it->second.Reset();
g_exports.erase(it);
}
+ {
+ // Nothing traced the representative: V8 reclaims its traced reference.
+ std::lock_guard lock(g_gc_lock);
+ g_export_gc.erase(id);
+ }
+ g_gc_stats.freed_exports++;
+ g_cycle_freed_exports++;
for (auto p = g_proxy_ids.begin(); p != g_proxy_ids.end();) {
if (p->second == id) p = g_proxy_ids.erase(p);
else ++p;
@@ -954,6 +1303,7 @@ void OnExportGone(const v8::WeakCallbackInfo &info) {
// Delivers the releases V8's collector queued (never from inside a GC).
void FlushReleasedExports() {
+ if (!g_released_exports.empty() && g_setup) ReportExternal(g_setup->isolate());
while (!g_released_exports.empty()) {
uint32_t id = g_released_exports.back();
g_released_exports.pop_back();
@@ -962,8 +1312,12 @@ void FlushReleasedExports() {
}
}
+// The export id a representative's callback carries. JavaScript is
+// calling into that export: the runtime may reach its native value now.
uint32_t ExportIdArg(Local data) {
- return data->IsUint32() ? data.As()->Value() : 0;
+ uint32_t id = data->IsUint32() ? data.As()->Value() : 0;
+ if (id != 0) Touch(id);
+ return id;
}
// Out-value scratch of host calls made from outside any runtime entry (a
@@ -1464,10 +1818,42 @@ void RefreshMirror(Isolate *isolate, Local context, uint32_t id, uint32
}
}
+// NI-8: the representative of export `id` holds a carrier whose CppHeap
+// object traces what the export reaches, and the export's traced reference
+// (marked only by the collector) starts out a root.
+void AttachReach(Isolate *isolate, Local context, uint32_t id, Local rep) {
+ if (g_cpp_heap == nullptr) return;
+ if (g_carrier_template.IsEmpty()) {
+ Local t = v8::ObjectTemplate::New(isolate);
+ t->SetInternalFieldCount(1);
+ g_carrier_template.Reset(isolate, t);
+ }
+ if (g_gc_key.IsEmpty())
+ g_gc_key.Reset(isolate, v8::Private::New(isolate, Utf8(isolate, "scriptc:gc")));
+ Local carrier = g_carrier_template.Get(isolate)->NewInstance(context).ToLocalChecked();
+ uint32_t gen = ++g_export_gen;
+ Reach *reach = cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle(), id, gen);
+ v8::Object::Wrap(isolate, carrier, reach);
+ // A Proxy holds its target: an array export's private lives there.
+ Local holder = rep->IsProxy() ? rep.As()->GetTarget().As() : rep;
+ holder->SetPrivate(context, g_gc_key.Get(isolate), carrier).Check();
+ {
+ std::lock_guard lock(g_gc_lock);
+ std::unique_ptr &x = g_export_gc[id];
+ x = std::make_unique();
+ x->rep.Reset(isolate, rep);
+ x->gen = gen;
+ }
+ g_cycle_new_exports++;
+ NoteFresh(id | SCR_NODE_GC_EXPORT);
+ ReportExternal(isolate);
+}
+
Local ExportValue(Isolate *isolate, Local context, const ScrNodeValue *in) {
uint32_t id = in->u.exp.id;
auto found = g_exports.find(id);
if (found != g_exports.end()) {
+ Touch(id);
Local existing = found->second.Get(isolate);
RefreshMirror(isolate, context, id, in->flags, existing);
return existing;
@@ -1509,6 +1895,7 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode
obj->SetPrototypeV2(context, v8::Object::New(isolate)->GetPrototypeV2()).FromMaybe(false);
}
if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check();
+ AttachReach(isolate, context, id, obj);
Global &slot = g_exports[id];
slot.Reset(isolate, obj);
slot.SetWeak(reinterpret_cast(static_cast(id)), OnExportGone,
@@ -1531,6 +1918,11 @@ void ExportSettle(uint32_t id, int rejected, const ScrNodeValue *value) {
}
void DropAllExports() {
+ {
+ std::lock_guard lock(g_gc_lock);
+ for (auto &it : g_export_gc) it.second->rep.Reset();
+ g_export_gc.clear();
+ }
for (auto &it : g_exports) it.second.Reset();
g_exports.clear();
for (auto &it : g_resolvers) it.second.Reset();
@@ -1541,6 +1933,122 @@ void DropAllExports() {
g_export_key.Reset();
}
+// After a major GC: what the collector left untraced is reclaimed. Dead
+// imports are forgotten (the runtime drops them from the garbage it frees
+// next); a representative that lived on through JavaScript alone gets a new
+// traced reference. Then the collector is conservative again until the
+// next analysis.
+void OnGcEpilogue(Isolate *isolate, v8::GCType, v8::GCCallbackFlags, void *) {
+ HandleScope handles(isolate);
+ uint64_t freed_imports = 0;
+ std::vector refresh, dead;
+ {
+ std::lock_guard lock(g_gc_lock);
+ uint32_t epoch = g_epoch;
+ for (uint32_t id = 1; id < g_slots.size(); id++) {
+ Slot *slot = g_slots[id].get();
+ if (slot == nullptr || !slot->live || slot->dead) continue;
+ if (slot->traced.load(std::memory_order_relaxed) == epoch) continue;
+ slot->dead = true;
+ new (&slot->value) v8::TracedReference();
+ dead.push_back(id);
+ freed_imports++;
+ }
+ for (auto &it : g_export_gc) {
+ ExportGc &x = *it.second;
+ if (x.traced.load(std::memory_order_relaxed) != epoch) {
+ new (&x.rep) v8::TracedReference();
+ refresh.push_back(it.first);
+ }
+ x.rooted = true;
+ x.reach.clear();
+ }
+ for (auto &slot : g_slots)
+ if (slot) slot->rooted = true;
+ g_gc_mode = kConservative;
+ g_fresh.clear();
+ g_fresh_pending = false;
+ g_epoch++;
+ }
+ for (uint32_t id : dead) ForgetIdentity(id, g_slots[id]->hash);
+ for (uint32_t id : refresh) {
+ auto weak = g_exports.find(id);
+ std::lock_guard lock(g_gc_lock);
+ auto x = g_export_gc.find(id);
+ if (x == g_export_gc.end()) continue;
+ if (weak == g_exports.end() || weak->second.IsEmpty()) g_export_gc.erase(x);
+ else x->second->rep.Reset(isolate, weak->second.Get(isolate));
+ }
+ g_gc_stats.major_gcs++;
+ g_gc_stats.freed_imports += freed_imports;
+ if (g_cycle_new_exports >= 1024)
+ g_export_external_bytes = g_cycle_freed_exports * 8 < g_cycle_new_exports
+ ? kExportExternalFloor
+ : kExportExternalBytes;
+ g_cycle_new_exports = 0;
+ if (g_gc_trace) {
+ std::fflush(stdout);
+ if (g_gc_cycle.analyzed)
+ std::fprintf(stderr,
+ "[node-islands] gc %llu%s: exports %zu (native roots %zu, reach %zu, touched "
+ "%zu), imports %zu (free %zu), walked %zu in %.3f ms; freed exports %llu, "
+ "imports %llu\n",
+ (unsigned long long)g_gc_stats.major_gcs,
+ g_gc_cycle.incremental ? " (incremental)" : "", g_gc_cycle.exports,
+ g_gc_cycle.rooted, g_gc_cycle.reach, g_gc_cycle.touched, g_gc_cycle.imports,
+ g_gc_cycle.free, g_gc_cycle.visited, (double)g_gc_cycle.ns / 1e6,
+ (unsigned long long)g_cycle_freed_exports, (unsigned long long)freed_imports);
+ else
+ std::fprintf(stderr,
+ "[node-islands] gc %llu: not analyzed (every handle a root); freed exports "
+ "%llu, imports %llu\n",
+ (unsigned long long)g_gc_stats.major_gcs,
+ (unsigned long long)g_cycle_freed_exports, (unsigned long long)freed_imports);
+ }
+ g_gc_cycle = {};
+ g_cycle_freed_exports = 0;
+}
+
+void GcStats(ScrNodeGcStats *out) {
+ *out = g_gc_stats;
+ out->live_exports = static_cast(g_exports.size());
+ out->live_imports = g_live_handles;
+}
+
+bool StartCollector(Isolate *isolate) {
+ const char *trace = std::getenv("SCRIPTC_NODE_ISLANDS_TRACE");
+ g_gc_trace = trace != nullptr && std::strstr(trace, "gc") != nullptr;
+ const char *mode = std::getenv("SCRIPTC_NODE_ISLANDS_GC");
+ g_gc_off = mode != nullptr && std::strcmp(mode, "off") == 0;
+ // Node creates every isolate with a CppHeap; the import table's traced
+ // references need one, or V8 would reclaim them at its first major GC.
+ g_cpp_heap = isolate->GetCppHeap();
+ if (g_cpp_heap == nullptr) return false;
+ g_roots = new cppgc::Persistent(
+ cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle()));
+ g_fresh_root = new cppgc::Persistent(
+ cppgc::MakeGarbageCollected(g_cpp_heap->GetAllocationHandle()));
+ isolate->AddGCPrologueCallback(
+ OnGcPrologue, nullptr,
+ static_cast(v8::kGCTypeMarkSweepCompact | v8::kGCTypeIncrementalMarking));
+ isolate->AddGCEpilogueCallback(OnGcEpilogue, nullptr, v8::kGCTypeMarkSweepCompact);
+ return true;
+}
+
+void StopCollector(Isolate *isolate) {
+ if (g_roots == nullptr) return;
+ if (g_external_reported != 0) isolate->AdjustAmountOfExternalAllocatedMemory(-g_external_reported);
+ g_external_reported = 0;
+ isolate->RemoveGCPrologueCallback(OnGcPrologue, nullptr);
+ isolate->RemoveGCEpilogueCallback(OnGcEpilogue, nullptr);
+ delete g_roots;
+ delete g_fresh_root;
+ g_roots = nullptr;
+ g_fresh_root = nullptr;
+ g_carrier_template.Reset();
+ g_gc_key.Reset();
+}
+
void FromV8(Isolate *isolate, Local context, Local v, ScrNodeValue *out) {
std::memset(out, 0, sizeof *out);
if (v.IsEmpty() || v->IsUndefined()) { out->tag = SCR_NV_UNDEFINED; return; }
@@ -1577,6 +2085,7 @@ void FromV8(Isolate *isolate, Local context, Local v, ScrNodeVal
}
}
if (uint32_t export_id = ExportIdOf(isolate, context, v)) {
+ Touch(export_id);
out->tag = SCR_NV_EXPORT;
out->u.exp.id = export_id;
return;
@@ -1715,7 +2224,14 @@ struct Op {
Enter e;
TryCatch try_catch;
Op() : try_catch(e.isolate) {
- if (g_entry_depth++ == 0) g_scratch.clear();
+ if (g_entry_depth++ == 0) {
+ g_scratch.clear();
+ // Representatives V8 collected since: the runtime drops their exports
+ // now (NI-8), whatever runs JavaScript next. The runtime holds nothing
+ // of theirs while it starts an operation: their values were reachable
+ // only through the table.
+ FlushReleasedExports();
+ }
}
~Op() { g_entry_depth--; }
// The operation's outcome: 0 and the result, or 1 and the thrown value.
@@ -2087,6 +2603,7 @@ const ScrNodeBridge kBridge = {
LiveHandles,
ExportSettle,
SharedPush,
+ GcStats,
};
} // namespace
diff --git a/packages/libnode-common/fetch-dev-pack.mjs b/packages/libnode-common/fetch-dev-pack.mjs
index 9d3a0512fc..d3c74e4810 100644
--- a/packages/libnode-common/fetch-dev-pack.mjs
+++ b/packages/libnode-common/fetch-dev-pack.mjs
@@ -9,7 +9,7 @@
* node packages/libnode-common/fetch-dev-pack.mjs --out */
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
-import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -58,19 +58,24 @@ async function fetchVerified({ urls, sha256 }, name) {
throw new Error(`no source for ${name} matched its pin:\n${failures.join("\n")}`);
}
-await fetchVerified(pins.libnode, "libnode.tar.gz");
-await fetchVerified(pins.headers, "headers.tar.gz");
-mkdirSync(resolve(values.out), { recursive: true });
-execFileSync(
- process.execPath,
- [
- join(here, "build-pack.mjs"),
- "--libnode",
- join(work, pins.libnode.file),
- "--headers",
- join(work, pins.headers.dir),
- "--out",
- resolve(values.out),
- ],
- { stdio: "inherit" },
-);
+// The downloads and their unpacked trees are removed once the pack is built.
+try {
+ await fetchVerified(pins.libnode, "libnode.tar.gz");
+ await fetchVerified(pins.headers, "headers.tar.gz");
+ mkdirSync(resolve(values.out), { recursive: true });
+ execFileSync(
+ process.execPath,
+ [
+ join(here, "build-pack.mjs"),
+ "--libnode",
+ join(work, pins.libnode.file),
+ "--headers",
+ join(work, pins.headers.dir),
+ "--out",
+ resolve(values.out),
+ ],
+ { stdio: "inherit" },
+ );
+} finally {
+ rmSync(work, { recursive: true, force: true });
+}
diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs
index 35ca2f1716..67f56d1279 100644
--- a/packages/runtime-pack-common/runtime-pack-matrix.mjs
+++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs
@@ -201,6 +201,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [
["scr_node_island_handle.c", "nodeIsland"],
["scr_node_island_export.c", "nodeIsland"],
["scr_node_island_boot.c", "nodeIsland"],
+ ["scr_node_island_gc.c", "nodeIsland"],
["scr_stream_from_web.c", all("stream", "nativeFetch")],
];
diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c
index 9e35c673b8..f1428cdd9b 100644
--- a/packages/runtime/src/scr_async.c
+++ b/packages/runtime/src/scr_async.c
@@ -2048,6 +2048,17 @@ void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrP
*scr_promise_cb_push(in) =
(struct ScrPromiseCbWaiter){ cb, scr_promise_retain(in), NULL, SCR_CB_ANY, SCR_CB_SYNC };
}
+
+/* Node islands (NI-8): a settled promise's dyn payload when the promise
+ * owns a reference to it that its trace does not visit (settled without a
+ * trace function), else NULL. The cross-heap analysis counts that edge. */
+void *scr_promise_untraced_dyn_payload(const ScrPromise *p) {
+ if ((p->payload_kind == SCR_EXC_REF || p->payload_kind == SCR_EXC_OBJ ||
+ p->payload_kind == SCR_EXC_PRIMITIVE_REF) &&
+ p->payload != NULL && p->trace_fn == NULL && p->release_fn == scr_dyn_release_v)
+ return p->payload;
+ return NULL;
+}
#endif
void scr_promise_race_add(ScrPromise *race, ScrPromise *in,
diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h
index 830b67193e..4aad133f1f 100644
--- a/packages/runtime/src/scr_node_bridge.h
+++ b/packages/runtime/src/scr_node_bridge.h
@@ -17,7 +17,7 @@
#include
#include
-#define SCR_NODE_BRIDGE_ABI 9u
+#define SCR_NODE_BRIDGE_ABI 10u
/* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */
#define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1"
@@ -74,10 +74,12 @@ typedef struct ScrNodeIoState {
/* ── object handles (scope-B NI-4) ────────────────────────────────────────
* A value crossing between the runtime and V8. Primitives, strings and
* BigInt are copies; objects are handles: ids into the bridge's import
- * table of v8::Global references, one id per V8 object while the runtime
+ * table of V8 references, one id per V8 object while the runtime
* holds it (the bridge answers the same id for the same object), released
- * with `drop`. Typed arrays and Buffers coming out of V8 also carry their
- * backing store's memory (BYTES), which the runtime aliases.
+ * with `drop`. The table holds v8::TracedReferences that V8 marks only
+ * while the runtime may still use them (NI-8, gc_analyze). Typed arrays and
+ * Buffers coming out of V8 also carry their backing store's memory (BYTES),
+ * which the runtime aliases.
*
* Out-values the bridge writes hold bridge-owned string data valid until
* the next bridge entry; the runtime copies it at once. In-values are
@@ -156,6 +158,38 @@ typedef struct ScrNodeErrorInfo {
size_t arrow_len;
} ScrNodeErrorInfo;
+/* ── cross-heap cycle collection (scope-B NI-8) ────────────────────────────
+ * Before V8's major GC marks, the runtime answers which exports its own
+ * roots still hold and, for each other export, what its native subgraph
+ * reaches across the boundary (gc_analyze). V8 then marks from its roots,
+ * the exports native roots hold and the imports they hold, and through each
+ * representative it marks into what that export reaches. Exports V8 did not
+ * reach lose their representative (export_release), imports it did not
+ * reach are collected, and the runtime's cycle collector frees the native
+ * half. Reach entries are import ids, or export ids with SCR_NODE_GC_EXPORT
+ * set. All arrays are runtime-owned until the next host call. */
+#define SCR_NODE_GC_EXPORT 0x80000000u
+typedef struct ScrNodeGcResult {
+ const uint32_t *free_imports; /* import ids no native root holds */
+ size_t nfree_imports;
+ const uint8_t *rooted; /* per analyzed export: native roots hold it */
+ const uint32_t *reach_start; /* per analyzed export: its first entry in `reach` */
+ const uint32_t *reach_len;
+ const uint32_t *reach;
+ size_t visited; /* native objects the analysis walked */
+} ScrNodeGcResult;
+/* What the bridge counted, for the trace and the leak tests. */
+typedef struct ScrNodeGcStats {
+ uint64_t major_gcs; /* V8 major GCs since Node started */
+ uint64_t analyses; /* of which the runtime analyzed (the others traced everything) */
+ uint64_t freed_exports; /* representatives V8 collected */
+ uint64_t freed_imports; /* imports V8 collected */
+ uint64_t analysis_ns; /* time spent in gc_analyze, in total */
+ uint64_t max_analysis_ns;
+ uint32_t live_exports; /* representatives alive now */
+ uint32_t live_imports; /* import-table entries alive now */
+} ScrNodeGcStats;
+
/* What the runtime provides to the bridge. */
typedef struct ScrNodeBridgeHost {
/* An asynchronous call settled with this outcome. */
@@ -241,6 +275,11 @@ typedef struct ScrNodeBridgeHost {
* of a Map, the values of a Set. */
int (*export_op)(uint32_t id, uint32_t op, const ScrNodeValue *args, size_t argc,
ScrNodeValue *out);
+ /* NI-8: the analysis before a major GC, over the `nexports` exports that
+ * have a representative. 0: *out holds the answer; 1: the runtime cannot
+ * answer now (V8 then treats every handle as a root for this GC). Runs
+ * inside V8's GC prologue: it must not call into the bridge. */
+ int (*gc_analyze)(const uint32_t *exports, size_t nexports, ScrNodeGcResult *out);
} ScrNodeBridgeHost;
typedef struct ScrNodeBridge {
@@ -325,7 +364,7 @@ typedef struct ScrNodeBridge {
int (*then)(const ScrNodeValue *value, uint32_t token, ScrNodeValue *out);
/* The own name, message, stack and string code of an error handle. */
void (*error_info)(uint32_t h, ScrNodeErrorInfo *out);
- /* Releases handle h's Global. */
+ /* Releases handle h. */
void (*drop)(uint32_t h);
/* The number of live import-table entries (leak tests). */
uint32_t (*live_handles)(void);
@@ -334,6 +373,8 @@ typedef struct ScrNodeBridge {
/* NI-6: the native value of shared export (mod, slot) changed: the
* generated module's binding takes `value`. */
void (*shared_push)(uint32_t mod, uint32_t slot, const ScrNodeValue *value);
+ /* NI-8: the cross-heap collector's counters. */
+ void (*gc_stats)(ScrNodeGcStats *out);
} ScrNodeBridge;
#endif /* SCR_NODE_BRIDGE_H */
diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c
index 6404752c4c..aaf03f67a0 100644
--- a/packages/runtime/src/scr_node_island.c
+++ b/packages/runtime/src/scr_node_island.c
@@ -1212,6 +1212,7 @@ static const ScrNodeBridgeHost ni_host = {
&scr_ni_host_shared_get,
&scr_ni_host_shared_tdz,
&scr_ni_host_export_op,
+ &scr_ni_host_gc_analyze,
};
typedef struct {
@@ -1256,9 +1257,29 @@ static void ni_stop_atexit(void) {
fflush(stdout);
fprintf(stderr, "[node-islands] live handles %u\n", scr_ni_live_handles());
}
+ /* SCRIPTC_NODE_ISLANDS_TRACE=gc: the cross-heap collector's counters at
+ * exit (NI-8; each major GC is traced by the bridge as it ends). */
+ if (trace != NULL && strstr(trace, "gc") != NULL) {
+ /* The native halves of cycles V8 already freed are cyclic garbage the
+ * exit sweep would free anyway: count what is left after it. */
+ scr_collect_cycles();
+ ScrNodeGcStats stats;
+ memset(&stats, 0, sizeof stats);
+ ni_bridge->gc_stats(&stats);
+ fflush(stdout);
+ fprintf(stderr,
+ "[node-islands] gc totals: major %llu, analyzed %llu, freed exports %llu, freed "
+ "imports %llu, representatives %u, import entries %u, held exports %u, held handles "
+ "%u, analysis max %.3f ms total %.3f ms\n",
+ (unsigned long long)stats.major_gcs, (unsigned long long)stats.analyses,
+ (unsigned long long)stats.freed_exports, (unsigned long long)stats.freed_imports,
+ stats.live_exports, stats.live_imports, scr_ni_live_exports(), scr_ni_live_handles(),
+ (double)stats.max_analysis_ns / 1e6, (double)stats.analysis_ns / 1e6);
+ }
ni_on_node_stack(ni_stop_job, NULL);
/* Node is gone: no representative can call in again. */
scr_ni_exports_release_all();
+ scr_ni_gc_cleanup();
}
/* The hosted loop's run_loop on Node's stack. */
diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h
index b47e614b42..ccc572e904 100644
--- a/packages/runtime/src/scr_node_island.h
+++ b/packages/runtime/src/scr_node_island.h
@@ -180,6 +180,17 @@ int scr_ni_host_export_op(uint32_t id, uint32_t op, const struct ScrNodeValue *a
struct ScrNodeValue *out);
/* Promise settlement callback (SCR_NODE_ISLANDS build of scr_async.c). */
void scr_promise_on_settle_sync(ScrPromise *in, void (*cb)(ScrPromise *dst, ScrPromise *src));
+void *scr_promise_untraced_dyn_payload(const ScrPromise *p);
+
+/* ── cross-heap cycle collection (scope-B NI-8, scr_node_island_gc.c) ──── */
+const void *scr_ni_export_identity(uint32_t id);
+void *scr_ni_gc_ref(const ScrDyn *box, size_t *rc, uint32_t *id);
+void *scr_ni_gc_ref_info(void *ref, size_t *rc, uint32_t *id);
+size_t scr_ni_gc_pins(void ***objs, void ***refs);
+struct ScrNodeGcResult;
+int scr_ni_host_gc_analyze(const uint32_t *exports, size_t nexports,
+ struct ScrNodeGcResult *out);
+void scr_ni_gc_cleanup(void); /* at exit */
/* ── deferred island modules (scope-B NI-7, scr_node_island_boot.c) ─────
* nodeIsland.defer(path, snapshot): a statically imported island module the
diff --git a/packages/runtime/src/scr_node_island_export.c b/packages/runtime/src/scr_node_island_export.c
index f44913ff2a..bb67a467f1 100644
--- a/packages/runtime/src/scr_node_island_export.c
+++ b/packages/runtime/src/scr_node_island_export.c
@@ -26,7 +26,8 @@
*
* When V8 collects a representative, the bridge reports export_release at
* its next entry (never inside a GC) and the table drops its reference.
- * Cycles through both heaps leak until NI-8. */
+ * Cycles through both heaps are found by the analysis of
+ * scr_node_island_gc.c before each major GC (NI-8). */
#define _XOPEN_SOURCE 700
#define _DARWIN_C_SOURCE 1
#include "scr_runtime.h"
@@ -277,6 +278,13 @@ void scr_ni_export_out(ScrDyn *v, ScrNodeValue *out) {
}
}
+/* NI-8: the identity of export `id` (the referent its representative
+ * stands for), or NULL for a free id. */
+const void *scr_ni_export_identity(uint32_t id) {
+ if (id == 0 || id >= ni_exports_len) return NULL;
+ return ni_exports[id].identity;
+}
+
uint32_t scr_ni_live_exports(void) {
uint32_t n = 0;
for (uint32_t i = 1; i < ni_exports_len; i++) n += ni_exports[i].value != NULL;
diff --git a/packages/runtime/src/scr_node_island_gc.c b/packages/runtime/src/scr_node_island_gc.c
new file mode 100644
index 0000000000..6b8e26d8c6
--- /dev/null
+++ b/packages/runtime/src/scr_node_island_gc.c
@@ -0,0 +1,547 @@
+/* Node islands: cross-heap cycle collection (scope-B milestone NI-8).
+ * Linked with scr_node_island.c (the `nodeIsland` runtime feature).
+ *
+ * The two heaps hold each other through two tables. The export table holds
+ * native values whose V8 representatives JavaScript uses
+ * (scr_node_island_export.c), and the bridge's import table holds V8
+ * objects the program uses (scr_node_island_handle.c). Each table is a root
+ * for its own collector, so a cycle through both heaps (a native object
+ * holding an island emitter whose listener is a native closure capturing
+ * that object) was never freed by either side.
+ *
+ * Before V8's major GC marks, the bridge asks this unit which exports the
+ * program's own roots still hold and what every other export reaches
+ * across the boundary. The answer comes from a restricted trial deletion
+ * (Bacon-Rajan) over the native subgraph under the exported values,
+ * through the same trace functions the cycle collector uses (ScrCycType):
+ *
+ * 1. Walk every object reachable from an exported value and count, for
+ * each, the references that come from inside that subgraph or from the
+ * export table. An object with references left over is held from
+ * outside: from a root, or from an object the walk did not reach.
+ * 2. Everything reachable from such an object is held natively (black).
+ * An export whose value is black is a root for V8; an import held by a
+ * black holder, or by anything outside the subgraph (its count is not
+ * explained by holders the walk saw), is a root too.
+ * 3. For every other export, walk its white subgraph and list the imports
+ * and the other exports it reaches. V8 marks those when, and only
+ * when, it marks the export's representative.
+ *
+ * This never under-approximates what the program can reach. An edge the
+ * walk does not count leaves a count unexplained, which only makes more
+ * objects black; so does a reference from an object the walk did not
+ * reach, and so does stopping the walk early (its budget). A white object
+ * is reachable only through the export table, so the program can reach it
+ * again only through a representative V8 still marks (the bridge roots any
+ * export JavaScript touches during the marking that follows). V8's own
+ * marker honors weak references, ephemerons (WeakMap) and
+ * FinalizationRegistry on its side; native weak collections hold their
+ * keys weakly and need the key in hand, so they open no path to a white
+ * object.
+ *
+ * Holders of imports are island handle boxes and the pin table, which
+ * holds a runtime promise standing for an island promise together with its
+ * ref: a pinned object the walk reaches counts as the holder of its ref.
+ *
+ * The walk reads counts and calls trace functions only: it writes no
+ * collector header and allocates nothing the collector sees. It refuses
+ * to answer (the bridge then roots everything for that GC) while a cycle
+ * collection is in progress, which it notices by a non-black header, and
+ * when counts are inconsistent. */
+#define _XOPEN_SOURCE 700
+#define _DARWIN_C_SOURCE 1
+#include "scr_runtime.h"
+#include "scr_node_bridge.h"
+#include "scr_node_island.h"
+
+#include
+#include
+#include
+#include
+
+#if !defined(_WIN32) && !defined(__wasi__)
+
+/* One object of the walk: a headered native object, or (NI_REF) the
+ * ScrNodeRef of an island handle box. `count` starts at the object's
+ * reference count and loses one per reference the walk explains. */
+enum { NI_OBJ = 1, NI_REF = 2 };
+typedef struct {
+ void *key;
+ int64_t count;
+ uint32_t stamp; /* the reach walk that last saw it */
+ uint32_t ident; /* 1 + index of the analyzed export whose referent this is, else 0 */
+ uint32_t id; /* NI_REF: its import id (0: none) */
+ uint32_t pin; /* NI_OBJ: 1 + index of its first pin, else 0 */
+ uint8_t kind;
+ uint8_t black; /* held natively */
+ uint8_t walked; /* NI_OBJ: its children were (or are queued to be) counted */
+} NiGcNode;
+
+static NiGcNode *gc_nodes = NULL;
+static size_t gc_cap = 0, gc_len = 0;
+static void **gc_work = NULL;
+static size_t gc_nwork = 0, gc_work_cap = 0;
+static bool gc_failed = false;
+static size_t gc_budget = 0;
+
+/* The pin table as the walk sees it: pinned object -> its pins. */
+static void **gc_pin_obj = NULL, **gc_pin_ref = NULL;
+static size_t gc_npins = 0;
+static uint32_t *gc_pin_next = NULL; /* 1 + next pin of the same object */
+static size_t gc_pin_next_cap = 0;
+typedef struct {
+ void *obj;
+ uint32_t first; /* 1 + index */
+} NiGcPinSlot;
+static NiGcPinSlot *gc_pin_map = NULL;
+static size_t gc_pin_map_cap = 0;
+
+/* Answer buffers, reused across GCs. */
+static uint32_t *gc_free = NULL;
+static size_t gc_nfree = 0, gc_free_cap = 0;
+static uint8_t *gc_rooted = NULL;
+static uint32_t *gc_rstart = NULL, *gc_rlen = NULL;
+static size_t gc_nexp_cap = 0;
+static uint32_t *gc_reach = NULL;
+static size_t gc_nreach = 0, gc_reach_cap = 0;
+
+SCR_COLD static void gc_oom(void) { scr_trap("scriptc: out of memory\n"); }
+
+static void *gc_realloc(void *p, size_t n) {
+ void *q = realloc(p, n);
+ if (q == NULL) gc_oom();
+ return q;
+}
+
+static size_t gc_hash(const void *p) {
+ uintptr_t x = (uintptr_t)p;
+ x ^= x >> 33;
+ x *= 0xff51afd7ed558ccdULL;
+ x ^= x >> 33;
+ return (size_t)x;
+}
+
+static NiGcNode *gc_find_slot(NiGcNode *table, size_t cap, const void *key) {
+ size_t mask = cap - 1;
+ for (size_t i = gc_hash(key) & mask;; i = (i + 1) & mask)
+ if (table[i].key == NULL || table[i].key == key) return &table[i];
+}
+
+static void gc_grow(void) {
+ size_t cap = gc_cap ? gc_cap * 2 : 1024;
+ NiGcNode *table = calloc(cap, sizeof *table);
+ if (table == NULL) gc_oom();
+ for (size_t i = 0; i < gc_cap; i++)
+ if (gc_nodes[i].key != NULL) *gc_find_slot(table, cap, gc_nodes[i].key) = gc_nodes[i];
+ free(gc_nodes);
+ gc_nodes = table;
+ gc_cap = cap;
+}
+
+static NiGcNode *gc_lookup(const void *key) {
+ if (gc_cap == 0 || key == NULL) return NULL;
+ NiGcNode *n = gc_find_slot(gc_nodes, gc_cap, key);
+ return n->key != NULL ? n : NULL;
+}
+
+/* The slot of `key`, initialized as a new node of `kind` with `count` when
+ * it has none (*fresh). Node pointers stay valid until the next insertion. */
+static NiGcNode *gc_insert(void *key, int kind, int64_t count, bool *fresh) {
+ if ((gc_len + 1) * 2 > gc_cap) gc_grow();
+ NiGcNode *n = gc_find_slot(gc_nodes, gc_cap, key);
+ *fresh = n->key == NULL;
+ if (*fresh) {
+ memset(n, 0, sizeof *n);
+ n->key = key;
+ n->kind = (uint8_t)kind;
+ n->count = count;
+ gc_len++;
+ } else if (n->kind != kind) { /* one pointer seen as two kinds: inconsistent */
+ gc_failed = true;
+ }
+ return n;
+}
+
+static void gc_push(void *obj) {
+ if (gc_nwork == gc_work_cap) {
+ gc_work_cap = gc_work_cap ? gc_work_cap * 2 : 1024;
+ gc_work = gc_realloc(gc_work, gc_work_cap * sizeof *gc_work);
+ }
+ gc_work[gc_nwork++] = obj;
+}
+
+/* Objects the walk does not enter: none, immortal, published or compact
+ * (no count to explain; whatever they hold stays black). */
+static bool gc_skip(const void *obj) {
+ return obj == NULL || SCR_RC_COUNT(obj) >= SCR_RC_COMPACT_TAG;
+}
+
+/* An island handle box: its ref is an import. */
+static bool gc_island_box(void *obj) {
+ if (scr_cyc_type(scr_cyc_hdr(obj))->kind != SCR_CYC_KIND_DYN) return false;
+ const ScrDyn *d = obj;
+ return d->kind == SCR_DYN_HANDLE && d->v.handle.tag == SCR_DYNH_NODE_ISLAND;
+}
+
+/* ── pins ─────────────────────────────────────────────────────────────── */
+
+static void gc_pins_load(void) {
+ gc_npins = scr_ni_gc_pins(&gc_pin_obj, &gc_pin_ref);
+ size_t cap = 64;
+ while (cap < gc_npins * 2) cap *= 2;
+ if (cap > gc_pin_map_cap) {
+ free(gc_pin_map);
+ gc_pin_map = malloc(cap * sizeof *gc_pin_map);
+ if (gc_pin_map == NULL) gc_oom();
+ gc_pin_map_cap = cap;
+ }
+ memset(gc_pin_map, 0, gc_pin_map_cap * sizeof *gc_pin_map);
+ if (gc_npins > gc_pin_next_cap) {
+ gc_pin_next_cap = gc_npins * 2;
+ gc_pin_next = gc_realloc(gc_pin_next, gc_pin_next_cap * sizeof *gc_pin_next);
+ }
+ size_t mask = gc_pin_map_cap - 1;
+ for (size_t p = 0; p < gc_npins; p++) {
+ size_t i = gc_hash(gc_pin_obj[p]) & mask;
+ while (gc_pin_map[i].obj != NULL && gc_pin_map[i].obj != gc_pin_obj[p]) i = (i + 1) & mask;
+ gc_pin_next[p] = gc_pin_map[i].obj != NULL ? gc_pin_map[i].first : 0;
+ gc_pin_map[i].obj = gc_pin_obj[p];
+ gc_pin_map[i].first = (uint32_t)p + 1;
+ }
+}
+
+static uint32_t gc_pin_first(const void *obj) {
+ if (gc_npins == 0) return 0;
+ size_t mask = gc_pin_map_cap - 1;
+ for (size_t i = gc_hash(obj) & mask; gc_pin_map[i].obj != NULL; i = (i + 1) & mask)
+ if (gc_pin_map[i].obj == obj) return gc_pin_map[i].first;
+ return 0;
+}
+
+/* ── phase 1: count the references the subgraph explains ──────────────── */
+
+/* Every child of `obj` the walk counts: what its trace visits, plus the
+ * counted references the cycle collector leaves untraced but that can lead
+ * across the boundary (a dyn promise box's promise, a promise's untraced
+ * dyn payload). Counting more owned edges is always sound: an edge the
+ * walk counts is one it also follows. */
+static void gc_children(void *obj, ScrTraceVisit visit) {
+ const ScrCycType *type = scr_cyc_type(scr_cyc_hdr(obj));
+ type->trace(obj, visit, NULL);
+ if (type->kind == SCR_CYC_KIND_DYN) {
+ const ScrDyn *d = obj;
+ if (d->kind == SCR_DYN_PROMISE && d->v.promise != NULL) visit(d->v.promise, NULL);
+ } else if (type->kind == SCR_CYC_KIND_PROMISE) {
+ void *payload = scr_promise_untraced_dyn_payload(obj);
+ if (payload != NULL) visit(payload, NULL);
+ }
+}
+
+/* The refs `obj` holds across the boundary: an island box's ref, and the
+ * refs of the pins of a pinned object. */
+static void gc_refs_of(void *obj, uint32_t pin, void (*fn)(void *ref)) {
+ if (gc_island_box(obj)) {
+ size_t rc;
+ uint32_t id;
+ void *ref = scr_ni_gc_ref(obj, &rc, &id);
+ if (ref != NULL) fn(ref);
+ }
+ for (uint32_t p = pin; p != 0; p = gc_pin_next[p - 1]) fn(gc_pin_ref[p - 1]);
+}
+
+/* The node of a headered object, created on first sight: the pin table
+ * holds a pinned object once per pin, which the count starts without. */
+static NiGcNode *gc_obj_node(void *obj) {
+ bool fresh;
+ NiGcNode *n = gc_insert(obj, NI_OBJ, SCR_RC_COUNT(obj), &fresh);
+ if (fresh) {
+ n->pin = gc_pin_first(obj);
+ for (uint32_t p = n->pin; p != 0; p = gc_pin_next[p - 1]) n->count--;
+ }
+ return n;
+}
+
+/* Reached through a counted edge: queued for counting its own children. */
+static NiGcNode *gc_obj(void *obj) {
+ NiGcNode *n = gc_obj_node(obj);
+ if (!n->walked && n->kind == NI_OBJ) {
+ n->walked = 1;
+ gc_push(obj);
+ }
+ return n;
+}
+
+static void gc_count_visit(void *child, void *ctx) {
+ (void)ctx;
+ if (gc_skip(child)) return;
+ gc_obj(child)->count--;
+}
+
+static void gc_count_ref(void *ref) {
+ size_t rc = 0;
+ uint32_t id = 0;
+ scr_ni_gc_ref_info(ref, &rc, &id);
+ bool fresh;
+ NiGcNode *n = gc_insert(ref, NI_REF, (int64_t)rc, &fresh);
+ if (fresh) n->id = id;
+ n->count--;
+}
+
+/* Counts the subgraph under the queued objects, until the budget. */
+static void gc_count(void) {
+ while (gc_nwork > 0 && !gc_failed && gc_len <= gc_budget) {
+ void *obj = gc_work[--gc_nwork];
+ /* Outside a collection every live object is black. */
+ if (scr_cyc_color(scr_cyc_hdr(obj)) != SCR_CYC_BLACK) {
+ gc_failed = true;
+ return;
+ }
+ gc_children(obj, gc_count_visit);
+ gc_refs_of(obj, gc_lookup(obj)->pin, gc_count_ref);
+ }
+ /* Left over when the budget ran out: their children were not counted,
+ * so they must not pass for walked (they become black). */
+ while (gc_nwork > 0) {
+ NiGcNode *n = gc_lookup(gc_work[--gc_nwork]);
+ if (n != NULL) n->walked = 0;
+ }
+}
+
+/* ── phase 2: blacken what is held from outside ───────────────────────── */
+
+static void gc_black_visit(void *child, void *ctx) {
+ (void)ctx;
+ if (gc_skip(child)) return;
+ NiGcNode *n = gc_lookup(child);
+ if (n != NULL && !n->black) {
+ n->black = 1;
+ if (n->walked) gc_push(child);
+ }
+}
+
+static void gc_black_ref(void *ref) {
+ NiGcNode *r = gc_lookup(ref);
+ if (r != NULL) r->black = 1;
+}
+
+static void gc_blacken(void) {
+ for (size_t i = 0; i < gc_cap; i++) {
+ NiGcNode *n = &gc_nodes[i];
+ if (n->key == NULL) continue;
+ if (n->count < 0) { /* more references explained than counted */
+ gc_failed = true;
+ return;
+ }
+ /* Held from outside, or never walked: its children are unknown, and
+ * what it holds is a root as far as this analysis can tell. */
+ if (n->kind == NI_OBJ && (n->count > 0 || !n->walked) && !n->black) {
+ n->black = 1;
+ if (n->walked) gc_push(n->key);
+ }
+ while (gc_nwork > 0) {
+ void *obj = gc_work[--gc_nwork];
+ gc_children(obj, gc_black_visit);
+ gc_refs_of(obj, gc_lookup(obj)->pin, gc_black_ref);
+ }
+ }
+}
+
+/* ── phase 3: what each white export reaches ──────────────────────────── */
+
+static uint32_t gc_stamp = 0;
+static uint32_t gc_walk_export; /* 1 + index of the export being walked */
+static const uint32_t *gc_exports_in;
+
+static void gc_reach_add(uint32_t entry) {
+ if (gc_nreach == gc_reach_cap) {
+ gc_reach_cap = gc_reach_cap ? gc_reach_cap * 2 : 256;
+ gc_reach = gc_realloc(gc_reach, gc_reach_cap * sizeof *gc_reach);
+ }
+ gc_reach[gc_nreach++] = entry;
+}
+
+static void gc_reach_visit(void *child, void *ctx) {
+ (void)ctx;
+ if (gc_skip(child)) return;
+ NiGcNode *n = gc_lookup(child);
+ if (n == NULL || n->black || n->stamp == gc_stamp) return;
+ n->stamp = gc_stamp;
+ /* Another white export's referent: V8 marks its representative, whose
+ * own reach covers the rest. */
+ if (n->ident != 0 && n->ident != gc_walk_export && !gc_rooted[n->ident - 1]) {
+ gc_reach_add(gc_exports_in[n->ident - 1] | SCR_NODE_GC_EXPORT);
+ return;
+ }
+ gc_push(child);
+}
+
+static void gc_reach_ref(void *ref) {
+ NiGcNode *r = gc_lookup(ref);
+ if (r != NULL && !r->black && r->count == 0 && r->id != 0 && r->stamp != gc_stamp) {
+ r->stamp = gc_stamp;
+ gc_reach_add(r->id);
+ }
+}
+
+/* White objects were all walked: an unwalked one is black. */
+static void gc_reach_walk(void *start) {
+ NiGcNode *s = gc_lookup(start);
+ if (s == NULL || s->black) return;
+ s->stamp = gc_stamp;
+ gc_push(start);
+ while (gc_nwork > 0) {
+ void *obj = gc_work[--gc_nwork];
+ gc_children(obj, gc_reach_visit);
+ gc_refs_of(obj, gc_lookup(obj)->pin, gc_reach_ref);
+ }
+}
+
+static void gc_reserve_exports(size_t n) {
+ if (n <= gc_nexp_cap) return;
+ size_t cap = gc_nexp_cap ? gc_nexp_cap : 64;
+ while (cap < n) cap *= 2;
+ gc_rooted = gc_realloc(gc_rooted, cap);
+ gc_rstart = gc_realloc(gc_rstart, cap * sizeof *gc_rstart);
+ gc_rlen = gc_realloc(gc_rlen, cap * sizeof *gc_rlen);
+ gc_nexp_cap = cap;
+}
+
+static void gc_free_add(uint32_t id) {
+ if (gc_nfree == gc_free_cap) {
+ gc_free_cap = gc_free_cap ? gc_free_cap * 2 : 256;
+ gc_free = gc_realloc(gc_free, gc_free_cap * sizeof *gc_free);
+ }
+ gc_free[gc_nfree++] = id;
+}
+
+/* Clears the table for a walk like the last one, dropping a table much
+ * larger than that walk needs. */
+static void gc_reset(size_t expect) {
+ size_t want = 1024;
+ while (want < expect * 2) want *= 2;
+ if (gc_cap > 4 * want) {
+ free(gc_nodes);
+ gc_nodes = NULL;
+ gc_cap = 0;
+ } else if (gc_len > 0) {
+ memset(gc_nodes, 0, gc_cap * sizeof *gc_nodes);
+ }
+ gc_len = 0;
+ gc_nwork = 0;
+ gc_nfree = 0;
+ gc_nreach = 0;
+ gc_failed = false;
+}
+
+/* The object export `e`'s representative stands for, as the walk counts
+ * it: the export's identity, except for a promise. A promise's identity is
+ * a plain cell the export table keeps (so a typed promise that crosses
+ * twice is one export); the counted object is the promise itself. */
+static const void *gc_referent(uint32_t e) {
+ const ScrDyn *v = scr_ni_export_value(e);
+ if (v != NULL && v->kind == SCR_DYN_PROMISE) return v->v.promise;
+ return scr_ni_export_identity(e);
+}
+
+/* The objects one analysis may walk before the exports it has not reached
+ * yet count as held: a bounded pause, at the price of collecting their
+ * cycles at a later GC. */
+#define NI_GC_BUDGET ((size_t)1 << 20)
+
+int scr_ni_host_gc_analyze(const uint32_t *exports, size_t n, ScrNodeGcResult *out) {
+ static size_t last_len = 0;
+ memset(out, 0, sizeof *out);
+ gc_reset(last_len);
+ gc_reserve_exports(n > 0 ? n : 1);
+ out->rooted = gc_rooted;
+ out->reach_start = gc_rstart;
+ out->reach_len = gc_rlen;
+ if (n == 0) return 0; /* nothing exported: no cycle can pass through V8 */
+ gc_exports_in = exports;
+ gc_budget = NI_GC_BUDGET + 8 * n;
+ gc_pins_load();
+
+ /* The exports in the bridge's order (newest first), each referent marked
+ * so the walk recognizes it, and each value box, which the export table
+ * holds once, counted from. Once the budget runs out the remaining
+ * exports stay roots. */
+ for (size_t i = 0; i < n; i++) {
+ gc_rstart[i] = gc_rlen[i] = 0;
+ ScrDyn *v = scr_ni_export_value(exports[i]);
+ gc_rooted[i] = v == NULL || gc_skip(v);
+ }
+ for (size_t i = 0; i < n && !gc_failed; i++) {
+ if (gc_rooted[i]) continue;
+ if (gc_len > gc_budget) {
+ gc_rooted[i] = 1;
+ continue;
+ }
+ const void *ident = gc_referent(exports[i]);
+ if (ident != NULL && !gc_skip(ident)) gc_obj_node((void *)ident)->ident = (uint32_t)i + 1;
+ gc_obj(scr_ni_export_value(exports[i]))->count--;
+ gc_count();
+ }
+ if (!gc_failed) gc_blacken();
+ last_len = gc_len;
+ if (gc_failed) return 1;
+
+ for (size_t i = 0; i < n; i++) {
+ if (gc_rooted[i]) continue;
+ NiGcNode *nd = gc_lookup(scr_ni_export_value(exports[i]));
+ if (nd == NULL || nd->black) gc_rooted[i] = 1;
+ /* A box the export table alone holds still stands for its referent (a
+ * closure, a typed capsule's object): held natively, the export is a
+ * root, so its representative keeps its identity in V8's weak maps. */
+ const void *ident = gc_referent(exports[i]);
+ if (ident != NULL && ident != scr_ni_export_value(exports[i])) {
+ NiGcNode *in = gc_lookup(ident);
+ if (in != NULL && in->black) gc_rooted[i] = 1;
+ }
+ }
+ for (size_t i = 0; i < n; i++) {
+ gc_rstart[i] = (uint32_t)gc_nreach;
+ if (!gc_rooted[i]) {
+ if (++gc_stamp == 0) { /* wrapped: clear the stamps */
+ for (size_t k = 0; k < gc_cap; k++) gc_nodes[k].stamp = 0;
+ gc_stamp = 1;
+ }
+ gc_walk_export = (uint32_t)i + 1;
+ gc_reach_walk(scr_ni_export_value(exports[i]));
+ }
+ gc_rlen[i] = (uint32_t)gc_nreach - gc_rstart[i];
+ }
+
+ /* Imports whose every holder is white. */
+ for (size_t k = 0; k < gc_cap; k++) {
+ NiGcNode *r = &gc_nodes[k];
+ if (r->key != NULL && r->kind == NI_REF && !r->black && r->count == 0 && r->id != 0)
+ gc_free_add(r->id);
+ }
+ out->free_imports = gc_free;
+ out->nfree_imports = gc_nfree;
+ out->reach = gc_reach;
+ out->visited = gc_len;
+ return 0;
+}
+
+void scr_ni_gc_cleanup(void) {
+ free(gc_nodes);
+ free(gc_work);
+ free(gc_free);
+ free(gc_rooted);
+ free(gc_rstart);
+ free(gc_rlen);
+ free(gc_reach);
+ free(gc_pin_next);
+ free(gc_pin_map);
+ gc_nodes = NULL;
+ gc_work = NULL;
+ gc_free = NULL;
+ gc_rooted = NULL;
+ gc_rstart = gc_rlen = gc_reach = gc_pin_next = NULL;
+ gc_pin_map = NULL;
+ gc_cap = gc_len = gc_nwork = gc_work_cap = gc_free_cap = gc_nfree = 0;
+ gc_nexp_cap = gc_nreach = gc_reach_cap = gc_pin_next_cap = gc_pin_map_cap = 0;
+}
+
+#endif
diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c
index 41e11ab769..e399d25abf 100644
--- a/packages/runtime/src/scr_node_island_handle.c
+++ b/packages/runtime/src/scr_node_island_handle.c
@@ -7,8 +7,10 @@
* per V8 value while the runtime holds it and the runtime keeps one ref per
* id (ni_refs), so the same V8 object always comes back as the same ref:
* `===` compares handle pointers and identity is exact. When the last box
- * releases a ref, the bridge drops its Global and V8 may collect the
- * object (acyclic garbage; cycles across both heaps wait for NI-8).
+ * releases a ref, the bridge drops its reference and V8 may collect the
+ * object. Cycles across both heaps are collected by NI-8
+ * (scr_node_island_gc.c): V8 keeps an import alive only while the runtime
+ * may still use it.
*
* Boundary rules (DESIGN §2.5.1): undefined, null, booleans, numbers
* (exact: -0, NaN), strings (WTF-8, lone surrogates kept) and BigInt are
@@ -1225,6 +1227,44 @@ ScrDyn *scr_ni_value_in(const ScrNodeValue *v) {
return d != NULL ? d : scr_dyn_retain(scr_dyn_undefined());
}
+/* NI-8: the pinned runtime objects and the refs they stand for (the pin
+ * table holds one reference to each). */
+size_t scr_ni_gc_pins(void ***objs, void ***refs) {
+ static void **o = NULL, **r = NULL;
+ static size_t cap = 0;
+ if (nh_npins > cap) {
+ cap = nh_npins * 2;
+ void **go = realloc(o, cap * sizeof *go);
+ if (go == NULL) nh_oom();
+ o = go;
+ void **gr = realloc(r, cap * sizeof *gr);
+ if (gr == NULL) nh_oom();
+ r = gr;
+ }
+ for (size_t i = 0; i < nh_npins; i++) {
+ o[i] = nh_pins[i].obj;
+ r[i] = nh_pins[i].ref;
+ }
+ *objs = o;
+ *refs = r;
+ return nh_npins;
+}
+
+/* NI-8: the ref an island handle box holds, its reference count and its
+ * import id (0 unless it is the id's own ref: stubs, aliases and refs
+ * whose import was transplanted have none). */
+void *scr_ni_gc_ref_info(void *r, size_t *rc, uint32_t *id) {
+ ScrNodeRef *ref = r;
+ if (ref == NULL) return NULL;
+ *rc = ref->rc;
+ *id = ref->id != 0 && ref->id < ni_refs_cap && ni_refs[ref->id] == ref ? ref->id : 0;
+ return ref;
+}
+
+void *scr_ni_gc_ref(const ScrDyn *box, size_t *rc, uint32_t *id) {
+ return scr_ni_gc_ref_info(box->v.handle.ptr, rc, id);
+}
+
/* ── island arrays as copies (results typed as arrays of copied values) ─
* An island array whose declared native type is an array of primitives or
* strings (or arrays of those) crosses as a copy of its elements, the
@@ -1304,8 +1344,10 @@ static bool nh_op_set(void *h, const char *key, size_t key_len, const ScrDyn *va
}
/* A member that reads back as one of the program's own functions (a
- * module the program shares with its islands gives them its native
- * exports): called natively, with the island object as the receiver. */
+ * native function stored on the island object, whose representative
+ * crossed back as the native value, or a member of a module the program
+ * shares with its islands): called natively, with the island object as
+ * `this`, as V8 would call the representative. */
static bool nh_is_native_function(const ScrDyn *fn) {
return !scr_ni_is_handle(fn) && scr_dyn_is_callable(fn);
}
diff --git a/tests/corpus-node-islands-order/g06-worker/main.ts b/tests/corpus-node-islands-order/g06-worker/main.ts
index 5ee47a2946..fd7f4725af 100644
--- a/tests/corpus-node-islands-order/g06-worker/main.ts
+++ b/tests/corpus-node-islands-order/g06-worker/main.ts
@@ -1,4 +1,5 @@
// @requires-libnode
+// @order: race
// X { T 0 { W { L i-worker } } } T 2 { B 300 L h-sync }
import { callSync } from "@scriptc/node";
import { readFile, writeSync } from "node:fs";
diff --git a/tests/corpus/6890-node-island-gc-emitter/lib.cjs b/tests/corpus/6890-node-island-gc-emitter/lib.cjs
new file mode 100644
index 0000000000..bdca18663d
--- /dev/null
+++ b/tests/corpus/6890-node-island-gc-emitter/lib.cjs
@@ -0,0 +1,34 @@
+"use strict";
+const { EventEmitter } = require("node:events");
+
+class Ticker extends EventEmitter {
+ constructor() {
+ super();
+ // Each emitter carries some weight, so leaked cycles show in memory.
+ this.payload = new Array(256).fill(0).map((_, i) => ({ i }));
+ }
+ tick(n) {
+ return this.emit("tick", n);
+ }
+}
+
+// Allocates and drops JavaScript garbage so V8 runs its collector.
+function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
+
+// Emitters island code keeps alive on its own (a registry), whose native
+// listeners must keep working whatever the collector does.
+const kept = new Set();
+function keep(emitter) {
+ kept.add(emitter);
+}
+function tickAll(n) {
+ let delivered = 0;
+ for (const e of kept) if (e.tick(n)) delivered++;
+ return delivered;
+}
+
+module.exports = { Ticker, churn, keep, kept, tickAll };
diff --git a/tests/corpus/6890-node-island-gc-emitter/main.mjs b/tests/corpus/6890-node-island-gc-emitter/main.mjs
new file mode 100644
index 0000000000..196ec00232
--- /dev/null
+++ b/tests/corpus/6890-node-island-gc-emitter/main.mjs
@@ -0,0 +1,43 @@
+// @requires-libnode
+// Cross-heap cycles through an island emitter (scope-B NI-8): a native
+// object holds an island EventEmitter whose listener is a native closure
+// capturing that object. Neither heap alone can free such a pair; the
+// collector frees the pairs nothing holds and keeps the ones either side
+// still holds, which keep working.
+import { requireModule } from "@scriptc/node";
+
+const { churn, keep, kept, Ticker, tickAll } = requireModule("./lib.cjs");
+
+class Counter {
+ constructor(name) {
+ this.name = name;
+ this.hits = 0;
+ this.ticker = new Ticker();
+ this.ticker.on("tick", (n) => {
+ this.hits += n;
+ });
+ }
+}
+
+// Pairs held by native code, and pairs held by island code only.
+const nativeKept = [];
+let islandHeld = 0;
+let total = 0;
+for (let i = 0; i < 4000; i++) {
+ const c = new Counter(`c${i}`);
+ c.ticker.tick(1);
+ total += c.hits;
+ if (i % 500 === 0) nativeKept.push(c);
+ if (i % 700 === 0) {
+ keep(c.ticker);
+ islandHeld++;
+ }
+ churn(4);
+}
+console.log("total", total, "native kept", nativeKept.length, "island kept", islandHeld);
+
+// After the churn, every kept pair still works from both sides.
+for (const c of nativeKept) c.ticker.tick(10);
+console.log("native kept hits", nativeKept.map((c) => c.hits).join(","));
+console.log("island delivered", tickAll(100), "registry", kept.size);
+console.log("native kept hits", nativeKept.map((c) => c.hits).join(","));
diff --git a/tests/corpus/6891-node-island-gc-closure/lib.cjs b/tests/corpus/6891-node-island-gc-closure/lib.cjs
new file mode 100644
index 0000000000..608c821e15
--- /dev/null
+++ b/tests/corpus/6891-node-island-gc-closure/lib.cjs
@@ -0,0 +1,22 @@
+"use strict";
+// Island objects that hold native callbacks.
+function make(n) {
+ return { n, payload: new Array(128).fill(n), onPing: null, onBoth: null };
+}
+function ping(o, k) {
+ return o.onPing(k);
+}
+function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
+// Island-side roots.
+const held = [];
+function hold(o) {
+ held.push(o);
+}
+function pingHeld(k) {
+ return held.map((o) => o.onPing(k)).join(",");
+}
+module.exports = { make, ping, churn, hold, pingHeld };
diff --git a/tests/corpus/6891-node-island-gc-closure/main.mjs b/tests/corpus/6891-node-island-gc-closure/main.mjs
new file mode 100644
index 0000000000..70c95f1637
--- /dev/null
+++ b/tests/corpus/6891-node-island-gc-closure/main.mjs
@@ -0,0 +1,34 @@
+// @requires-libnode
+// Cross-heap cycles through native closures (scope-B NI-8): an island
+// object holds a native closure that captures that island object, and a
+// pair of island objects hold closures capturing each other. The collector
+// frees the cycles nothing holds; those held by native code (a native
+// closure, a native array) or by island code keep working.
+import { requireModule } from "@scriptc/node";
+
+const { churn, hold, make, ping, pingHeld } = requireModule("./lib.cjs");
+
+const nativeHeld = [];
+const viaClosure = [];
+let total = 0;
+for (let i = 0; i < 4000; i++) {
+ const o = make(i);
+ o.onPing = (k) => o.n + k + o.payload.length;
+ total += ping(o, 1);
+
+ // Two island objects whose closures capture each other.
+ const a = make(i);
+ const b = make(-i);
+ a.onPing = (k) => b.n * k;
+ b.onPing = (k) => a.n * k;
+ total += ping(a, 2) + ping(b, 3);
+
+ if (i % 800 === 0) nativeHeld.push(o);
+ if (i % 900 === 0) viaClosure.push(() => ping(a, 1) + ping(b, 1));
+ if (i % 1000 === 0) hold(b);
+ churn(4);
+}
+console.log("total", total);
+console.log("native held", nativeHeld.map((o) => ping(o, 0)).join(","));
+console.log("via closure", viaClosure.map((f) => f()).join(","));
+console.log("island held", pingHeld(5));
diff --git a/tests/corpus/6892-node-island-gc-native-subclass/main.ts b/tests/corpus/6892-node-island-gc-native-subclass/main.ts
new file mode 100644
index 0000000000..bf0aca24d4
--- /dev/null
+++ b/tests/corpus/6892-node-island-gc-native-subclass/main.ts
@@ -0,0 +1,35 @@
+// @requires-libnode
+// Cross-heap cycles through native subclasses of an island class (scope-B
+// NI-8): the instance is an island object; its native constructor registers
+// a native listener that captures the instance. The pairs nothing holds are
+// freed; those native code or island code holds keep working.
+import { adopt, broadcast, Channel, churn } from "channel-pkg";
+
+class CountingChannel extends Channel {
+ count = 0;
+ constructor(name: string) {
+ super(name);
+ this.onMessage((text: string) => {
+ this.count += text.length;
+ });
+ }
+ summary(): string {
+ return `${this.name}:${this.count}`;
+ }
+}
+
+const nativeHeld: CountingChannel[] = [];
+let total = 0;
+for (let i = 0; i < 3000; i++) {
+ const c = new CountingChannel(`c${i}`);
+ total += c.deliver("ping");
+ total += c.count;
+ if (i % 600 === 0) nativeHeld.push(c);
+ if (i % 700 === 0) adopt(c);
+ churn(4);
+}
+console.log("total", total);
+for (const c of nativeHeld) c.deliver("again");
+console.log("native held", nativeHeld.map((c) => c.summary()).join(","));
+console.log("island delivered", broadcast("hi"));
+console.log("native held", nativeHeld.map((c) => c.summary()).join(","));
diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts
new file mode 100644
index 0000000000..e5fece5c4e
--- /dev/null
+++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.d.ts
@@ -0,0 +1,9 @@
+export declare class Channel {
+ name: string;
+ constructor(name: string);
+ onMessage(fn: (text: string) => void): void;
+ deliver(text: string): number;
+}
+export declare function churn(kib: number): number;
+export declare function adopt(channel: Channel): void;
+export declare function broadcast(text: string): number;
diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js
new file mode 100644
index 0000000000..c18a85e59c
--- /dev/null
+++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/index.js
@@ -0,0 +1,35 @@
+"use strict";
+// An island class whose instances keep the listeners they are given.
+class Channel {
+ constructor(name) {
+ this.name = name;
+ this.listeners = [];
+ this.payload = new Array(128).fill(0);
+ }
+ onMessage(fn) {
+ this.listeners.push(fn);
+ }
+ deliver(text) {
+ for (const fn of this.listeners) fn(text);
+ return this.listeners.length;
+ }
+}
+
+function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
+
+// Channels island code keeps on its own.
+const adopted = [];
+function adopt(channel) {
+ adopted.push(channel);
+}
+function broadcast(text) {
+ let n = 0;
+ for (const c of adopted) n += c.deliver(text);
+ return n;
+}
+
+module.exports = { Channel, churn, adopt, broadcast };
diff --git a/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json
new file mode 100644
index 0000000000..c10cd58a3d
--- /dev/null
+++ b/tests/corpus/6892-node-island-gc-native-subclass/node_modules/channel-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "channel-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
diff --git a/tests/corpus/6892-node-island-gc-native-subclass/package.json b/tests/corpus/6892-node-island-gc-native-subclass/package.json
new file mode 100644
index 0000000000..6fb11b47fa
--- /dev/null
+++ b/tests/corpus/6892-node-island-gc-native-subclass/package.json
@@ -0,0 +1 @@
+{ "name": "island-gc-native-subclass", "private": true, "type": "module", "scriptc": { "packages": { "channel-pkg": "node" } } }
diff --git a/tests/corpus/6893-node-island-gc-shared-module/main.mjs b/tests/corpus/6893-node-island-gc-shared-module/main.mjs
new file mode 100644
index 0000000000..47e3ba0e2e
--- /dev/null
+++ b/tests/corpus/6893-node-island-gc-shared-module/main.mjs
@@ -0,0 +1,20 @@
+// @requires-libnode
+// Cross-heap cycles through a shared module (scope-B NI-8): island code
+// pairs native sessions of a program module it shares with island peers
+// that point back at them. The pairs island code drops are garbage on both
+// sides; the ones the shared module keeps stay alive and keep working.
+import { requireModule } from "@scriptc/node";
+import { openSessions } from "./sessions.mjs";
+
+const { churn, pair } = requireModule("./peers.mjs");
+
+let total = 0;
+for (let i = 0; i < 3000; i++) {
+ total += pair(i, i % 500 === 0);
+ churn(4);
+}
+console.log("total", total);
+const open = openSessions();
+console.log("open", open.length, open.map((s) => s.id).join(","));
+console.log("sent", open.map((s) => s.send("again")).join(","));
+console.log("logs", open.map((s) => s.log.length).join(","));
diff --git a/tests/corpus/6893-node-island-gc-shared-module/peers.mjs b/tests/corpus/6893-node-island-gc-shared-module/peers.mjs
new file mode 100644
index 0000000000..e2edd234c3
--- /dev/null
+++ b/tests/corpus/6893-node-island-gc-shared-module/peers.mjs
@@ -0,0 +1,30 @@
+// Island code: island peers paired with native sessions of the shared
+// module, each pair a cycle across both heaps.
+import { keep, Session } from "./sessions.mjs";
+
+class Peer {
+ constructor(session) {
+ this.session = session;
+ this.received = 0;
+ this.payload = new Array(128).fill(1);
+ }
+ receive(text) {
+ this.received += text.length;
+ return this.received;
+ }
+}
+
+export function pair(id, keepIt) {
+ const session = new Session(id);
+ const peer = new Peer(session);
+ session.peer = peer;
+ const n = session.send(`hello ${id}`);
+ if (keepIt) keep(session);
+ return n;
+}
+
+export function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
diff --git a/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs b/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs
new file mode 100644
index 0000000000..46da173dd5
--- /dev/null
+++ b/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs
@@ -0,0 +1,20 @@
+// A program module island code imports too (shared, scope-B NI-6): native
+// sessions that island code pairs with island peers.
+export class Session {
+ constructor(id) {
+ this.id = id;
+ this.peer = null;
+ this.log = [];
+ }
+ send(text) {
+ this.log.push(text);
+ return this.peer ? this.peer.receive(text) : 0;
+ }
+}
+const open = [];
+export function keep(session) {
+ open.push(session);
+}
+export function openSessions() {
+ return open;
+}
diff --git a/tests/corpus/6894-node-island-gc-promises/lib.cjs b/tests/corpus/6894-node-island-gc-promises/lib.cjs
new file mode 100644
index 0000000000..444e208011
--- /dev/null
+++ b/tests/corpus/6894-node-island-gc-promises/lib.cjs
@@ -0,0 +1,38 @@
+"use strict";
+// Promises that tie island objects to native values.
+function make(n) {
+ return { n, payload: new Array(128).fill(n), settle: null, promise: null };
+}
+function deferred() {
+ let resolve;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve, payload: new Array(64).fill(0) };
+}
+function settle(o, v) {
+ o.settle(v);
+}
+function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
+const held = [];
+function hold(x) {
+ held.push(x);
+}
+function resolveHeld() {
+ let k = 0;
+ for (const x of held) if (x.resolve) x.resolve(++k);
+ return k;
+}
+async function awaitHeld() {
+ const out = [];
+ for (const x of held) {
+ const v = await x.promise;
+ out.push(typeof v === "number" ? v : v.n);
+ }
+ return out.join(",");
+}
+module.exports = { make, deferred, settle, churn, hold, resolveHeld, awaitHeld };
diff --git a/tests/corpus/6894-node-island-gc-promises/main.mjs b/tests/corpus/6894-node-island-gc-promises/main.mjs
new file mode 100644
index 0000000000..a133df91f8
--- /dev/null
+++ b/tests/corpus/6894-node-island-gc-promises/main.mjs
@@ -0,0 +1,40 @@
+// @requires-libnode
+// Cross-heap cycles through promises (scope-B NI-8): an island object holds
+// a native promise that settles with that island object; a native promise
+// reaction to an island promise captures the island object holding it. The
+// collector frees the settled cycles nothing holds, and never a pending
+// one that will still settle.
+import { requireModule } from "@scriptc/node";
+
+const { awaitHeld, churn, deferred, hold, make, resolveHeld, settle } = requireModule("./lib.cjs");
+
+const pending = [];
+let total = 0;
+for (let i = 0; i < 3000; i++) {
+ // island object -> native promise -> settled with the island object
+ const o = make(i);
+ o.promise = new Promise((resolve) => {
+ o.settle = (v) => resolve(v);
+ });
+ settle(o, o);
+ total += (await o.promise).n;
+
+ // island deferred -> native reaction -> captures the deferred
+ const d = deferred();
+ const reaction = d.promise.then((v) => v + d.payload.length);
+ d.resolve(i);
+ total += await reaction;
+
+ // Pending cycles, settled only after the loop.
+ if (i % 600 === 0) {
+ const late = deferred();
+ pending.push(late.promise.then((v) => v * 2 + late.payload.length));
+ hold(late);
+ }
+ if (i % 700 === 0) hold(o);
+ churn(4);
+}
+console.log("total", total);
+console.log("resolved", resolveHeld());
+console.log("pending", (await Promise.all(pending)).join(","));
+console.log("held", await awaitHeld());
diff --git a/tests/corpus/6895-node-island-gc-weakmap/lib.cjs b/tests/corpus/6895-node-island-gc-weakmap/lib.cjs
new file mode 100644
index 0000000000..fc3a780a09
--- /dev/null
+++ b/tests/corpus/6895-node-island-gc-weakmap/lib.cjs
@@ -0,0 +1,32 @@
+"use strict";
+// An island WeakMap keyed by native objects, and WeakRefs to them.
+const notes = new WeakMap();
+function note(key, value) {
+ notes.set(key, value);
+}
+function noteOf(key) {
+ return notes.get(key);
+}
+function has(key) {
+ return notes.has(key);
+}
+function make(n) {
+ return { n, payload: new Array(128).fill(n), read: null };
+}
+function read(v) {
+ return v.read();
+}
+const refs = [];
+function watch(x) {
+ refs.push(new WeakRef(x));
+}
+// Watched objects native code still holds must be alive and the same.
+function watchedAlive(objs) {
+ return refs.filter((r, i) => r.deref() === objs[i]).length;
+}
+function churn(kib) {
+ let total = 0;
+ for (let i = 0; i < kib; i++) total += new Array(128).fill(i).length;
+ return total;
+}
+module.exports = { note, noteOf, has, make, read, watch, watchedAlive, churn };
diff --git a/tests/corpus/6895-node-island-gc-weakmap/main.mjs b/tests/corpus/6895-node-island-gc-weakmap/main.mjs
new file mode 100644
index 0000000000..3f5f2e43ce
--- /dev/null
+++ b/tests/corpus/6895-node-island-gc-weakmap/main.mjs
@@ -0,0 +1,30 @@
+// @requires-libnode
+// Cross-heap cycles through an island WeakMap (scope-B NI-8): a native key
+// object maps to an island value that holds a native closure capturing the
+// key. In Node the entry dies with the key; across the boundary the
+// collector must honor the same ephemeron semantics, and keep the entries
+// (and WeakRefs) of keys native code still holds.
+import { requireModule } from "@scriptc/node";
+
+const { churn, has, make, note, noteOf, read, watch, watchedAlive } = requireModule("./lib.cjs");
+
+const kept = [];
+let total = 0;
+for (let i = 0; i < 4000; i++) {
+ const key = { i, label: `k${i}` };
+ const value = make(i);
+ value.read = () => key.i + key.label.length;
+ key.value = value;
+ note(key, value);
+ total += read(noteOf(key));
+ if (i % 500 === 0) {
+ kept.push(key);
+ watch(key);
+ }
+ churn(4);
+}
+console.log("total", total);
+console.log("kept entries", kept.filter((k) => has(k)).length, "of", kept.length);
+console.log("kept values", kept.map((k) => read(noteOf(k))).join(","));
+console.log("same value", kept.every((k) => noteOf(k) === k.value));
+console.log("watched alive", watchedAlive(kept), "of", kept.length);
diff --git a/tests/corpus/6896-node-island-native-method-member/lib.cjs b/tests/corpus/6896-node-island-native-method-member/lib.cjs
new file mode 100644
index 0000000000..923febff4c
--- /dev/null
+++ b/tests/corpus/6896-node-island-native-method-member/lib.cjs
@@ -0,0 +1,9 @@
+"use strict";
+// Island objects that hold whatever functions they are given.
+function make(n) {
+ return { n, label: `item-${n}`, cb: null, fmt: null, nested: { run: null } };
+}
+function callFromIsland(o, ...args) {
+ return o.cb(...args);
+}
+module.exports = { make, callFromIsland };
diff --git a/tests/corpus/6896-node-island-native-method-member/main.mjs b/tests/corpus/6896-node-island-native-method-member/main.mjs
new file mode 100644
index 0000000000..4474e06d11
--- /dev/null
+++ b/tests/corpus/6896-node-island-native-method-member/main.mjs
@@ -0,0 +1,40 @@
+// @requires-libnode
+// Native functions stored on island objects and called as methods from
+// native code: the island object is the receiver, arguments and results
+// cross both ways, throws propagate, and island code calling the same
+// member sees the same function.
+import { requireModule } from "@scriptc/node";
+
+const { callFromIsland, make } = requireModule("./lib.cjs");
+
+const o = make(3);
+o.cb = (a, b) => o.n * a + b;
+console.log("arrow", o.cb(2, 1), callFromIsland(o, 2, 1));
+
+o.fmt = function (prefix) {
+ return `${prefix}:${this.label}:${this.n}`;
+};
+console.log("this", o.fmt("p"), o.fmt.call(make(9), "q"));
+
+o.nested.run = (x) => [x, x * 2];
+console.log("nested", o.nested.run(4).join(","));
+
+o.cb = () => {
+ throw new RangeError("from native member");
+};
+try {
+ o.cb();
+} catch (e) {
+ console.log("caught", e instanceof RangeError, e.message);
+}
+
+console.log("optional", o.missing?.(1), typeof o.cb);
+try {
+ o.label();
+} catch (e) {
+ console.log("not callable", e instanceof TypeError);
+}
+
+const items = [make(1), make(2), make(3)];
+for (const it of items) it.cb = (k) => it.n + k;
+console.log("loop", items.map((it) => it.cb(10)).join(","));
diff --git a/tests/fixtures/node-island/gc-capsule/main.ts b/tests/fixtures/node-island/gc-capsule/main.ts
new file mode 100644
index 0000000000..7e6e21db64
--- /dev/null
+++ b/tests/fixtures/node-island/gc-capsule/main.ts
@@ -0,0 +1,17 @@
+// A native object the program keeps, remembered weakly in a Node island:
+// full collections in Node keep its WeakMap entry.
+import { check, collect, remember } from "weakkit";
+
+class Point {
+ x: number;
+ y: number;
+ constructor(x: number, y: number) {
+ this.x = x;
+ this.y = y;
+ }
+}
+
+const kept = [new Point(1, 2)];
+remember(kept[0]!);
+collect();
+console.log(check(kept[0]!));
diff --git a/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.d.ts b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.d.ts
new file mode 100644
index 0000000000..67510e05a8
--- /dev/null
+++ b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.d.ts
@@ -0,0 +1,3 @@
+export declare function remember(o: object): void;
+export declare function collect(): void;
+export declare function check(o: object): string;
diff --git a/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.js b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.js
new file mode 100644
index 0000000000..87d8677b29
--- /dev/null
+++ b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/index.js
@@ -0,0 +1,16 @@
+"use strict";
+const v8 = require("node:v8");
+const vm = require("node:vm");
+v8.setFlagsFromString("--expose-gc");
+const gc = vm.runInNewContext("gc");
+
+// Remembers a native object weakly. (A WeakRef would keep its target until
+// the current job ends, which hides a collected representative.)
+const seen = new WeakMap();
+exports.remember = (o) => {
+ seen.set(o, "remembered");
+};
+exports.collect = () => {
+ for (let i = 0; i < 3; i++) gc();
+};
+exports.check = (o) => seen.get(o) ?? "forgotten";
diff --git a/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/package.json b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/package.json
new file mode 100644
index 0000000000..87069b748e
--- /dev/null
+++ b/tests/fixtures/node-island/gc-capsule/node_modules/weakkit/package.json
@@ -0,0 +1 @@
+{ "name": "weakkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
diff --git a/tests/fixtures/node-island/gc-capsule/package.json b/tests/fixtures/node-island/gc-capsule/package.json
new file mode 100644
index 0000000000..b3de500c1f
--- /dev/null
+++ b/tests/fixtures/node-island/gc-capsule/package.json
@@ -0,0 +1 @@
+{ "name": "gc-capsule", "private": true, "type": "module", "scriptc": { "packages": { "weakkit": "node" } } }
diff --git a/tests/fixtures/node-island/gc-cycles-peers.mjs b/tests/fixtures/node-island/gc-cycles-peers.mjs
new file mode 100644
index 0000000000..c9efa547b0
--- /dev/null
+++ b/tests/fixtures/node-island/gc-cycles-peers.mjs
@@ -0,0 +1,23 @@
+// Island code for gc-cycles.mjs: island peers paired with native sessions
+// of a shared module, each pair a cycle across both heaps.
+import { keep, Session } from "./gc-cycles-sessions.mjs";
+
+class Peer {
+ constructor(session) {
+ this.session = session;
+ this.received = 0;
+ this.payload = new Array(64).fill(1);
+ }
+ receive(text) {
+ this.received += text.length;
+ return this.received;
+ }
+}
+
+export function pair(id, keepIt) {
+ const session = new Session(id);
+ session.peer = new Peer(session);
+ const n = session.send(`hello ${id}`);
+ if (keepIt) keep(session);
+ return n;
+}
diff --git a/tests/fixtures/node-island/gc-cycles-sessions.mjs b/tests/fixtures/node-island/gc-cycles-sessions.mjs
new file mode 100644
index 0000000000..f5116d04aa
--- /dev/null
+++ b/tests/fixtures/node-island/gc-cycles-sessions.mjs
@@ -0,0 +1,18 @@
+// A program module the island half of gc-cycles.mjs imports too (a shared
+// module, scope-B NI-6): native sessions paired with island peers.
+export class Session {
+ constructor(id) {
+ this.id = id;
+ this.peer = null;
+ }
+ send(text) {
+ return this.peer ? this.peer.receive(text) : 0;
+ }
+}
+const open = [];
+export function keep(session) {
+ open.push(session);
+}
+export function openSessions() {
+ return open;
+}
diff --git a/tests/fixtures/node-island/gc-cycles.cjs b/tests/fixtures/node-island/gc-cycles.cjs
new file mode 100644
index 0000000000..0b7a3c97d4
--- /dev/null
+++ b/tests/fixtures/node-island/gc-cycles.cjs
@@ -0,0 +1,69 @@
+"use strict";
+// The island half of gc-cycles.mjs (node-island-gc.test.ts).
+const { EventEmitter } = require("node:events");
+const v8 = require("node:v8");
+const vm = require("node:vm");
+
+v8.setFlagsFromString("--expose-gc");
+const gc = vm.runInNewContext("gc");
+v8.setFlagsFromString("--no-expose-gc");
+
+class Ticker extends EventEmitter {
+ constructor() {
+ super();
+ this.payload = new Array(64).fill(0);
+ }
+ tick(n) {
+ return this.emit("tick", n);
+ }
+}
+
+// An island object that will hold a native closure capturing it.
+function box(n) {
+ return { n, payload: new Array(64).fill(n), cb: null };
+}
+function call(o) {
+ return o.cb();
+}
+
+function deferred() {
+ let resolve;
+ const promise = new Promise((r) => (resolve = r));
+ return { promise, resolve };
+}
+
+// An island WeakMap keyed by native objects.
+const map = new WeakMap();
+function remember(key, value) {
+ map.set(key, value);
+}
+function recall(key) {
+ return map.get(key);
+}
+
+// Island-side holders: strong (a Set) and weak (WeakRefs).
+const held = new Set();
+function hold(x) {
+ held.add(x);
+}
+const weak = [];
+function watch(x) {
+ weak.push(new WeakRef(x));
+}
+// How many watched objects are still alive, and whether each is usable.
+function alive() {
+ let n = 0;
+ for (const r of weak) if (r.deref() !== undefined) n++;
+ return n;
+}
+
+function fullGc() {
+ gc();
+}
+
+// The process's peak resident set in KiB (one process: both heaps).
+function maxRss() {
+ return process.resourceUsage().maxRSS;
+}
+
+module.exports = { Ticker, box, call, deferred, remember, recall, hold, held, watch, alive, fullGc, maxRss };
diff --git a/tests/fixtures/node-island/gc-cycles.mjs b/tests/fixtures/node-island/gc-cycles.mjs
new file mode 100644
index 0000000000..0638a733bf
--- /dev/null
+++ b/tests/fixtures/node-island/gc-cycles.mjs
@@ -0,0 +1,105 @@
+// Cross-heap garbage cycles (scope-B NI-8), for node-island-gc.test.ts.
+// Each iteration makes and drops one cycle of every shape: a native object
+// holding an island emitter whose listener captures it, an island object
+// holding a native closure that captures it, a native promise held by an
+// island object that it settles with, an island WeakMap entry keyed by a
+// native object whose value captures the key, and a native object of a
+// shared module paired with an island object. Some cycles stay held
+// by one side only (native roots, a native closure, island roots, an
+// island WeakRef next to a native root, a pending promise); they must
+// survive every collection and keep working. Usage: gc-cycles [gc]
+// (gc: force a full V8 collection every 64 iterations).
+import { requireModule } from "@scriptc/node";
+import { openSessions } from "./gc-cycles-sessions.mjs";
+
+const lib = requireModule("./gc-cycles.cjs");
+const peers = requireModule("./gc-cycles-peers.mjs");
+const n = Number(process.argv[2] ?? "1000");
+const forceGc = process.argv[3] === "gc";
+
+class Holder {
+ constructor(i) {
+ this.i = i;
+ this.hits = 0;
+ this.ticker = new lib.Ticker();
+ this.ticker.on("tick", (k) => {
+ this.hits += k;
+ });
+ }
+}
+
+const nativeKept = [];
+const closures = [];
+const pending = [];
+let sum = 0;
+for (let i = 0; i < n; i++) {
+ // 1. native -> island emitter -> native listener -> native
+ const h = new Holder(i);
+ h.ticker.tick(1);
+ sum += h.hits;
+
+ // 2. island object -> native closure -> island object
+ const b = lib.box(i);
+ b.cb = () => b.n + b.payload.length;
+ sum += lib.call(b);
+
+ // 3. island object -> native promise -> settled with the island object
+ const o = lib.box(i);
+ const np = new Promise((resolve) => {
+ o.cb = () => resolve(o);
+ });
+ o.p = np;
+ lib.call(o);
+ sum += (await np).n;
+
+ // 4. island WeakMap: native key -> island value -> native closure -> key
+ const key = { i };
+ const value = lib.box(i);
+ value.cb = () => key.i;
+ key.value = value;
+ lib.remember(key, value);
+ sum += lib.call(lib.recall(key));
+
+ // 5. shared-module native session <-> island peer
+ sum += peers.pair(i, i < 10000 && i % 967 === 0);
+
+ // Some cycles stay reachable from one side only (the same ones for
+ // every n of at least 10000, so what stays alive does not grow with n).
+ const keep = i < 10000;
+ if (keep && i % 997 === 0) nativeKept.push(h);
+ if (keep && i % 991 === 0) closures.push(() => lib.call(b) + lib.call(key.value));
+ if (keep && i % 983 === 0) lib.hold(o);
+ if (keep && i % 977 === 0) {
+ nativeKept.push(key);
+ lib.watch(key);
+ }
+ if (keep && i % 971 === 0) {
+ const d = lib.deferred();
+ const ticker = new Holder(-i);
+ pending.push(
+ d.promise.then((v) => {
+ ticker.ticker.tick(v);
+ return ticker.hits;
+ }),
+ );
+ lib.hold(d);
+ }
+ if (forceGc && i % 64 === 63) lib.fullGc();
+}
+lib.fullGc();
+await new Promise((resolve) => setTimeout(resolve, 1));
+lib.fullGc();
+
+// Everything held from one side still works.
+let check = 0;
+for (const k of nativeKept) check += k instanceof Holder ? k.ticker.tick(2) && k.hits : lib.call(k.value);
+for (const f of closures) check += f();
+for (const o of lib.held) {
+ if (o.resolve) o.resolve(3);
+ else check += (await o.p).n + 1;
+}
+for (const p of pending) check += await p;
+for (const s of openSessions()) check += s.send("again");
+const watched = nativeKept.filter((k) => !(k instanceof Holder)).length;
+console.log("sum", sum, "check", check, "watched alive", lib.alive(), "of", watched);
+console.error(`peak rss ${lib.maxRss()} KiB`);
diff --git a/tests/fixtures/node-island/gc-held.cjs b/tests/fixtures/node-island/gc-held.cjs
new file mode 100644
index 0000000000..5c2866ef63
--- /dev/null
+++ b/tests/fixtures/node-island/gc-held.cjs
@@ -0,0 +1,13 @@
+"use strict";
+// The island half of gc-held.mjs (node-island-gc.test.ts): keeps every
+// native callback it receives.
+const kept = [];
+function keep(cb) {
+ kept.push(cb);
+}
+function callKept() {
+ let s = 0;
+ for (const cb of kept) s += cb();
+ return s;
+}
+module.exports = { keep, callKept };
diff --git a/tests/fixtures/node-island/gc-held.mjs b/tests/fixtures/node-island/gc-held.mjs
new file mode 100644
index 0000000000..f2f8052b09
--- /dev/null
+++ b/tests/fixtures/node-island/gc-held.mjs
@@ -0,0 +1,12 @@
+// Long-lived exports (node-island-gc.test.ts): the island keeps one native
+// callback per iteration for the rest of the run, so no collection frees
+// any of them. Usage: gc-held
+import { requireModule } from "@scriptc/node";
+
+const lib = requireModule("./gc-held.cjs");
+const n = Number(process.argv[2] ?? "1000");
+for (let i = 0; i < n; i++) {
+ const j = i;
+ lib.keep(() => j);
+}
+console.log("kept", n, "sum", lib.callKept());
diff --git a/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json b/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json
new file mode 100644
index 0000000000..10edd65b7d
--- /dev/null
+++ b/tests/harness/corpus-costs/6890-node-island-gc-emitter/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1400, "san": 2000 }
diff --git a/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json b/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json
new file mode 100644
index 0000000000..99d1bba595
--- /dev/null
+++ b/tests/harness/corpus-costs/6891-node-island-gc-closure/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1350, "san": 2250 }
diff --git a/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json b/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json
new file mode 100644
index 0000000000..005ea43a23
--- /dev/null
+++ b/tests/harness/corpus-costs/6892-node-island-gc-native-subclass/main.ts.json
@@ -0,0 +1 @@
+{ "plain": 1500, "san": 2200 }
diff --git a/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json b/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json
new file mode 100644
index 0000000000..626a48272f
--- /dev/null
+++ b/tests/harness/corpus-costs/6893-node-island-gc-shared-module/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1150, "san": 1900 }
diff --git a/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json b/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json
new file mode 100644
index 0000000000..ae5c087c81
--- /dev/null
+++ b/tests/harness/corpus-costs/6894-node-island-gc-promises/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1550, "san": 2600 }
diff --git a/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json b/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json
new file mode 100644
index 0000000000..a18edc536c
--- /dev/null
+++ b/tests/harness/corpus-costs/6895-node-island-gc-weakmap/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1300, "san": 2000 }
diff --git a/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json b/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json
new file mode 100644
index 0000000000..603773b079
--- /dev/null
+++ b/tests/harness/corpus-costs/6896-node-island-native-method-member/main.mjs.json
@@ -0,0 +1 @@
+{ "plain": 1200, "san": 2200 }
diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp
index ba2af663f8..f52415c56b 100644
--- a/tests/harness/lsan.supp
+++ b/tests/harness/lsan.supp
@@ -20,6 +20,11 @@ leak:node::crypto::GetSystemStoreCACertificates
leak:node::crypto::GetExtraCACertificates
leak:node::crypto::GetOpenSSLSystemCertificates
leak:node::crypto::LoadCertsFromBIO
+# The same certificates under a stripped libnode (the packs the
+# libnode-packs workflow builds), where the loading threads' Node frames
+# have no names: each one is parsed through libnode's OpenSSL PEM reader.
+# The runtime has no OpenSSL of its own, so only libnode reaches it.
+leak:PEM_ASN1_read_bio
# The certificates' lazily decoded keys, cached in them when a root store
# is built.
leak:node::crypto::NewRootCertStore
diff --git a/tests/harness/node-island-gc.test.ts b/tests/harness/node-island-gc.test.ts
new file mode 100644
index 0000000000..3efd6329e9
--- /dev/null
+++ b/tests/harness/node-island-gc.test.ts
@@ -0,0 +1,148 @@
+/* Cross-heap cycle collection (scope-B NI-8) where a differential corpus
+ * program cannot show it. tests/fixtures/node-island/gc-cycles.mjs makes
+ * and drops one cross-heap cycle of each shape per iteration and keeps some
+ * reachable from one side only; the corpus (6890-6895) pins the same shapes
+ * against Node.
+ * - The memory and the handles a program holds do not grow with the
+ * number of cycles it dropped (10k against 100k iterations).
+ * - Forcing full collections throughout (and, in the sanitized lane,
+ * under ASan) frees nothing still reachable: the run prints exactly
+ * what a run with the collector switched off prints, and never trips the
+ * bridge's use-after-collection check.
+ * - A native object the program keeps, which crosses in a capsule only
+ * the export table holds, keeps its representative: the island's weak
+ * maps still find it (tests/fixtures/node-island/gc-capsule).
+ * - Exports the island keeps for the whole run schedule no major GCs of
+ * their own once a GC found them live (tests/fixtures/node-island/gc-held.mjs).
+ * Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */
+import { execFile } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { beforeAll, describe, expect, test } from "vitest";
+import { compile } from "@scriptc/compiler";
+import { libnodePack } from "./libnode.js";
+
+const execFileAsync = promisify(execFile);
+const repoRoot = join(import.meta.dirname, "../..");
+const fixtures = join(repoRoot, "tests/fixtures/node-island");
+const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-gc");
+const sanitize = process.env["SCRIPTC_SAN"] === "1";
+const skip = libnodePack() === null;
+
+interface Run {
+ stdout: string;
+ stderr: string;
+ totals: Record;
+ rss: number;
+}
+
+let binary = "";
+
+async function run(args: string[], env: Record = {}): Promise {
+ const { stdout, stderr } = await execFileAsync(binary, args, {
+ cwd: fixtures,
+ env: { ...process.env, SCRIPTC_NODE_ISLANDS_TRACE: "gc", ...env },
+ encoding: "utf8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ expect(stderr).not.toMatch(/used after V8 collected|AddressSanitizer|LeakSanitizer/);
+ const line = /\[node-islands\] gc totals: (.*)/.exec(stderr);
+ expect(line).not.toBeNull();
+ const totals: Record = {};
+ for (const m of line![1]!.matchAll(/([a-z ]+?) (\d+(?:\.\d+)?)(?: ms)?(?:,|$)/g))
+ totals[m[1]!.trim()] = Number(m[2]);
+ const rss = Number(/peak rss (\d+) KiB/.exec(stderr)?.[1]);
+ return { stdout, stderr, totals, rss };
+}
+
+describe.skipIf(skip)("Node island cross-heap cycles", () => {
+ beforeAll(async () => {
+ mkdirSync(outDir, { recursive: true });
+ const result = await compile(join(fixtures, "gc-cycles.mjs"), {
+ outPath: join(outDir, "gc-cycles"),
+ outDir,
+ sanitize,
+ });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ binary = result.binaryPath;
+ }, 300_000);
+
+ test("memory and held handles stay flat as dropped cycles grow", async () => {
+ const small = await run(["10000"]);
+ const large = await run(["100000"]);
+ for (const r of [small, large]) {
+ expect(r.stdout).toMatch(/^sum \d+ check \d+ watched alive (\d+) of \1\n$/);
+ // Every major GC was analyzed, and the collector freed nearly every
+ // dropped cycle (seven representatives and five imports each).
+ expect(r.totals["analyzed"]).toBe(r.totals["major"]);
+ }
+ expect(large.totals["freed exports"]).toBeGreaterThan(0.99 * 7 * 100_000 - 1000);
+ expect(large.totals["freed imports"]).toBeGreaterThan(0.99 * 5 * 100_000 - 1000);
+ // What stays alive is what the program keeps, the same for both.
+ for (const key of ["held exports", "held handles", "representatives", "import entries"])
+ expect(large.totals[key], key).toBe(small.totals[key]);
+ expect(large.totals["held handles"]).toBeLessThan(200);
+ // Peak memory does not grow with the cycles dropped (ASan's quarantine
+ // makes the sanitized lane's resident set meaningless).
+ if (!sanitize) expect(large.rss).toBeLessThan(small.rss * 1.5 + 32 * 1024);
+ }, 600_000);
+
+ test("forced collections free nothing still reachable from either side", async () => {
+ const n = sanitize ? "2000" : "6000";
+ const off = await run([n, "gc"], { SCRIPTC_NODE_ISLANDS_GC: "off" });
+ const on = await run([n, "gc"]);
+ expect(off.totals["analyzed"]).toBe(0);
+ expect(on.totals["analyzed"]).toBe(on.totals["major"]);
+ expect(on.totals["major"]).toBeGreaterThan(20);
+ expect(on.totals["freed exports"]).toBeGreaterThan(0);
+ expect(on.stdout).toMatch(/^sum \d+ check \d+ watched alive (\d+) of \1\n$/);
+ expect(on.stdout).toBe(off.stdout);
+ }, 600_000);
+
+ test("exports the island keeps do not schedule major GCs of their own", async () => {
+ // 200k long-lived representatives. Each export adds to the external
+ // memory V8 schedules major GCs by, until a GC finds that the exports
+ // made since the previous one were not garbage.
+ const dir = join(outDir, "gc-held");
+ mkdirSync(dir, { recursive: true });
+ const result = await compile(join(fixtures, "gc-held.mjs"), {
+ outPath: join(dir, "gc-held"),
+ outDir: dir,
+ sanitize,
+ });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ const n = 200_000;
+ const { stdout, stderr } = await execFileAsync(result.binaryPath, [String(n)], {
+ cwd: fixtures,
+ env: { ...process.env, SCRIPTC_NODE_ISLANDS_TRACE: "gc" },
+ encoding: "utf8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ expect(stdout).toBe(`kept ${n} sum ${(n * (n - 1)) / 2}\n`);
+ const major = Number(/\[node-islands\] gc totals: major (\d+)/.exec(stderr)?.[1]);
+ expect(major).toBeGreaterThan(0);
+ expect(major).toBeLessThanOrEqual(3);
+ }, 300_000);
+
+ test("a native object the program keeps keeps its identity in island weak maps", async () => {
+ // A typed object crosses in a capsule the export table alone holds;
+ // the program holds the object itself.
+ const dir = join(outDir, "gc-capsule");
+ mkdirSync(dir, { recursive: true });
+ const result = await compile(join(fixtures, "gc-capsule/main.ts"), {
+ outPath: join(dir, "main"),
+ outDir: dir,
+ sanitize,
+ });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ const { stdout } = await execFileAsync(result.binaryPath, [], {
+ env: { ...process.env, XDG_CACHE_HOME: join(dir, "cache") },
+ encoding: "utf8",
+ });
+ expect(stdout).toBe("remembered\n");
+ }, 300_000);
+});
diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts
index 3bbe1865fe..4b8c6df628 100644
--- a/tests/harness/node-island-handles.test.ts
+++ b/tests/harness/node-island-handles.test.ts
@@ -1,10 +1,10 @@
/* Island object handles (scope-B NI-4) where a differential corpus program
* cannot show it: handles to Node objects the program dropped are released,
* so the import table does not grow with the work a program does (acyclic
- * garbage; cycles across both heaps wait for NI-8). The corpus (6812-6815)
- * pins handle semantics against Node. An island package ships with the
- * program, so the executable runs without the tree it was built from.
- * Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */
+ * garbage; node-island-gc.test.ts covers cycles across both heaps). The
+ * corpus (6812-6815) pins handle semantics against Node. An island package
+ * ships with the program, so the executable runs without the tree it was
+ * built from. Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */
import { execFile } from "node:child_process";
import { cpSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts
index e3e82216ca..215d75384f 100644
--- a/tests/harness/node-island.test.ts
+++ b/tests/harness/node-island.test.ts
@@ -308,7 +308,7 @@ describe.skipIf(skip)("Node islands in-process", () => {
});
expect(result.code).toBe(1);
expect(result.stderr).toBe(
- `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`,
+ `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 10)\n`,
);
});