Collect garbage cycles across the scriptc and V8 heaps (NI-8) (#1908)

This commit is contained in:
Chris Tate
2026-10-29 15:33:24 -05:00
committed by GitHub
62 changed files with 2225 additions and 60 deletions
+2
View File
@@ -174,6 +174,8 @@ title: "Limitations"
<include>../limitations/runtime-differences-from-node-js.mdx</include>
<include>../limitations/runtime-differences-from-node-js/event-order-across-native-code-and-islands.mdx</include>
### Array bounds and runtime errors
<include>../limitations/array-bounds-and-runtime-errors.mdx</include>
@@ -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.
@@ -396,6 +396,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"),
@@ -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/";
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6890-node-island-gc-emitter/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6891-node-island-gc-closure/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6892-node-island-gc-native-subclass/main.ts"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/6893-node-island-gc-shared-module/sessions.mjs",
"<repo>/tests/corpus/6893-node-island-gc-shared-module/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6894-node-island-gc-promises/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6895-node-island-gc-weakmap/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6896-node-island-native-method-member/main.mjs"
],
"diags": []
}
+544 -27
View File
@@ -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 <cppgc/allocation.h>
#include <cppgc/garbage-collected.h>
#include <cppgc/heap-consistency.h>
#include <cppgc/heap-state.h>
#include <cppgc/persistent.h>
#include <cppgc/visitor.h>
#include <dlfcn.h>
#include <node.h>
#include <node_buffer.h>
#include <node_api.h>
#include <node_version.h>
#include <uv.h>
#include <v8-cppgc.h>
#include <v8.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <functional>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <new>
#include <string>
#include <unordered_map>
#include <unordered_set>
@@ -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<node::MultiIsolatePlatform> g_platform;
node::EmbedderSnapshotData::Pointer g_snapshot;
std::unique_ptr<node::CommonEnvironmentSetup> 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> 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<Function> *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> value;
v8::TracedReference<Value> value;
std::shared_ptr<v8::BackingStore> 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<uint32_t> traced{0}; // the GC cycle that last traced it
};
std::vector<Slot> 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<std::unique_ptr<Slot>> g_slots(1); // index = id; id 0 is never used
std::vector<uint32_t> g_free_ids;
std::unordered_multimap<int, uint32_t> g_ids;
uint32_t g_live_handles = 0;
// Strings of out-values: valid until the next outermost entry.
std::deque<std::string> g_scratch;
int g_entry_depth = 0;
void NoteFresh(uint32_t entry);
int IdentityHash(Local<Value> v) {
if (v->IsObject()) return v.As<Object>()->GetIdentityHash();
@@ -752,7 +792,7 @@ uint32_t Import(Isolate *isolate, Local<Value> v, std::shared_ptr<v8::BackingSto
int hash = IdentityHash(v);
auto range = g_ids.equal_range(hash);
for (auto it = range.first; it != range.second; ++it) {
Slot &slot = g_slots[it->second];
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<Value> v, std::shared_ptr<v8::BackingSto
g_free_ids.pop_back();
} else {
id = static_cast<uint32_t>(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<std::mutex> 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 &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;
}
Local<Value> 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);
[[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();
}
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);
Local<Value> HandleValue(Isolate *isolate, uint32_t id) {
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 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<v8::BackingStore> store;
{
std::lock_guard<std::mutex> 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<Value>();
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<Object> rep; // the representative, traced only by the collector
bool rooted = true;
std::vector<uint32_t> reach; // what the export reaches (SCR_NODE_GC_EXPORT marks exports)
std::atomic<uint32_t> 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<uint32_t, std::unique_ptr<ExportGc>> 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<uint32_t> 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<v8::JSVisitor *>(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<Roots> {
public:
void Trace(cppgc::Visitor *visitor) const {
std::lock_guard<std::mutex> 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<Fresh> {
public:
void Trace(cppgc::Visitor *visitor) const {
std::lock_guard<std::mutex> 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<Reach> {
public:
Reach(uint32_t id, uint32_t gen) : id_(id), gen_(gen) {}
void Trace(cppgc::Visitor *visitor) const {
std::lock_guard<std::mutex> 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<int64_t>(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<Roots> *g_roots = nullptr;
cppgc::Persistent<Fresh> *g_fresh_root = nullptr;
Global<v8::Private> g_gc_key;
Global<v8::ObjectTemplate> 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<std::mutex> 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<std::mutex> 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<uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
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<std::pair<uint32_t, uint32_t>> 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<uint32_t> 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<std::mutex> 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<void> &info) {
it->second.Reset();
g_exports.erase(it);
}
{
// Nothing traced the representative: V8 reclaims its traced reference.
std::lock_guard<std::mutex> 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<void> &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<Value> data) {
return data->IsUint32() ? data.As<v8::Uint32>()->Value() : 0;
uint32_t id = data->IsUint32() ? data.As<v8::Uint32>()->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> 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> context, uint32_t id, Local<Object> rep) {
if (g_cpp_heap == nullptr) return;
if (g_carrier_template.IsEmpty()) {
Local<v8::ObjectTemplate> 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<Object> carrier = g_carrier_template.Get(isolate)->NewInstance(context).ToLocalChecked();
uint32_t gen = ++g_export_gen;
Reach *reach = cppgc::MakeGarbageCollected<Reach>(g_cpp_heap->GetAllocationHandle(), id, gen);
v8::Object::Wrap<v8::CppHeapPointerTag::kDefaultTag>(isolate, carrier, reach);
// A Proxy holds its target: an array export's private lives there.
Local<Object> holder = rep->IsProxy() ? rep.As<v8::Proxy>()->GetTarget().As<Object>() : rep;
holder->SetPrivate(context, g_gc_key.Get(isolate), carrier).Check();
{
std::lock_guard<std::mutex> lock(g_gc_lock);
std::unique_ptr<ExportGc> &x = g_export_gc[id];
x = std::make_unique<ExportGc>();
x->rep.Reset(isolate, rep);
x->gen = gen;
}
g_cycle_new_exports++;
NoteFresh(id | SCR_NODE_GC_EXPORT);
ReportExternal(isolate);
}
Local<Value> ExportValue(Isolate *isolate, Local<Context> 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<Object> existing = found->second.Get(isolate);
RefreshMirror(isolate, context, id, in->flags, existing);
return existing;
@@ -1509,6 +1895,7 @@ Local<Value> ExportValue(Isolate *isolate, Local<Context> 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<Object> &slot = g_exports[id];
slot.Reset(isolate, obj);
slot.SetWeak(reinterpret_cast<void *>(static_cast<uintptr_t>(id)), OnExportGone,
@@ -1531,6 +1918,11 @@ void ExportSettle(uint32_t id, int rejected, const ScrNodeValue *value) {
}
void DropAllExports() {
{
std::lock_guard<std::mutex> 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<uint32_t> refresh, dead;
{
std::lock_guard<std::mutex> 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<Value>();
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<Object>();
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<std::mutex> 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<uint32_t>(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<Roots>(
cppgc::MakeGarbageCollected<Roots>(g_cpp_heap->GetAllocationHandle()));
g_fresh_root = new cppgc::Persistent<Fresh>(
cppgc::MakeGarbageCollected<Fresh>(g_cpp_heap->GetAllocationHandle()));
isolate->AddGCPrologueCallback(
OnGcPrologue, nullptr,
static_cast<v8::GCType>(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> context, Local<Value> 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> context, Local<Value> 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
+22 -17
View File
@@ -9,7 +9,7 @@
* node packages/libnode-common/fetch-dev-pack.mjs --out <pack directory> */
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 });
}
@@ -212,6 +212,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")],
];
+11
View File
@@ -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,
+46 -5
View File
@@ -17,7 +17,7 @@
#include <stddef.h>
#include <stdint.h>
#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 */
+21
View File
@@ -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. */
+11
View File
@@ -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
@@ -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;
+547
View File
@@ -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 <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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
+46 -4
View File
@@ -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);
}
@@ -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";
@@ -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 };
@@ -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(","));
@@ -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 };
@@ -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));
@@ -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(","));
@@ -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;
@@ -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 };
@@ -0,0 +1 @@
{ "name": "channel-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "island-gc-native-subclass", "private": true, "type": "module", "scriptc": { "packages": { "channel-pkg": "node" } } }
@@ -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(","));
@@ -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;
}
@@ -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;
}
@@ -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 };
@@ -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());
@@ -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 };
@@ -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);
@@ -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 };
@@ -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(","));
+17
View File
@@ -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]!));
@@ -0,0 +1,3 @@
export declare function remember(o: object): void;
export declare function collect(): void;
export declare function check(o: object): string;
+16
View File
@@ -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";
@@ -0,0 +1 @@
{ "name": "weakkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
+1
View File
@@ -0,0 +1 @@
{ "name": "gc-capsule", "private": true, "type": "module", "scriptc": { "packages": { "weakkit": "node" } } }
+23
View File
@@ -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;
}
+18
View File
@@ -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;
}
+69
View File
@@ -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 };
+105
View File
@@ -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 <n> [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`);
+13
View File
@@ -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 };
+12
View File
@@ -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 <n>
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());
@@ -0,0 +1 @@
{ "plain": 1400, "san": 2000 }
@@ -0,0 +1 @@
{ "plain": 1350, "san": 2250 }
@@ -0,0 +1 @@
{ "plain": 1500, "san": 2200 }
@@ -0,0 +1 @@
{ "plain": 1150, "san": 1900 }
@@ -0,0 +1 @@
{ "plain": 1550, "san": 2600 }
@@ -0,0 +1 @@
{ "plain": 1300, "san": 2000 }
@@ -0,0 +1 @@
{ "plain": 1200, "san": 2200 }
+5
View File
@@ -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
+148
View File
@@ -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<string, number>;
rss: number;
}
let binary = "";
async function run(args: string[], env: Record<string, string> = {}): Promise<Run> {
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<string, number> = {};
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);
});
+4 -4
View File
@@ -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";
+1 -1
View File
@@ -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`,
);
});