0133 - A module roots the values it holds directly
Date: 2026-10-09
Area: gc, modules, sandbox
Related: ADR 0105
Context
A TGocciaModule holds its exports in three shapes. A local export is a binding
in the module's environment scope, and SetEnvironment roots that scope. A
forwarded export belongs to its source module. A value export
(AddExportValue, UpdateExportValue) stores the value itself. Next to the
bindings, the export table (ExportsTable) keeps a value per name: the value of
a value export, the snapshot a local export took when it was linked, or, for the
YAML, TOML, JSON5 and indexed-data modules, the only record of the export,
written there directly. Nothing rooted the value exports or the table. The only
path that marked them was the module namespace object, which is created only
when something asks for the namespace.
The sandbox's fs and goccia modules and the JSON, text and bytes modules
publish through AddExportValue; the data modules above write the table. A
default or named import such as import fs from "fs" never builds a namespace
object, so a collection between the module's creation and the importer reading
it freed the exported value while the module still handed it out. The entry's
import check (ValidateStaticNamedImports → CanResolveExport) then ran a type
test on the freed object. Under a small --max-memory a single run faults; in a
host that reuses one process for many scripts, the extra live heap from earlier
runs moves the first collection early enough that an ordinary run faults. A
local export's snapshot had the same gap: once the binding was reassigned, the
old value could be freed while the table still named it, and a namespace object
built afterwards marked the freed object.
Decision
A module is a garbage-collection root source for the values it holds directly.
TGocciaModule owns a TGocciaModuleRoots (a TGCRootSource) for its
lifetime; on every collection it marks the module's value-bound exports, its
export table and its evaluation promise. It deliberately does not read local
bindings: their environment scope is a root of its own, and reading a binding in
its temporal dead zone would raise inside the collector.
A module created before the thread has a collector (an embedder building a
host module before its engine) starts the collector in its constructor, as the
engine would, so its root source registers; TGarbageCollector.Initialize is
idempotent. A root source marks only for the collector it registered with, and
a host can keep a module across a collector replacement (a thread pool that
resets its runtime between work items), so the module re-registers whenever it
takes a value: in SetExportBinding, UpdateExportValue and the
ExportsTable accessor host modules write through, the pattern
TGocciaTimerQueue.EnsureRoots already uses.
The root is tied to the module rather than to each value. AddRootObject keeps
a set, not a count, so rooting each exported value would let the first module
freed unroot a value that another module still exports.
Consequences
- A value in a module's bindings or export table lives as long as the module, with or without a namespace object. That includes a local export's linking snapshot after the binding has been reassigned: the table keeps naming it, so it is kept until the module is freed.
- Freeing a module releases its values at the next collection.
Goccia.Modules.ExportRoots.Testchecks both directions, for value exports, direct table writes and replaced snapshots, with a probe value that records its own destruction. - Each module adds one entry to the collector's root-source list and a walk of its export maps per collection; a program has few modules, so the cost is negligible next to marking the heap.