frostney / frostney/GocciaScript
Rooted-temporaries discipline for interpreter store paths; growth-gate collect-before-refuse blocked on it
Nobody has claimed this yet.
- Dominant language
- Pascal
- Stars
- 20
- Forks
- 3
- Avg merge
- 3d 4h
- Merged PRs (30d)
- 45
Description
Summary
Establish a rooted-temporaries discipline for the tree-walking interpreter's property-store paths (receiver and incoming value) and the native builders' descriptor values. This is the structural precondition for making the growth gate collect before refusing (H4) — which was implemented, measured to work, and then proven to be a use-after-free as currently reachable; the prototype is parked below, not shipped.
Why
The growth gate (RequireNativeBytes) refuses against instantaneous BytesAllocated without collecting, so whether a storage doubling is refused depends on collectable garbage (measured: a 4 MiB-ceiling workload refused at C=32,766 with ~2 MB of the ceiling being garbage; with periodic gc() — or with the parked prototype — it reaches the true arithmetic crossing at C=65,534). Fixing that requires the gate to become a collecting safe point — but the gate sits inside the insertion window, where the incoming property value is held only in Pascal locals and a descriptor not yet in the map: by construction unreachable from every GC root in the interpreter. Deterministic canary proof (a value whose Recycle records the sweep):
with gate-collect: [probe] collections during gated growth: 1
[probe] in-flight value swept by the gate: TRUE
baseline: TGocciaMemoryLimitError (0 collections) # non-vacuity
Bytecode mode is safe for these sites only via TGocciaVMStackRoot marking the whole register stack; mode-dependent memory safety is not shippable, and native builders run with Pascal locals in both modes.
Current behavior
Call-site rootedness table (gate sites, live-across-the-gate state):
| Gate call site | Reached from | Rooted? |
|---|---|---|
Goccia.Values.Shape.pas:271 ← OrderedStringMap.pas:415 (Add) |
every JS property store | NO (interpreter) |
same ← OrderedStringMap.pas:273 (Grow) / :316 (Compact) |
Add |
NO |
HoleValue.pas:59 ← ArrayValue.pas SetElement/SetProperty/DefineProperty family |
arr[i] = v |
NO |
HoleValue.pas:59 ← ArrayValue.pas:1287 (new Array(n)) |
native ctor | yes (Evaluator.pas:3738 temp-root) |
HoleValue.pas:59 ← ArrayValue.pas:749/769 (length = n) |
length descriptor | unproven |
Evidence of the gap: AST.Expressions.pas:1812/1830 hold Obj and the RHS in locals through AssignProperty → FProperties.Add with no AddTempRoot; EvaluateObject temp-roots the literal but not each PropertyValue; allocation is not a safe point, so the interpreter's model is "collections only at the 17 CollectInterpreterMemoryPressure(Result) checkpoints" — a discipline the gate would sit outside. ~377 TGocciaPropertyDescriptorData.Create( sites in source/units; the existing builder contract (test-cli.ts probe comments, ADR 0105) roots containers, not incoming values.
The same unrooted-local class is already reachable through EXISTING safe points on pristine main: #1156 (interpreter nil-dereference at a 16 MiB ceiling) and the earlier guest-catchable Object reference is Nil chip.
Expected behavior
- A stated, auditable rooting contract for store paths: receiver + incoming value (or the descriptor graph) reachable from a GC root across any potential safe point in the store sequence — via active-root frames, temp roots, or extending the arguments-container pattern of ADR 0105.
- #1156 fixed as a consequence (or at least covered by the audit).
- Then H4 lands:
RequireNativeBytesforce-collects once and re-tests before raising the uncatchableTGocciaMemoryLimitError(contract unchanged; over-whole-budget still refuses without collecting; damper shared viaFForcedCollectFloor— it records a heap fact, both paths force the sameCollect, so a gate-private floor would only re-learn what the other path proved).
Scope notes
- Parked, proven-working-but-unsafe prototype (apply only after the discipline exists):
diff --git a/source/units/Goccia.GarbageCollector.pas b/source/units/Goccia.GarbageCollector.pas
index 4a4d6177..64705adb 100644
--- a/source/units/Goccia.GarbageCollector.pas
+++ b/source/units/Goccia.GarbageCollector.pas
@@ -122,7 +122,7 @@ type
function GetManagedObjectCount: Integer;
function GetWatermark: Integer; {$IFDEF FPC}inline;{$ENDIF}
procedure ClearActiveRootEntries(const AObject: TGCManagedObject);
- function ShouldForceReservationCollection(const ABytes: Int64): Boolean;
+ function ShouldForceLimitCollection(const ABytes: Int64): Boolean;
protected
procedure MarkRoots; virtual;
procedure TraceWeakReferences;
@@ -187,6 +187,20 @@ type
// collection. Tracing through old roots keeps those young objects live.
procedure CollectYoung(const AWatermark: Integer);
procedure ResetPeakBytesAllocated;
+
+ // Forces one collection when a request of ABytes does not currently fit
+ // under MaxBytes and a collection could plausibly change that, then
+ // re-tests the fit. Returns True only when ABytes fits afterwards.
+ //
+ // Charges nothing: the charged path (TryReserveExternalBytes) adds the
+ // bytes itself once this returns True, and the uncharged growth gate
+ // (Goccia.MemoryLimit) permits the growth without ever charging.
+ // False also covers the shapes no collection could help — a request
+ // larger than the whole budget, a repeat of a request the last forced
+ // collection already refused, and re-entrant calls from inside the
+ // collector — none of which walk the heap.
+ function TryCollectForLimitedBytes(const ABytes: Int64;
+ const AProtect: TGCManagedObject = nil): Boolean;
function TryReserveExternalBytes(const ABytes: Int64;
const AProtect: TGCManagedObject = nil): Boolean;
procedure ReleaseExternalBytes(const ABytes: Int64);
@@ -1040,24 +1054,31 @@ begin
FPeakBytesAllocated := FBytesAllocated;
end;
-function TGarbageCollector.ShouldForceReservationCollection(
+function TGarbageCollector.ShouldForceLimitCollection(
const ABytes: Int64): Boolean;
begin
- // Last resort: a reservation is only refused once a collection has actually
- // been attempted. The pressure heuristic cannot decide this on its own — it
- // triggers at a fixed reserve below the ceiling, so a reservation larger
- // than that reserve used to be refused with reclaimable garbage still on the
- // heap whenever the live set sat below the trigger.
+ // Last resort: a reservation — or a gated growth — is only refused once a
+ // collection has actually been attempted. The pressure heuristic cannot
+ // decide this on its own: it triggers at a fixed reserve below the ceiling,
+ // so a request larger than that reserve used to be refused with reclaimable
+ // garbage still on the heap whenever the live set sat below the trigger.
//
// Two shapes are refused without walking the heap, because for them no
// collection could change the answer. A request larger than the whole budget
// never fits. And once a forced collection has left FForcedCollectFloor
// bytes live, no later collection gets the heap below that level, so a
// request that does not fit beside the floor cannot be made to fit either —
- // which is what keeps a guest that catches the RangeError and retries at
- // O(1) per attempt instead of a full mark-and-sweep each time. The floor is
- // per request size, so a smaller request that the floor does not rule out
- // still forces its collection.
+ // which is what keeps a guest that retries a refused request at O(1) per
+ // attempt instead of a full mark-and-sweep each time. The floor is per
+ // request size, so a smaller request that the floor does not rule out still
+ // forces its collection.
+ //
+ // The floor is shared by both refusal paths on purpose. It records a fact
+ // about the heap ("the last forced collection left this many bytes live"),
+ // not about the caller, and both paths force the same full collection and
+ // then apply the same fit test, so a level that defeated one defeats the
+ // other at the same request size. A second, gate-private floor would only
+ // buy each path the right to re-learn what the other just proved.
Result := not FCollecting and not FMemoryLimitFiring and
(FMaxBytes > 0) and (ABytes <= FMaxBytes) and
(FBytesAllocated <= High(Int64) - ABytes) and
@@ -1066,6 +1087,21 @@ begin
(FForcedCollectFloor <= FMaxBytes - ABytes));
end;
+function TGarbageCollector.TryCollectForLimitedBytes(const ABytes: Int64;
+ const AProtect: TGCManagedObject): Boolean;
+begin
+ if not ShouldForceLimitCollection(ABytes) then
+ Exit(False);
+ CollectForMemoryPressure(AProtect, True);
+ Result := (FBytesAllocated <= High(Int64) - ABytes) and
+ ((FMaxBytes <= 0) or (FBytesAllocated + ABytes <= FMaxBytes));
+ // Record the level this collection could not get below, so a retry of a
+ // request it already refused skips the walk that just proved fruitless.
+ // Collect clears this again, so any ordinary collection re-arms forcing.
+ if not Result then
+ FForcedCollectFloor := FBytesAllocated;
+end;
+
function TGarbageCollector.TryReserveExternalBytes(
const ABytes: Int64; const AProtect: TGCManagedObject): Boolean;
begin
@@ -1073,17 +1109,8 @@ begin
Exit(True);
Result := (FBytesAllocated <= High(Int64) - ABytes) and
((FMaxBytes <= 0) or (FBytesAllocated + ABytes <= FMaxBytes));
- if not Result and ShouldForceReservationCollection(ABytes) then
- begin
- CollectForMemoryPressure(AProtect, True);
- Result := (FBytesAllocated <= High(Int64) - ABytes) and
- ((FMaxBytes <= 0) or (FBytesAllocated + ABytes <= FMaxBytes));
- // Record the level this collection could not get below, so a retry of a
- // request it already refused skips the walk that just proved fruitless.
- // Collect clears this again, so any ordinary collection re-arms forcing.
- if not Result then
- FForcedCollectFloor := FBytesAllocated;
- end;
+ if not Result then
+ Result := TryCollectForLimitedBytes(ABytes, AProtect);
if not Result then
Exit;
Inc(FBytesAllocated, ABytes);
diff --git a/source/units/Goccia.MemoryLimit.pas b/source/units/Goccia.MemoryLimit.pas
index c335e0ed..5a6452dc 100644
--- a/source/units/Goccia.MemoryLimit.pas
+++ b/source/units/Goccia.MemoryLimit.pas
@@ -111,7 +111,20 @@ begin
Exit;
GC := TGarbageCollector.Instance;
if Assigned(GC) then
- Budget := GC.MaxBytes
+ begin
+ { Refuse only once a collection has actually been attempted. Without this
+ the answer depends on how much collectable garbage happens to be resident
+ when the growth arrives, not on what the program is holding: the same
+ workload that refused a doubling at 16,382 properties under a 4 MiB
+ ceiling reached 65,534 with a periodic explicit collection.
+ TryCollectForLimitedBytes forces the collection, re-tests the fit once,
+ and declines to walk the heap for the shapes no collection could change —
+ a request larger than the whole budget, and a repeat of a request the
+ previous forced collection already refused at the current live level. }
+ if GC.TryCollectForLimitedBytes(ABytes) then
+ Exit;
+ Budget := GC.MaxBytes;
+ end
else
Budget := 0;
raise TGocciaMemoryLimitError.Create(ABytes, Budget);
- Canary probe used for the use-after-free proof:
diff --git a/source/units/Goccia.MemoryLimit.Test.pas b/source/units/Goccia.MemoryLimit.Test.pas
index e73cdc46..cf71a3cc 100644
--- a/source/units/Goccia.MemoryLimit.Test.pas
+++ b/source/units/Goccia.MemoryLimit.Test.pas
@@ -14,11 +14,24 @@ uses
Goccia.Executor.Interpreter,
Goccia.GarbageCollector,
Goccia.MemoryLimit,
- Goccia.TestSetup;
+ Goccia.TestSetup,
+ Goccia.Values.ObjectPropertyDescriptor,
+ Goccia.Values.ObjectValue,
+ Goccia.Values.Primitives;
type
+ { Stand-in for a property value that the caller is still holding in a Pascal
+ local when the growth gate runs — the shape every interpreter property
+ assignment has. Recycle records the sweep instead of freeing, so the probe
+ observes the use-after-free without committing one. }
+ TCanaryValue = class(TGocciaObjectValue)
+ public
+ procedure Recycle; override;
+ end;
+
TMemoryLimitTests = class(TTestSuite)
private
+ procedure ProbeGateCollectionSweepsInFlightValue;
{ Runs ASource under a budget that cannot fit the allocation it asks for,
once per executor, and answers whether the refusal reached the host. }
function RefusalEscapesScript(const ASource: string;
@@ -47,6 +60,69 @@ const
so a fixed number would refuse the engine's own setup on a busy run. }
BUDGET_HEADROOM_BYTES = 64 * 1024 * 1024;
+var
+ GCanarySwept: Boolean;
+
+procedure TCanaryValue.Recycle;
+begin
+ GCanarySwept := True;
+end;
+
+{ Deterministic rootedness probe for the growth gate.
+
+ Recreates the state an interpreter property assignment is in when the map's
+ entry array doubles: the receiver is reachable, the incoming value is not —
+ it lives in a Pascal local and in a descriptor that has not been inserted
+ yet, so no GC root can reach it. If the gate forces a collection there, the
+ value is swept out from under the descriptor. }
+procedure TMemoryLimitTests.ProbeGateCollectionSweepsInFlightValue;
+const
+ GATE_STEP_PROPERTY_COUNT = 62;
+ GARBAGE_OBJECT_COUNT = 400;
+ BUDGET_HEADROOM_BYTES = 4000;
+var
+ Canary: TCanaryValue;
+ CollectionsBefore: Integer;
+ GC: TGarbageCollector;
+ I: Integer;
+ Owner: TGocciaObjectValue;
+ PreviousMaxBytes: Int64;
+begin
+ GC := TGarbageCollector.Instance;
+ GC.Collect;
+ PreviousMaxBytes := GC.MaxBytes;
+ GCanarySwept := False;
+ Owner := TGocciaObjectValue.Create;
+ GC.AddRootObject(Owner);
+ try
+ for I := 1 to GATE_STEP_PROPERTY_COUNT do
+ Owner.DefineProperty('p' + IntToStr(I),
+ TGocciaPropertyDescriptorData.Create(
+ TGocciaUndefinedLiteralValue.UndefinedValue, []));
+
+ { Reclaimable garbage, so the forced collection has something to find. }
+ for I := 1 to GARBAGE_OBJECT_COUNT do
+ TGocciaObjectValue.Create;
+
+ Canary := TCanaryValue.Create;
+ { Tight enough that the next entry-array doubling (62 -> 126 entries,
+ 4512 transient bytes) does not fit, loose enough that nothing here
+ trips the allocation-time ceiling. }
+ GC.MaxBytes := GC.BytesAllocated + BUDGET_HEADROOM_BYTES;
+ CollectionsBefore := GC.TotalCollections;
+
+ Owner.DefineProperty('p63',
+ TGocciaPropertyDescriptorData.Create(Canary, []));
+
+ WriteLn('[probe] collections during gated growth: ',
+ GC.TotalCollections - CollectionsBefore);
+ WriteLn('[probe] in-flight value swept by the gate: ', GCanarySwept);
+ finally
+ GC.MaxBytes := PreviousMaxBytes;
+ GC.RemoveRootObject(Owner);
+ end;
+end;
+
procedure TMemoryLimitTests.BeforeEach;
begin
inherited BeforeEach;
@@ -267,6 +343,8 @@ begin
end;
begin
+ TGarbageCollector.Initialize;
+ TMemoryLimitTests.Create('probe').ProbeGateCollectionSweepsInFlightValue;
TestRunnerProgram.AddSuite(TMemoryLimitTests.Create('Memory limit'));
RunGocciaTests;
- Measured and rejected alternative: "latch a pressure collection for the next real safe point" (two window widths,
2×AByteslookahead andABytes > remaining/8) — both still died at C=16,382; the heap regains its garbage in the thousands of stores between geometric doublings. Not a substitute. - Related: ADR 0105 (argument containers root elements), ADR 0106 (gate posture — uncatchability stays), #1152 (charged-path collect-before-refuse; its "site could already collect" safety argument explicitly does NOT transfer here), #1156 (live crash from this class).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the rootedness table and trace AST.Expressions.pas:1812/1830 through OrderedStringMap.pas Add/Grow/Compact and ArrayValue.pas store paths. Read the existing TGocciaVMStackRoot, AddTempRoot usage, and ADR 0105, then run Goccia.MemoryLimit.Test.pas and the #1156 coverage. Done means the receiver and incoming value or descriptor graph remain reachable across every potential safe point, with the audit covering #1156 before the parked H4 prototype is reconsidered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100