frostney / frostney/GocciaScript

Rooted-temporaries discipline for interpreter store paths; growth-gate collect-before-refuse blocked on it

Open
#1,157 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

engine internal
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:271OrderedStringMap.pas:415 (Add) every JS property store NO (interpreter)
same ← OrderedStringMap.pas:273 (Grow) / :316 (Compact) Add NO
HoleValue.pas:59ArrayValue.pas SetElement/SetProperty/DefineProperty family arr[i] = v NO
HoleValue.pas:59ArrayValue.pas:1287 (new Array(n)) native ctor yes (Evaluator.pas:3738 temp-root)
HoleValue.pas:59ArrayValue.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

  1. 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.
  2. #1156 fixed as a consequence (or at least covered by the audit).
  3. Then H4 lands: RequireNativeBytes force-collects once and re-tests before raising the uncatchable TGocciaMemoryLimitError (contract unchanged; over-whole-budget still refuses without collecting; damper shared via FForcedCollectFloor — it records a heap fact, both paths force the same Collect, 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×ABytes lookahead and ABytes > 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.