Skip to content

Commit 655f026

Browse files
Avoid weakset for executed generators (#64830)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 257e874 commit 655f026

3 files changed

Lines changed: 84 additions & 19 deletions

File tree

‎packages/typescript/src/api/sync/generatorSupport.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ export function* apiRequest(method: PropertyKey, params: unknown): Generator<{ m
2323
return yield { method, params };
2424
}
2525
const deferredGeneratorMarker: unique symbol = Symbol();
26+
// Marks executed generators on the generator itself: a WeakSet holding every generator of a long-running batch is
27+
// costly to grow and to process during GC
28+
const executedMarker: unique symbol = Symbol();
29+
interface ExecutedGenerator {
30+
[executedMarker]?: true;
31+
}
32+
const executedNonExtensibleGenerators = new WeakSet<AnyAPIRequestGenerator>();
33+
34+
/** Marks the generator as executed, returning false if it already was. */
35+
function markExecuted(generator: AnyAPIRequestGenerator): boolean {
36+
if (!Object.isExtensible(generator)) {
37+
if (executedNonExtensibleGenerators.has(generator)) return false;
38+
executedNonExtensibleGenerators.add(generator);
39+
return true;
40+
}
41+
const target = generator as ExecutedGenerator;
42+
if (target[executedMarker]) return false;
43+
target[executedMarker] = true;
44+
return true;
45+
}
2646
const allGeneratorMarker: unique symbol = Symbol();
2747
interface DeferredAPIRequest {
2848
readonly method: "__defer";
@@ -125,7 +145,6 @@ export function executeRequestGenerators<T extends readonly AnyAPIRequestGenerat
125145
requestGenerators: T,
126146
executeRequests: (requests: APIRequest[]) => readonly GeneratorResponse[],
127147
): ExecutedGeneratorsResults<T> {
128-
const registeredGenerators = new WeakSet<AnyAPIRequestGenerator>();
129148
let firstGenerator: GeneratorState | undefined;
130149
let lastGenerator: GeneratorState | undefined;
131150
const root = startGroup(requestGenerators);
@@ -220,13 +239,12 @@ export function executeRequestGenerators<T extends readonly AnyAPIRequestGenerat
220239
}
221240

222241
function addGenerator(generator: AnyAPIRequestGenerator, parent?: GeneratorGroup): void {
223-
if (registeredGenerators.has(generator)) {
242+
if (!markExecuted(generator)) {
224243
const error = new Error("Cannot execute the same generator instance more than once");
225244
if (!parent) throw error;
226245
failGroup(parent, error);
227246
return;
228247
}
229-
registeredGenerators.add(generator);
230248
const next = parent?.parent;
231249
const previous = next ? next.previous : lastGenerator;
232250
const state: GeneratorState = {
@@ -265,12 +283,11 @@ export function executeRequestGenerators<T extends readonly AnyAPIRequestGenerat
265283
const wrapper = generator as AllRequestGenerator<unknown>;
266284
const pending = wrapper.generators;
267285
if (!pending) break;
268-
if (registeredGenerators.has(wrapper)) {
286+
if (!markExecuted(wrapper)) {
269287
failGroup(group, new Error("Cannot execute the same generator instance more than once"));
270288
group.initializing = false;
271289
return group;
272290
}
273-
registeredGenerators.add(wrapper);
274291
wrapper.next();
275292
(group.wrappers ??= []).push(wrapper);
276293
generators = pending;

‎packages/typescript/test/api-comparison.bench.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function runBenchmarks(options?: { filter?: string; singleIteration
5656
addGeneratorBenchmarks();
5757
addExecutorBenchmarks();
5858
addDeferBenchmarks();
59+
addLongBatchBenchmarks();
5960

6061
if (filter) {
6162
const pattern = filter.toLowerCase();
@@ -291,6 +292,62 @@ export async function runBenchmarks(options?: { filter?: string; singleIteration
291292
}
292293
}
293294

295+
function addLongBatchBenchmarks() {
296+
// Enough short-lived generators in a single executor run that per-generator bookkeeping dominates
297+
const generatorCount = 100_000;
298+
const rounds = 100;
299+
const generatorsPerRound = generatorCount / rounds;
300+
for (const mode of ["direct", "yield* defer"] as const) {
301+
bench.add(`executor long batch - ${generatorCount} generators (${mode})`, () => run(false), {
302+
async: false,
303+
beforeAll: () => {
304+
run(true);
305+
},
306+
});
307+
308+
function run(verify: boolean) {
309+
let completed = 0;
310+
let executedRequests = 0;
311+
let executedRounds = 0;
312+
const results = executeRequestGenerators(
313+
mode === "direct" ? Array.from({ length: generatorCount }, (_, index) => runRequest(index)) : [schedule()],
314+
requests => {
315+
executedRequests += requests.length;
316+
executedRounds++;
317+
return requests.map(request => ({ result: request.params }));
318+
},
319+
);
320+
if (verify) {
321+
assert.equal(completed, generatorCount);
322+
if (mode === "direct") {
323+
assert.equal(executedRequests, generatorCount);
324+
assert.equal(executedRounds, 1);
325+
assert.equal(results.length, generatorCount);
326+
}
327+
else {
328+
assert.equal(executedRequests, generatorCount + rounds);
329+
assert.equal(executedRounds, rounds);
330+
assert.deepEqual(results, [undefined]);
331+
}
332+
}
333+
334+
function* runRequest(index: number): APIRequestGenerator<number> {
335+
const result = yield { method: "benchmark", params: index } as unknown as APIRequest;
336+
completed++;
337+
return result;
338+
}
339+
340+
// A long-running scheduler that keeps spawning new generators across many request rounds
341+
function* schedule(): APIRequestGenerator {
342+
for (let round = 0; round < rounds; round++) {
343+
for (let lane = 0; lane < generatorsPerRound; lane++) yield* defer(runRequest(round * generatorsPerRound + lane));
344+
yield { method: "benchmark", params: round } as unknown as APIRequest;
345+
}
346+
}
347+
}
348+
}
349+
}
350+
294351
function* runDependentRequests(project: SyncProject) {
295352
let index = 0;
296353
for (let request = 0; request < requestCount; request++) {

‎packages/typescript/test/sync/api-generators.test.ts‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () =>
579579
assert.equal(started, false);
580580
});
581581

582-
test("retains deferred branding after completion", () => {
582+
test("rejects deferred generator reuse after completion", () => {
583583
let completed = false;
584584
function* child(): Generator<APIRequest, string, string> {
585585
const result = yield { method: "request", params: null } as unknown as APIRequest;
@@ -591,8 +591,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () =>
591591
assert.deepEqual(results, []);
592592
assert.equal(completed, true);
593593
const noRequests = () => assert.fail("Unexpected request round");
594-
assert.deepEqual(executeRequestGenerators([deferred], noRequests), []);
595-
assert.throws(() => executeRequestGenerators([deferred, deferred], noRequests), /same generator instance more than once/);
594+
assert.throws(() => executeRequestGenerators([deferred], noRequests), /same generator instance more than once/);
596595
});
597596

598597
test("joins nested, empty, and synchronously completed groups in result order", () => {
@@ -897,23 +896,15 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () =>
897896
}
898897
});
899898

900-
test("tracks all identity independently across executors", () => {
899+
test("rejects generator reuse across executors", () => {
901900
const joined = all();
902901
const noRequests = () => assert.fail("Unexpected request round");
903902
function* parent() {
904903
assert.deepEqual(yield* all(joined), [[]]);
905-
assert.deepEqual(executeRequestGenerators([joined], noRequests), [undefined]);
906-
try {
907-
yield* all(joined);
908-
assert.fail("Expected duplicate generator rejection");
909-
}
910-
catch (error) {
911-
assert.match((error as Error).message, /same generator instance more than once/);
912-
}
904+
assert.throws(() => executeRequestGenerators([joined], noRequests), /same generator instance more than once/);
913905
}
914906
executeRequestGenerators([parent()], noRequests);
915-
assert.deepEqual(executeRequestGenerators([joined], noRequests), [undefined]);
916-
assert.throws(() => executeRequestGenerators([joined, joined], noRequests), /same generator instance more than once/);
907+
assert.throws(() => executeRequestGenerators([joined], noRequests), /same generator instance more than once/);
917908
});
918909

919910
test("deduplicates initialization across nested joins", () => {

0 commit comments

Comments
 (0)