From 75332d12a6360b38fd2c3653d9c8d98333a28a40 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 13:24:10 +0100 Subject: [PATCH 1/3] `s.length = n` makes a new string and leaves the old one alone Assigning a string's length reallocated the block in place (ts.SetStringLength -> realloc). realloc frees the block when it moves, while something else can still hold it: the caller whose string a `this: string` parameter borrowed, a temporary, another variable. Under rc the holder then released the freed block (heap corruption, 0xC0000374). The default library's `"".clone().resize(n)` does exactly this, and it is behind padStart, padEnd, repeat, the regexp match results and more - most of the DefaultLib tests that failed under -mm=rc. Under none a second variable was left pointing at freed memory, and under gc, where GC_realloc can grow a block in place, a second variable saw the resized string's writes. A string is a value, so the resize is now `s = `: a new ts.StringResize op allocates the new block and copies what fits, and the result goes through the ordinary assignment, which gives up the old string only if `s` owned it. ts.SetStringLength is gone. -mm=own counts the new op as fresh and as a borrow of its operand, as it does ts.StringConcat. New test 00string_length_resize.ts (a parameter, an alias, a field, and a shrink), registered under gc and in the rc/none corpus; it fails on main under all three models. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/TypeScriptOps.td | 8 +- tslang/lib/TypeScript/LowerToAffineLoops.cpp | 2 +- tslang/lib/TypeScript/LowerToLLVM.cpp | 40 +++--- tslang/lib/TypeScript/MLIRGenImpl.h | 21 ++-- tslang/lib/TypeScript/OwnershipFacts.h | 2 +- .../lib/TypeScript/OwnershipInferencePass.cpp | 3 +- tslang/test/tester/CMakeLists.txt | 3 + .../tester/tests/00string_length_resize.ts | 114 ++++++++++++++++++ 8 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 tslang/test/tester/tests/00string_length_resize.ts diff --git a/tslang/include/TypeScript/TypeScriptOps.td b/tslang/include/TypeScript/TypeScriptOps.td index 9f5e15283..9958dff85 100644 --- a/tslang/include/TypeScript/TypeScriptOps.td +++ b/tslang/include/TypeScript/TypeScriptOps.td @@ -2354,9 +2354,11 @@ def TypeScript_SetLengthOfOp : TypeScript_Op<"SetLengthOf"> { let results = (outs ); } -def TypeScript_SetStringLengthOp : TypeScript_Op<"SetStringLength"> { - let arguments = (ins Arg:$op, Index:$size); - let results = (outs ); +// A new string of `size` bytes holding the start of `op` - `s.length = n`, whose result is then +// assigned to `s`. The source is left alone: whoever else holds it still does. +def TypeScript_StringResizeOp : TypeScript_Op<"StringResize"> { + let arguments = (ins TypeScript_String:$op, Index:$size); + let results = (outs Res:$result); } def TypeScript_StringLengthOp : TypeScript_Op<"StringLength"> { diff --git a/tslang/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index 8177da8ef..e08535693 100644 --- a/tslang/lib/TypeScript/LowerToAffineLoops.cpp +++ b/tslang/lib/TypeScript/LowerToAffineLoops.cpp @@ -2657,7 +2657,7 @@ void AddTsAffineLegalOps(ConversionTarget &target) mlir_ts::ElementRefOp, mlir_ts::PointerOffsetRefOp, mlir_ts::FuncOp, mlir_ts::GlobalOp, mlir_ts::GlobalResultOp, mlir_ts::DefaultOp, mlir_ts::HasValueOp, mlir_ts::ValueOp, mlir_ts::ValueOrDefaultOp, mlir_ts::NullOp, mlir_ts::ParseFloatOp, mlir_ts::ParseIntOp, mlir_ts::IsNaNOp, mlir_ts::PrintOp, mlir_ts::ConvertFOp, mlir_ts::SizeOfOp, mlir_ts::TypeDescriptorOp, mlir_ts::RetainOp, mlir_ts::ReleaseOp, mlir_ts::RetainSlotOp, mlir_ts::ReleaseSlotOp, mlir_ts::RetainCellOp, mlir_ts::ReleaseCellOp, mlir_ts::StoreOp, mlir_ts::SymbolRefOp, mlir_ts::LengthOfOp, mlir_ts::SetLengthOfOp, - mlir_ts::StringLengthOp, mlir_ts::SetStringLengthOp, mlir_ts::StringConcatOp, mlir_ts::StringCompareOp, mlir_ts::AnyCompareOp, + mlir_ts::StringLengthOp, mlir_ts::StringResizeOp, mlir_ts::StringConcatOp,mlir_ts::StringCompareOp, mlir_ts::AnyCompareOp, mlir_ts::LoadOp, mlir_ts::LoadSaveOp, mlir_ts::NewOp, mlir_ts::CreateTupleOp, mlir_ts::DeconstructTupleOp, mlir_ts::CreateArrayOp, mlir_ts::NewEmptyArrayOp, mlir_ts::NewArrayOp, mlir_ts::DeleteOp, mlir_ts::PropertyRefOp, mlir_ts::InsertPropertyOp, mlir_ts::ExtractPropertyOp, mlir_ts::LogicalBinaryOp, mlir_ts::UndefOp, mlir_ts::VariableOp, mlir_ts::DebugVariableOp, mlir_ts::AllocaOp, diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index b9a79d179..6909d777f 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -743,34 +743,46 @@ class StringLengthOpLowering : public TsLlvmPattern } }; -class SetStringLengthOpLowering : public TsLlvmPattern +// A new block of `size` bytes with as much of the source as fits, its terminator included; the +// source is read and nothing else. A null source is copied as the empty string. +class StringResizeOpLowering : public TsLlvmPattern { public: - using TsLlvmPattern::TsLlvmPattern; + using TsLlvmPattern::TsLlvmPattern; - LogicalResult matchAndRewrite(mlir_ts::SetStringLengthOp op, Adaptor transformed, + LogicalResult matchAndRewrite(mlir_ts::StringResizeOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { TypeHelper th(rewriter); - CodeLogicHelper clh(op, rewriter); LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); TypeConverterHelper tch(getTypeConverter()); auto loc = op->getLoc(); - - // TODO implement str concat auto i8PtrTy = th.getPtrType(); + auto llvmIndexType = tch.convertType(th.getIndexType()); - mlir::Value ptr = transformed.getOp(); - mlir::Value size = transformed.getSize(); + auto strType = mlir_ts::StringType::get(rewriter.getContext()); + mlir::Value source = transformed.getOp(); + mlir::Value emptyText = rewriter.create( + loc, tch.convertType(strType), rewriter.create(loc, strType, rewriter.getStringAttr(""))); + auto isNull = rewriter.create(loc, LLVM::ICmpPredicate::eq, source, + rewriter.create(loc, i8PtrTy)); + source = rewriter.create(loc, isNull, emptyText, source); - mlir::Value strPtr = rewriter.create(loc, i8PtrTy, ptr); + mlir::Value size = transformed.getSize(); + auto newStringValue = ch.MemoryAlloc(size); - mlir::Value newStringValue = ch.MemoryRealloc(strPtr, size); + auto strlenFuncOp = ch.getOrInsertFunction("strlen", th.getFunctionType(llvmIndexType, {i8PtrTy})); + mlir::Value sourceBytes = rewriter.create(loc, strlenFuncOp, ValueRange{source}).getResult(); + sourceBytes = rewriter.create( + loc, llvmIndexType, + ValueRange{sourceBytes, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1))}); + auto fits = rewriter.create(loc, LLVM::ICmpPredicate::ult, sourceBytes, size); + auto copyBytes = rewriter.create(loc, fits, sourceBytes, size); - rewriter.create(loc, newStringValue, ptr); - rewriter.eraseOp(op); + rewriter.create(loc, newStringValue, source, copyBytes, /*isVolatile=*/false); + rewriter.replaceOp(op, ValueRange{newStringValue}); return success(); } }; @@ -4171,7 +4183,7 @@ struct GlobalOpLowering : public TsLlvmPattern isa(op) || isa(op) || isa(op) || isa(op) || isa(op) || isa(op) || isa(op) || isa(op) || - isa(op) || isa(op) || + isa(op) || isa(op) || isa(op) || isa(op) || // a tagged union's value goes in and out through memory (an alloca and a copy), // which a global's initializer cannot hold @@ -7819,7 +7831,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() DeconstructTupleOpLowering, CreateArrayOpLowering, NewEmptyArrayOpLowering, NewArrayOpLowering, ArrayPushOpLowering, ArrayPopOpLowering, ArrayUnshiftOpLowering, ArrayShiftOpLowering, ArraySpliceOpLowering, ArrayViewOpLowering, DeleteOpLowering, ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, TypeDescriptorOpLowering, RetainOpLowering, ReleaseOpLowering, RetainSlotOpLowering, ReleaseSlotOpLowering, RetainCellOpLowering, ReleaseCellOpLowering, - InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, SetStringLengthOpLowering, StringConcatOpLowering, + InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, StringResizeOpLowering, StringConcatOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering, InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, InterfaceSymbolRefOpLowering, diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index b86f77e1a..b72e5b7a5 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -5525,17 +5525,18 @@ class MLIRGenImpl } else if (auto stringLength = leftExpressionValueBeforeCast.getDefiningOp()) { - MLIRCodeLogic mcl(builder, compileOptions); - auto stringValueLoaded = mcl.GetReferenceFromValue(location, stringLength.getOp()); - if (!stringValueLoaded) - { - emitError(location) << "Can't get reference of the string, ensure const string is not used"; - return mlir::failure(); - } - - // special case to resize array + // A string is a value: resizing one makes a new string, copied at its new size and + // assigned as `s = s + t` would be, and whoever else held the old one still does - the + // caller whose string a `this: string` parameter borrowed, a temporary, another + // variable. Resizing in place reallocated the block, which freed it when it moved + // under every model but gc, and under rc the holder then released it again. syncSavingValue(stringLength.getResult().getType()); - builder.create(location, stringValueLoaded, savingValue); + auto resized = builder.create(location, getStringType(), stringLength.getOp(), + savingValue); + markFreshBlockOwned(location, resized); + + auto saved = mlirGenSaveLogicOneItem(location, stringLength.getOp(), resized, genContext); + EXIT_IF_FAILED(saved) } else { diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index eb98a3e44..0630acdca 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -183,7 +183,7 @@ inline bool isFresh(mlir::Value value) } if (mlir::isa(def)) + mlir_ts::StringResizeOp, mlir_ts::CharToStringOp>(def)) { return true; } diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 69d756c3c..ddedc8c46 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -1446,7 +1446,8 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) + mlir_ts::ArithmeticBinaryOp, mlir_ts::LogicalBinaryOp, mlir_ts::StringConcatOp, + mlir_ts::StringResizeOp>(user)) { return true; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2a6d62279..a1fe0174c 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -429,6 +429,7 @@ tslang_add_test(NAME test-compile-00-owned-interfaces COMMAND test-runner "${PRO tslang_add_test(NAME test-compile-00-owned-closures COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") tslang_add_test(NAME test-compile-00-owned-any-boxing COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-compile-00-owned-strings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +tslang_add_test(NAME test-compile-00-string-length-resize COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") tslang_add_test(NAME test-compile-00-owned-generators COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-compile-00-owned-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") tslang_add_test(NAME test-compile-00-owned-generator-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") @@ -885,6 +886,7 @@ tslang_add_test(NAME test-jit-00-owned-interfaces COMMAND test-runner -jit "${PR tslang_add_test(NAME test-jit-00-owned-closures COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_closures.ts") tslang_add_test(NAME test-jit-00-owned-any-boxing COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-jit-00-owned-strings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") +tslang_add_test(NAME test-jit-00-string-length-resize COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") tslang_add_test(NAME test-jit-00-owned-generators COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-jit-00-owned-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") tslang_add_test(NAME test-jit-00-owned-generator-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") @@ -1819,6 +1821,7 @@ set(TSLANG_CORPUS 00spread.ts 00stack_test.ts 00str_null.ts + 00string_length_resize.ts 00strings.ts 00switch.ts 00symbol.ts diff --git a/tslang/test/tester/tests/00string_length_resize.ts b/tslang/test/tester/tests/00string_length_resize.ts new file mode 100644 index 000000000..02c61b876 --- /dev/null +++ b/tslang/test/tester/tests/00string_length_resize.ts @@ -0,0 +1,114 @@ +// `s.length = n` resizes a string, and a string is a value: the resized one is a new string, and +// whoever held the old one still holds it. It used to reallocate the block in place, which frees +// the block when it moves - under every model but gc - while something else still held it. Under +// rc that was fatal whenever the string came in through a parameter: the default library's +// `"".clone().resize(n)`, behind padStart, padEnd, repeat, the regexp results and more, resized +// the caller's temporary through `this`, and the caller then released the freed block (heap +// corruption, 0xC0000374 on Windows). +// +// The block has to move for the bug to show, so every case grows its string well past the size +// it started at, and the heap is churned before anything is read back. + +namespace __String { + function copyOf(this: string): string { + return this + ""; + } + + // the default library's `resize` + function resized(this: string, newSize: index): string { + this.length = newSize + 1; + this[newSize] = 0; + return this; + } +} + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +class Box { + text: string; + + constructor(text: string) { + this.text = text; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +function repeated(c: string, n: int): string { + let s = ""; + for (let i = 0; i < n; i++) { + s += c; + } + + return s; +} + +// The default library's shape: the string is the caller's temporary, borrowed by `this`. +function filled(n: int, c: string): string { + const s = "ab".copyOf().resized(n); + for (let i = 0; i < n; i++) { + s[i] = c[0]; + } + + return s; +} + +function resizedThroughParameter() { + let last = ""; + for (let k = 0; k < 200; k++) { + last = filled(100 + k, "x"); + } + + churn(); + return last == repeated("x", 299); +} + +// A second variable holds the same string; resizing one of them leaves the other as it was. +function aliasKeepsItsString() { + let a = "ab" + 1; + let b = a; + b.length = 64; + b[0] = "z"[0]; + churn(); + + return a == "ab1" && b == "zb1"; +} + +// A field is resized; a local still holding what the field held keeps it. +function fieldResized() { + let box = new Box("cd" + 2); + let before = box.text; + box.text.length = 128; + box.text[1] = "q"[0]; + churn(); + + return before == "cd2" && box.text == "cq2"; +} + +// Smaller: the copy keeps only what fits, and `resized` writes the terminator. +function shrunk() { + let s = "abcdef".copyOf().resized(2); + churn(); + + return s == "ab"; +} + +function main() { + assert(resizedThroughParameter(), "a string resized through `this` leaves the caller's own alone"); + assert(aliasKeepsItsString(), "resizing a string leaves another variable's copy as it was"); + assert(fieldResized(), "resizing a field's string leaves a local's copy as it was"); + assert(shrunk(), "a string resized smaller keeps its start"); + + print("done."); +} From db66d7107dc779bbb293fe0f6e3c247198289ad7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 13:24:15 +0100 Subject: [PATCH 2/3] rc: the array a rest parameter receives takes a reference to each element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array a rest parameter gets is built at the call from the trailing arguments and, like an array literal's, releases every element when it dies. The literal paths take a reference to each element first (§9.21); the packing in mlirGenCallFunction - reached by a call through an extension function, such as `fruits.concat(["x", "y"])` - did not. The callee's first `for (const item of other)` then took the only reference and gave it back, freeing the argument array, and the second loop read freed memory: the default library's Array.concat printed garbage for the first element it copied and corrupted the heap (array_concat under -mm=rc). New test 00rest_args_pack_owned.ts, registered under gc and in the rc/none corpus; it fails on main under rc (0xC0000374). Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenImpl.h | 12 +++- tslang/test/tester/CMakeLists.txt | 3 + .../tester/tests/00rest_args_pack_owned.ts | 70 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/tests/00rest_args_pack_owned.ts diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index b72e5b7a5..54573c20a 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -7486,8 +7486,18 @@ class MLIRGenImpl operands.pop_back_n(toIndex - fromIndex); + auto packed = !varArgOperands.empty() || isa(varArgsType); + + // the data block about to be filled releases every element when it dies; without + // this, `a.concat(["x"])` handed the callee an array nobody held, and its first + // `for (const item of other)` freed it (§9.21, as for an array literal) + if (packed) + { + mlirGenRetainCaptured(location, varArgOperands); + } + // create array - auto array = varArgOperands.empty() && !isa(varArgsType) + auto array = !packed ? V(builder.create(location, varArgsType)) : V(builder.create(location, varArgsType, varArgOperands)); operands.push_back(array); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index a1fe0174c..f50c8871d 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -430,6 +430,7 @@ tslang_add_test(NAME test-compile-00-owned-closures COMMAND test-runner "${PROJE tslang_add_test(NAME test-compile-00-owned-any-boxing COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-compile-00-owned-strings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") tslang_add_test(NAME test-compile-00-string-length-resize COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") +tslang_add_test(NAME test-compile-00-rest-args-pack-owned COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00rest_args_pack_owned.ts") tslang_add_test(NAME test-compile-00-owned-generators COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-compile-00-owned-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") tslang_add_test(NAME test-compile-00-owned-generator-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") @@ -887,6 +888,7 @@ tslang_add_test(NAME test-jit-00-owned-closures COMMAND test-runner -jit "${PROJ tslang_add_test(NAME test-jit-00-owned-any-boxing COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-jit-00-owned-strings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") tslang_add_test(NAME test-jit-00-string-length-resize COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") +tslang_add_test(NAME test-jit-00-rest-args-pack-owned COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00rest_args_pack_owned.ts") tslang_add_test(NAME test-jit-00-owned-generators COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-jit-00-owned-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") tslang_add_test(NAME test-jit-00-owned-generator-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generator_locals.ts") @@ -1806,6 +1808,7 @@ set(TSLANG_CORPUS 00reference_index_bug.ts 00reference_null_bug.ts 00reference_ref_deref.ts + 00rest_args_pack_owned.ts 00safe_cast_bug.ts 00safe_cast_field_access.ts 00safe_cast_early_exit.ts diff --git a/tslang/test/tester/tests/00rest_args_pack_owned.ts b/tslang/test/tester/tests/00rest_args_pack_owned.ts new file mode 100644 index 000000000..d7e01cc4b --- /dev/null +++ b/tslang/test/tester/tests/00rest_args_pack_owned.ts @@ -0,0 +1,70 @@ +// The array a rest parameter receives is built at the call from the arguments, and like an array +// literal's it releases every element when it dies - so it has to take a reference to each. Under rc +// a call through an extension function, `fruits.concat(["x", "y"])`, packed its arguments without +// one: the callee's first `for (const item of other)` took the only reference and gave it back, +// freeing the array, and the second loop read what was left (the default library's Array.concat, +// which printed garbage for the first element it copied and corrupted the heap). + +namespace __Array { + function joinedWith(this: T[], ...other: T[][]): string { + let count = this.length; + for (const item of other) { + count += item.length; + } + + let result = ""; + for (const v of this) { + result += v; + } + + for (const item of other) { + for (const v of item) { + result += v; + } + } + + return result + count; + } +} + +class Vec { + x: number; + + constructor(x: number) { + this.x = x; + } +} + +// Allocate over whatever has just been freed, so a use-after-free reads something else. +function churn() { + for (let i = 0; i < 64; i++) { + let filler = new Vec(999); + } +} + +function viaExtension() { + const fruits = ["a", "b"]; + return fruits.joinedWith(["x", "y"], ["z"]); +} + +// a plain function's rest parameter, the elements made at the call +function lengths(...items: string[][]): int { + let n = 0; + for (const item of items) { + n += item.length; + } + + churn(); + for (const item of items) { + n += item.length; + } + + return n; +} + +function main() { + assert(viaExtension() == "abxyz5", "a rest parameter's array holds its elements through an extension call"); + assert(lengths(["p", "q"], ["r" + 1]) == 6, "a rest parameter's array holds elements made at the call"); + + print("done."); +} From 9e72d77e23161238c8ec67b581aa900e44c91d64 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 30 Sep 2026 13:24:16 +0100 Subject: [PATCH 3/3] A grown array's new slots read as zero under every model but gc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arr.length = n` reallocates the array's data block, and the slots it adds were zeroed only under rc and own, and only for an element type that owns heap memory (§9.37). Everywhere else they held whatever the allocator's memory held last. The default library's Set and Map grow their `int[]` buckets this way and take an unwritten bucket for an empty one, so outside gc their hash chains started from garbage: `new Set([1, 2]).union(new Set([2, 3]))` lost elements in about one run in two under -mm=rc, depending on what the heap had been used for (the set_* DefaultLib tests). gc was never affected: the collector hands back cleared memory, and GC_realloc clears what a shrink gives up. The grown tail is now zeroed for every element type under every model but gc. New test 00array_length_grow_zeroed.ts (grown over a dirtied heap, and shrunk then grown), registered under gc and in the rc/none corpus; it fails on main under rc and none. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToLLVM.cpp | 78 +++++++++---------- tslang/test/tester/CMakeLists.txt | 3 + .../tests/00array_length_grow_zeroed.ts | 54 +++++++++++++ 3 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 tslang/test/tester/tests/00array_length_grow_zeroed.ts diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 6909d777f..a0e6a922d 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -666,47 +666,45 @@ class SetLengthOfOpLowering : public TsLlvmPattern rewriter.create(loc, allocated, currentPtrPtr); - // `arr.length = n` on a grown array exposes slots the allocator has not written, and a - // store into one gives up what the slot held first (`isOwnedElementSlot`) - so the - // release reads whatever was last in that memory. `result.length = this.length` followed - // by `result[i] = ..` is exactly the default library's `Array.map`, which is why - // `arrS.map(e => e + "_")` crashed about one run in three (§9.37). + // `arr.length = n` on a grown array exposes slots the allocator has not written. They read + // as zero: the default library's Set and Map grow their `int[]` buckets this way and take + // an unwritten bucket for an empty one, and under rc and own a store into a slot gives up + // what the slot held first (`isOwnedElementSlot`), so the release would read whatever was + // last in that memory - `result.length = this.length` followed by `result[i] = ..` is the + // default library's `Array.map`, and `arrS.map(e => e + "_")` crashed about one run in + // three (§9.37). // - // Only where an element owns something, and only under -mm=rc: nothing reads an - // unwritten slot in the other models, and the memset is not free. - if (tsLlvmContext->compileOptions.tracksOwnership()) - { - MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); - if (mth.ownsHeapMemory(loc, elementType)) - { - auto oldBytes = rewriter.create(loc, th.getIndexType(), - ValueRange{sizeOfTypeAsIndexType, - rewriter.create( - loc, th.getIndexType(), countAsIndexType)}); - auto grew = rewriter.create(loc, mlir::index::IndexCmpPredicate::UGT, - multSizeOfTypeValue, oldBytes); - - auto *currentBlock = rewriter.getInsertionBlock(); - auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); - auto *zeroBlock = rewriter.createBlock(continuationBlock); - - rewriter.setInsertionPointToEnd(zeroBlock); - auto tailStart = rewriter.create(loc, ptrType, th.getI8Type(), allocated, - ValueRange{rewriter.create( - loc, llvmIndexType, oldBytes)}); - auto tailBytes = rewriter.create(loc, th.getIndexType(), - multSizeOfTypeValue, oldBytes); - rewriter.create( - loc, tailStart, - rewriter.create(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)), - rewriter.create(loc, llvmIndexType, tailBytes), /*isVolatile=*/false); - rewriter.create(loc, ValueRange{}, continuationBlock); - - rewriter.setInsertionPointToEnd(currentBlock); - rewriter.create(loc, grew, zeroBlock, continuationBlock); - - rewriter.setInsertionPointToStart(continuationBlock); - } + // Every model but gc, whose collector hands back cleared memory - also when a block + // shrunk in place grows again, GC_realloc having cleared what the shrink gave up. + if (!tsLlvmContext->compileOptions.needsGCRuntime()) + { + auto oldBytes = rewriter.create(loc, th.getIndexType(), + ValueRange{sizeOfTypeAsIndexType, + rewriter.create( + loc, th.getIndexType(), countAsIndexType)}); + auto grew = rewriter.create(loc, mlir::index::IndexCmpPredicate::UGT, + multSizeOfTypeValue, oldBytes); + + auto *currentBlock = rewriter.getInsertionBlock(); + auto *continuationBlock = rewriter.splitBlock(currentBlock, rewriter.getInsertionPoint()); + auto *zeroBlock = rewriter.createBlock(continuationBlock); + + rewriter.setInsertionPointToEnd(zeroBlock); + auto tailStart = rewriter.create(loc, ptrType, th.getI8Type(), allocated, + ValueRange{rewriter.create( + loc, llvmIndexType, oldBytes)}); + auto tailBytes = rewriter.create(loc, th.getIndexType(), + multSizeOfTypeValue, oldBytes); + rewriter.create( + loc, tailStart, + rewriter.create(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)), + rewriter.create(loc, llvmIndexType, tailBytes), /*isVolatile=*/false); + rewriter.create(loc, ValueRange{}, continuationBlock); + + rewriter.setInsertionPointToEnd(currentBlock); + rewriter.create(loc, grew, zeroBlock, continuationBlock); + + rewriter.setInsertionPointToStart(continuationBlock); } auto newCountAsLLVMType = rewriter.create(loc, llvmIndexType, newCountAsIndexType); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f50c8871d..6ccbcb978 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -430,6 +430,7 @@ tslang_add_test(NAME test-compile-00-owned-closures COMMAND test-runner "${PROJE tslang_add_test(NAME test-compile-00-owned-any-boxing COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-compile-00-owned-strings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") tslang_add_test(NAME test-compile-00-string-length-resize COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") +tslang_add_test(NAME test-compile-00-array-length-grow-zeroed COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_length_grow_zeroed.ts") tslang_add_test(NAME test-compile-00-rest-args-pack-owned COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00rest_args_pack_owned.ts") tslang_add_test(NAME test-compile-00-owned-generators COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-compile-00-owned-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") @@ -888,6 +889,7 @@ tslang_add_test(NAME test-jit-00-owned-closures COMMAND test-runner -jit "${PROJ tslang_add_test(NAME test-jit-00-owned-any-boxing COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_any_boxing.ts") tslang_add_test(NAME test-jit-00-owned-strings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_strings.ts") tslang_add_test(NAME test-jit-00-string-length-resize COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_length_resize.ts") +tslang_add_test(NAME test-jit-00-array-length-grow-zeroed COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_length_grow_zeroed.ts") tslang_add_test(NAME test-jit-00-rest-args-pack-owned COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00rest_args_pack_owned.ts") tslang_add_test(NAME test-jit-00-owned-generators COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_generators.ts") tslang_add_test(NAME test-jit-00-owned-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00owned_globals.ts") @@ -1577,6 +1579,7 @@ set(TSLANG_CORPUS 00any.ts 00array_assignment5.ts 00array_cond_access.ts + 00array_length_grow_zeroed.ts 00array_move_wide_elements.ts 00array_of.ts 00array_shift.ts diff --git a/tslang/test/tester/tests/00array_length_grow_zeroed.ts b/tslang/test/tester/tests/00array_length_grow_zeroed.ts new file mode 100644 index 000000000..efb8c1591 --- /dev/null +++ b/tslang/test/tester/tests/00array_length_grow_zeroed.ts @@ -0,0 +1,54 @@ +// `arr.length = n` on a grown array exposes slots nothing has written, and they read as zero. The +// default library's Set and Map grow their `int[]` buckets this way and take an unwritten bucket for +// an empty one; under every model but gc the new slots held whatever the allocator's memory held +// last, which broke the hash chains - `new Set([1, 2]).union(new Set([2, 3]))` lost elements, a run +// in two, depending on what the heap had been used for. +// +// So the heap is dirtied first: arrays of the same size are filled and dropped, and the one grown +// next is handed memory that still holds their values. + +function dirty() { + for (let k = 0; k < 8; k++) { + let d: int[] = []; + d.length = 64; + for (let i = 0; i < 64; i++) { + d[i] = 12345; + } + } +} + +function sum(a: int[]) { + let total = 0; + for (let i = 0; i < a.length; i++) { + total += a[i]; + } + + return total; +} + +function grownFromEmpty() { + dirty(); + let a: int[] = []; + a.length = 64; + return sum(a) == 0; +} + +// Shrunk and grown again: the slots the shrink gave up come back empty, not with what they held. +function shrunkThenGrown() { + let a: int[] = []; + a.length = 64; + for (let i = 0; i < 64; i++) { + a[i] = 7; + } + + a.length = 2; + a.length = 64; + return sum(a) == 14; +} + +function main() { + assert(grownFromEmpty(), "a grown array's new slots read as zero"); + assert(shrunkThenGrown(), "slots given up by a shrink read as zero when the array grows again"); + + print("done."); +}