Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions source/units/Goccia.VM.pas
Original file line number Diff line number Diff line change
Expand Up @@ -2878,6 +2878,11 @@ procedure TGocciaVMStackRoot.MarkClosureReferences(
begin
if not Assigned(AClosure) then
Exit;
// The closure borrows its owning function value (see the suspended-
// continuation mark walk); a live or displaced frame must keep it
// reachable the same way a parked one does.
if Assigned(AClosure.FunctionValue) then
AClosure.FunctionValue.MarkReferences;
if Assigned(AClosure.HomeObject) then
AClosure.HomeObject.MarkReferences;
if Assigned(AClosure.HomeClass) then
Expand Down Expand Up @@ -5055,9 +5060,21 @@ procedure TGocciaBytecodeGeneratorObjectValue.MarkReferences;
I: Integer;
Upvalue: TGocciaBytecodeUpvalue;
begin
// Marking the function value below closes a reference cycle back to this
// generator (a generator reachable from its own function's upvalues), so the
// walk has to be idempotent the way TGocciaBytecodeFunctionValue's is.
if GCMarked then Exit;
inherited;
if Assigned(FClosure) then
begin
// FClosure is a clone whose FunctionValue still borrows the function object
// that owns the original closure. A suspended generator — including the
// continuation OP_AWAIT builds for a plain async function — resumes through
// ExecuteClosureRegisters, which reads FunctionValue for the execution realm
// and global this. Without this edge a collection taken while the generator
// is the only thing holding the function object frees it under the frame.
if Assigned(FClosure.FunctionValue) then
FClosure.FunctionValue.MarkReferences;
if Assigned(FClosure.HomeObject) then
FClosure.HomeObject.MarkReferences;
if Assigned(FClosure.HomeClass) then
Expand Down Expand Up @@ -5547,6 +5564,10 @@ procedure TGocciaBytecodeAsyncGeneratorObjectValue.MarkReferences;
I: Integer;
Index: Integer;
begin
// The continuation's function edge can cycle back through an upvalue to
// this wrapper; the guard keeps re-visits from re-walking the queue.
if GCMarked then
Exit;
inherited;
if Assigned(FInner) then
FInner.MarkReferences;
Expand Down
24 changes: 8 additions & 16 deletions tests/built-ins/AsyncHooks/garbage-collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,25 +105,17 @@ describe("AsyncLocalStorage under garbage collection", () => {
});

test("interleaved chains survive collections between their resumptions", async () => {
// The chains are `.then` chains rather than async callbacks on purpose.
// Collecting from inside a bytecode async function that has already
// resumed from a suspension faults the VM, independently of this module —
// `(async () => { await Promise.resolve(); Goccia.gc(); })()` alone
// reproduces it on 0.13.0. A `.then` handler runs as an ordinary microtask
// job, which is the seam under test here anyway.
const als = new AsyncLocalStorage();
const observed = [];
const chain = (tag) =>
als.run(tag, () =>
Promise.resolve()
.then(() => {
collect();
observed.push(tag === als.getStore());
})
.then(() => {
collect();
observed.push(tag === als.getStore());
}));
als.run(tag, async () => {
await Promise.resolve();
collect();
observed.push(tag === als.getStore());
await Promise.resolve();
collect();
observed.push(tag === als.getStore());
});
await Promise.all([chain("a"), chain("b"), chain("c")]);
expect(observed).toEqual([true, true, true, true, true, true]);
});
Expand Down
174 changes: 174 additions & 0 deletions tests/language/async-await/bytecode-continuations/gc-roots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*---
description: a suspended continuation keeps its own function object reachable across a collection
features: [async-await, async-generators, generators, Goccia.gc]
---*/

// A generator object — including the continuation the bytecode VM builds for a
// plain async function at every `await` — clones the closure it resumes
// through, and that clone still borrows the function object which owns the
// original closure. Resumption reads the borrowed function object back for the
// execution realm and the global `this`, so a continuation has to hold its
// function object alive on its own: the moment nothing else refers to the
// function, the continuation is the only owner. Every case below therefore
// hands its function straight to a caller that keeps no reference, so the
// suspended continuation really is the last one standing, and forces two
// collections at each resumption so a survivor has to be genuinely rooted
// rather than merely not-yet-swept.
const collect = () => {
Goccia.gc();
Goccia.gc();
};

// Calls the function and drops it: the returned promise or iterator is the only
// thing the caller keeps.
const callAndDrop = (fn) => fn();

describe("suspended continuations under garbage collection", () => {
test("an async function collects after its first resumption", async () => {
const seen = [];
await callAndDrop(async () => {
await Promise.resolve();
collect();
seen.push("resumed");
});
expect(seen).toEqual(["resumed"]);
});

test("a collection before the first resumption keeps the body alive",
async () => {
const pending = callAndDrop(async () => {
await Promise.resolve();
return "late";
});
collect();
expect(await pending).toBe("late");
});

test("every suspension point of one function survives a collection",
async () => {
const steps = [];
await callAndDrop(async () => {
await Promise.resolve();
collect();
steps.push(1);
await Promise.resolve();
collect();
steps.push(2);
await Promise.resolve();
collect();
steps.push(3);
});
expect(steps).toEqual([1, 2, 3]);
});

test("a nested await chain survives collections at every depth", async () => {
const depths = [];
const descend = (depth) =>
callAndDrop(async () => {
if (depth > 0) await descend(depth - 1);
else await Promise.resolve();
collect();
depths.push(depth);
});
await descend(4);
expect(depths).toEqual([0, 1, 2, 3, 4]);
});

test("interleaved continuations survive collections between resumptions",
async () => {
const observed = [];
const chain = (tag) =>
callAndDrop(async () => {
await Promise.resolve();
collect();
observed.push(tag + "1");
await Promise.resolve();
collect();
observed.push(tag + "2");
return tag;
});
const settled = await Promise.all([chain("a"), chain("b"), chain("c")]);
expect(settled).toEqual(["a", "b", "c"]);
expect(observed.sort()).toEqual(["a1", "a2", "b1", "b2", "c1", "c2"]);
});

test("a collection inside a finally that follows an await is safe",
async () => {
const order = [];
await callAndDrop(async () => {
try {
await Promise.resolve();
collect();
order.push("try");
} finally {
collect();
order.push("finally");
}
});
expect(order).toEqual(["try", "finally"]);
});

test("an async object method survives a collection after its receiver is gone",
async () => {
const pending = callAndDrop(() =>
({
async probe() {
await Promise.resolve();
collect();
return "method";
},
}).probe());
collect();
expect(await pending).toBe("method");
});

test("an async class method survives a collection after its class is gone",
async () => {
const pending = callAndDrop(() =>
new (class {
async probe() {
await Promise.resolve();
collect();
return "class";
}
})().probe());
collect();
expect(await pending).toBe("class");
});

test("a sync generator resumes after collections between its yields", () => {
const iterator = callAndDrop(() =>
({
*counter() {
yield 1;
collect();
yield 2;
collect();
yield 3;
},
}).counter());
collect();
expect([...iterator]).toEqual([1, 2, 3]);
});

test("an async generator resumes after collections between its yields",
async () => {
const iterator = callAndDrop(() =>
({
async *counter() {
yield 1;
collect();
await Promise.resolve();
yield 2;
collect();
},
}).counter());
collect();
const values = [];
for await (const value of iterator) {
collect();
values.push(value);
}
expect(values).toEqual([1, 2]);
});
});
Loading