From 6a5db0d2a55fceb9b9cbce05e2f1e333b7572dd9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 19 Oct 2026 21:54:16 +0100 Subject: [PATCH] A generator's loop local gives back what the last turn stored A local declared in a generator's loop is a field of the state object, and its declaration stores into that field on every turn. Nothing released what the previous turn stored there, so under rc and own every turn but the last leaked (a const string[] in a while(true) grew by about 180 MB over 500000 runs). registerVariable now emits ts.ReleaseSlot on the field before the store. Under own that makes the generator's next() a call that may drop, and a caller's for...of was rejected: walkBorrowed followed the {value, done} record returned by it.next() as part of the method read out of `it`, so the record's release counted as a use after the other next() call. A call marked __own_fresh_result hands back a block of the caller's own, and the walk now stops there. Tests: own_generator_loop_local.ts under own, rc, gc and none (JIT and AOT), and an IR check that each generator's next releases its loop local. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenImpl.h | 4 + .../lib/TypeScript/OwnershipInferencePass.cpp | 11 ++- tslang/test/tester/CMakeLists.txt | 13 ++- .../tester/generator-loop-local-release.cmake | 28 ++++++ .../tester/own/own_generator_loop_local.ts | 90 +++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/generator-loop-local-release.cmake create mode 100644 tslang/test/tester/own/own_generator_loop_local.ts diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 8d3ef55c0..ee0cea73b 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -2328,6 +2328,10 @@ class MLIRGenImpl { builder.create(location, variableDeclarationInfo.initial); } + + // A declaration in a loop runs again on the next turn, into the same field: what + // the last turn stored there is given back first. The field starts out null. + builder.create(location, variableDeclarationInfo.storage); } auto storeOp = builder.create(location, variableDeclarationInfo.initial, variableDeclarationInfo.storage); diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 4a4d69b87..02960878e 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2354,7 +2354,16 @@ class OwnershipInferencePass continue; } - // A result that borrows what it was given borrows this too, and is walked as it. + // A result that borrows what it was given borrows this too, and is walked as it. A + // call known to make its result (`__own_fresh_result`: through a field that only + // ever holds one function) hands back a block of the caller's own - the `{value, + // done}` record of `it.next()` points nowhere into `it`, and what it does after + // its next call cannot be a use of the method read out of `it`. + if (isCall(user) && user->hasAttr(OWN_FRESH_RESULT_ATTR_NAME)) + { + continue; + } + for (auto result : user->getResults()) { if (mayPointInto(result) && seen.insert(result).second) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6960525fe..3e981b3e0 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3395,7 +3395,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call own_call_no_drops own_getter_borrow own_param_kept own_any_box own_string_length_borrow - own_closure_borrow own_closure_escape own_generator own_interface_view + own_closure_borrow own_closure_escape own_generator own_generator_loop_local own_interface_view own_async own_record_return own_generator_borrow own_global_script own_global_init own_constant_data own_record_local own_nullable_view own_declared_constant own_spread_build own_interface_field_call @@ -3451,6 +3451,17 @@ add_test(NAME test-own-string-copy-in-store-block "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_string_copy_branch.ts" -P "${CMAKE_CURRENT_SOURCE_DIR}/own-string-copy-block.cmake") +# A local declared in a generator's loop gives back what the last turn stored in its field. The +# program means the same under every model; no run can see the leak, so the IR is read. +foreach(loop_local_mm rc none gc) + tslang_add_test(NAME test-jit-${loop_local_mm}-own_generator_loop_local COMMAND test-runner -jit -mm=${loop_local_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_generator_loop_local.ts") + tslang_add_test(NAME test-compile-${loop_local_mm}-own_generator_loop_local COMMAND test-runner -mm=${loop_local_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_generator_loop_local.ts") +endforeach() +add_test(NAME test-rc-generator-loop-local-release + COMMAND ${CMAKE_COMMAND} -DTSLANG=$ + "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_generator_loop_local.ts" + -P "${CMAKE_CURRENT_SOURCE_DIR}/generator-loop-local-release.cmake") + # A `const` record literal's slot is released under rc (#485); no run can see the leak, so the IR is read. add_test(NAME test-rc-const-record-release COMMAND ${CMAKE_COMMAND} -DTSLANG=$ diff --git a/tslang/test/tester/generator-loop-local-release.cmake b/tslang/test/tester/generator-loop-local-release.cmake new file mode 100644 index 000000000..82809cd49 --- /dev/null +++ b/tslang/test/tester/generator-loop-local-release.cmake @@ -0,0 +1,28 @@ +# A local declared in a generator's loop is a field of the state object, stored again on every turn: +# under rc the generator's `next` releases what the field held before the store. Without it every +# turn but the last leaked, and no run shows that, so the IR is read: each generator in +# own_generator_loop_local.ts has a `ts.ReleaseSlot` of its `string[]` field. +cmake_minimum_required(VERSION 3.17.3) +execute_process(COMMAND "${TSLANG}" --emit=mlir-affine --no-default-lib -mm=rc "${FILE}" + OUTPUT_VARIABLE out ERROR_VARIABLE err RESULT_VARIABLE result) +# the IR is printed to stderr +string(APPEND out "${err}") +if(NOT result EQUAL 0) + message(FATAL_ERROR "tslang failed (${result}):\n${out}") +endif() +foreach(generator rows untilBreak pairs) + string(FIND "${out}" "ts.Func @\".f_${generator}..f_${generator}..obj" begin) + if(begin EQUAL -1) + message(FATAL_ERROR "no `next` of generator `${generator}` in:\n${out}") + endif() + string(SUBSTRING "${out}" ${begin} -1 body) + string(SUBSTRING "${body}" 1 -1 rest) + string(FIND "${rest}" "ts.Func @" end) + if(NOT end EQUAL -1) + math(EXPR end "${end} + 1") + string(SUBSTRING "${body}" 0 ${end} body) + endif() + if(NOT body MATCHES "\"ts[.]ReleaseSlot\"[(]%[0-9]+[)] : [(]!ts[.]ref>[)]") + message(FATAL_ERROR "`${generator}` does not release its loop local's old value:\n${body}") + endif() +endforeach() diff --git a/tslang/test/tester/own/own_generator_loop_local.ts b/tslang/test/tester/own/own_generator_loop_local.ts new file mode 100644 index 000000000..9bef2c8e1 --- /dev/null +++ b/tslang/test/tester/own/own_generator_loop_local.ts @@ -0,0 +1,90 @@ +// A local declared in a generator's loop is a field of the state object, and its declaration runs +// again on every turn, into the same field. What the last turn stored there is given back first; +// before, nothing gave it back, and 500000 runs of `untilBreak` grew by 150 MB under rc and own. +// The leak itself shows only in measure.ps1 (and generator-loop-local-release.cmake reads the IR); +// this checks that the release frees the old value, not the new one: each local is read after a +// churn(), which reuses freed blocks, so a value freed too early reads garbage. +// +// Under own the release makes `next()` a call that may drop, and a caller holds what it borrowed +// out of one record across the next call: a for...of over a generator, and two records by hand. +// +// Strings are made in another function: a temporary made in a generator's own body is never +// released (spec 18.5), which would hide the leak this covers. +function label(i: number) { + return "s" + i; +} + +function churn() { + const junk: string[][] = []; + for (let i = 0; i < 32; i++) { + junk.push([label(i), label(i + 1)]); + } +} + +// a `const` in a `for`, read after a churn() on its own turn +function* rows(n: number) { + for (let i = 0; i < n; i++) { + const row: string[] = [label(i), label(i + 1)]; + churn(); + yield row.length + row[0].length; + } +} + +// a `const` in a `while (true)`, the last one left by `break`: the state's release gives it back +function* untilBreak() { + let i = 0; + while (true) { + const result: string[] = [label(i++), "x"]; + if (i > 3) { + break; + } + + yield result.length; + } +} + +// yields a fresh array each turn while a local is given back in the same `next()` +function* pairs() { + let i = 0; + while (i < 3) { + const keep: string[] = [label(i), "k"]; + i++; + churn(); + yield [keep.length + keep[0].length + i, 7]; + } +} + +function main() { + let t = 0; + for (let n = 0; n < 2000; n++) { + for (const v of rows(3)) { + t += v; + } + + for (const v of untilBreak()) { + churn(); + t += v; + } + + // the loop variable borrows the record's value across the next `next()` + for (const g of pairs()) { + churn(); + t += g[0] + g.length; + } + + // by hand: the first record is read after the second `next()` + const ps = pairs(); + const a = ps.next(); + const b = ps.next(); + churn(); + t += a.value[0] + b.value[0]; + + // given up after the first value: the state still gives back its local + const half = rows(3); + t += half.next().value; + } + + // rows 4 + 4 + 4, untilBreak 2 + 2 + 2, pairs (5 + 2) + (6 + 2) + (7 + 2), by hand 5 + 6, half 4 + assert(t == 2000 * (12 + 6 + 24 + 11 + 4), "t"); + print("done."); +}