mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Collect garbage cycles across the scriptc and V8 heaps (NI-8) (#1908)
This commit is contained in:
@@ -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>
|
||||
|
||||
+3
@@ -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/";
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6890-node-island-gc-emitter/main.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6891-node-island-gc-closure/main.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6892-node-island-gc-native-subclass/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+7
@@ -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": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6894-node-island-gc-promises/main.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6895-node-island-gc-weakmap/main.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6896-node-island-native-method-member/main.mjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")],
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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(","));
|
||||
Generated
Vendored
+9
@@ -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;
|
||||
Generated
Vendored
+35
@@ -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 };
|
||||
Generated
Vendored
+1
@@ -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
@@ -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]!));
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export declare function remember(o: object): void;
|
||||
export declare function collect(): void;
|
||||
export declare function check(o: object): string;
|
||||
+16
@@ -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";
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "weakkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "gc-capsule", "private": true, "type": "module", "scriptc": { "packages": { "weakkit": "node" } } }
|
||||
+23
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user