Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6b09dad
Spec: arrays as references (#453, #477)
ASDAlexander77 Oct 3, 2026
4dd14ca
Plan: arrays as references (#453, #477); spec takes the plan's rulings
ASDAlexander77 Oct 3, 2026
6b5fc83
Lowering: one helper knows the array layout (array ops in LowerToLLVM…
ASDAlexander77 Oct 3, 2026
5eafedd
Lowering: casts, constants and ownership routines read arrays through…
ASDAlexander77 Oct 3, 2026
591b707
A destructuring rest element is a new array (#477)
ASDAlexander77 Oct 3, 2026
bea7503
An array is a reference to a header that never moves (#453)
ASDAlexander77 Oct 3, 2026
07c4070
A declared array with no initializer starts as no array (#453)
ASDAlexander77 Oct 3, 2026
4f76d12
A view retains what it copies; capacity follows length (#453)
ASDAlexander77 Oct 3, 2026
aab5e26
The view test states how many elements each view has (#453)
ASDAlexander77 Oct 3, 2026
18c1419
Merge remote-tracking branch 'origin/main' into array-reference-switch
ASDAlexander77 Oct 3, 2026
e212487
main's argv is Ref<string> only; a string[] argv is a compile error
ASDAlexander77 Oct 3, 2026
3908d65
A string[] argv of main is refused ahead of time too (not in a DLL)
ASDAlexander77 Oct 3, 2026
b276090
Debug info: an array is a pointer to { data, length, capacity }
ASDAlexander77 Oct 3, 2026
483ea95
own: a push through a borrowed array parameter is the owner's (#453)
ASDAlexander77 Oct 3, 2026
88b48dd
The argv DLL test links from the build tree, not the environment
ASDAlexander77 Oct 3, 2026
d9248e7
rc: the header holds a counted reference to its data block
ASDAlexander77 Oct 4, 2026
7ab0f38
own: the header's release frees its data block outright, as before
ASDAlexander77 Oct 4, 2026
db6a74b
Spec and MLIRGen comment: the rest copy is an element copy, not a pus…
ASDAlexander77 Oct 4, 2026
33a1af8
Arrays grow by doubling; pop, shift and shrink keep the block (#453)
ASDAlexander77 Oct 4, 2026
6b70262
Merge remote-tracking branch 'origin/main' into array-reference-growth
ASDAlexander77 Oct 4, 2026
c7ff7fc
Arrays: a shrinking length= releases, view() clamps, main refuses any…
ASDAlexander77 Oct 4, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

- **R1, a null header is an empty array.** Today zeroed memory is a valid empty array: elements of `new Array<T[]>(n)`, class fields of array type with no initializer, and `undefined` cast to an array (`CastLogicHelper::castToArrayType`, `isUndef`). So: reading `data`/`length` of a null header gives null/0; an op that changes an array through its slot (`push`, `pop`, `shift`, `unshift`, `splice`, `length =`) first stores a fresh empty header into a slot that holds null; rc/own release of null does nothing (`emitIfLastReference` already skips null). Cost if wrong: one compare and branch per array read.
- **R2 (replaced 2026-10-04), `main`'s argv is `Ref<string>` only.** `main`'s argv is `Ref<string>` only (C's `char **`); a `string[]` argv is a compile error that names the `Ref<string>` form (owner's ruling 2026-10-04; replaces plan ruling R2). Spec section 5 has the same text. The original Task 5 (a `ts.ArrayFromCStrings` op) is dropped.
- **R3, the rest copy is built in MLIRGen.** Spec §3 says `ts.ArrayView` becomes a copy. A copy must own its elements under rc/own; MLIRGen's existing "fresh array + synthesized loop" idiom (`MLIRGenCast.cpp`, numeric array widening) gets that from the passes for free. So MLIRGen stops emitting `ts.ArrayView` for a rest element and builds `const .rest: T[] = []; for (let .i = index; .i < .src.length; .i++) .rest.push(.src[.i]);` instead. `ArrayViewOpLowering` stays for other users (none known; see Task 3 step 1).
- **R3, the rest copy is built in MLIRGen.** Spec §3 says `ts.ArrayView` becomes a copy. A copy must own its elements under rc/own; MLIRGen's existing "fresh array + synthesized loop" idiom (`MLIRGenCast.cpp`, numeric array widening) gets that from the passes for free. So MLIRGen stops emitting `ts.ArrayView` for a rest element and builds a copy instead. (As implemented, ruling T3-A: not a loop of pushes but an indexed element copy into a new array of the slice's length - `.rest = new array(.rest_n); for (let .rest_i = 0; .rest_i < .rest_n; ++.rest_i) .rest[.rest_i] = .rest_src[.rest_i + index];`, with `.rest_n = max(0, .rest_src.length - index)`; see `MLIRGenVariables.cpp`.) `ArrayViewOpLowering` stays for other users (none known; see Task 3 step 1).

## Review Focus

Expand Down
27 changes: 18 additions & 9 deletions tslang/docs/superpowers/specs/2026-10-03-array-reference-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Arrays as references

Date: 2026-10-03. Status: design approved in conversation (sections 1-4); written spec awaiting
review. Fixes #453 (a push onto an array parameter is lost to the caller) and #477 (a
Date: 2026-10-03. Status: design approved; implemented in #478 (the layout helper), #480 (the
switch) and PR 3 (growth). Fixes #453 (a push onto an array parameter is lost to the caller) and #477 (a
destructuring rest element aliases its source). Plans go in `docs/superpowers/plans/`.

## 1. Purpose
Expand Down Expand Up @@ -72,17 +72,19 @@ struct fails. Under rc a parameter push exits with 127 and prints nothing.
owner does not see the change and, under rc and own, that header leaks. Accepted: such a slot
holds `undefined` in TypeScript terms, where `.push` would throw (ruling T4-D).
- `ConstArray` (a literal's static data) is unchanged. The cast from `ConstArray` to `T[]` makes a
header and copies the data into a fresh block on both of its paths; the path that today points
the struct at the static data (`byValue = false`) copies as well, since `push` would otherwise
reallocate static memory.
header and copies the data into a fresh block, since `push` would otherwise reallocate static
memory. The other path (`byValue = false`) turned out to be reached only by `null` as an array,
and returns a null header, which reads as empty (controller ruling T4-A).
- `[a, ...rest] = src` gives `rest` a new array holding a copy of the slice (#477). MLIRGen builds
it as a new array of the slice's length and a loop that copies each element (ruling T3-A; the
plan had a loop of pushes) instead of emitting `ts.ArrayView`, so the
ownership passes see a fresh array (plan ruling R3).
- Layout: an array field, element, tuple slot or union payload shrinks from two words to one
pointer; `T[] | undefined` is the pointer and its tag.
- ABI: every function that takes or returns an array changes. Objects built before the switch do
not link with objects built after it. The default library has no native code that reads an
- ABI: every function that takes or returns an array changes. Symbol names carry no types, so an
object built before the switch most likely still links with one built after it, and then fails
at run time (one side reads a `{ data, length }` struct where the other passes a header
pointer). Every object and the default library must be rebuilt. The default library has no native code that reads an
array (its C++ wrappers take none), so its sources are unchanged; it is rebuilt with the
switch, for every model and both build types.

Expand All @@ -108,7 +110,7 @@ unchanged.
formatters), and such a string takes a reference to the block; under rc the block was freed
under it (five default-library tests failed ahead of time). Counting the header's reference
restores what main does. A reallocating change (`push`, and PR 3's growth) still moves the
block under such a string, as it does on main.
block under such a string, as it does on main (#481).
- Slots `[length, capacity)` are never released (and, from PR 3, hold zero).
- `pop` and `shift` hand the removed element's reference to the caller, as today.
- **own.** The header is a single-owned block, like a class instance.
Expand Down Expand Up @@ -145,6 +147,11 @@ unchanged.
`max(4, 2 * capacity, needed)`; otherwise they write into the existing block.
- `pop`, `shift` and a smaller `length =` keep the block. They zero each slot they vacate, so gc
does not keep a removed element alive and a later growth reads zero.
- `pop` and `shift` on an empty array return a zeroed element (0, null, an empty string) and the
length stays 0.
- A smaller `length =` releases the elements it drops, `[new length, old length)`, before it
zeroes their slots, as `splice` releases the elements it removes (nothing under gc and none).
Without it, under rc and own, every element dropped that way leaked.
- `length = n` larger than `length` zeroes `[length, n)` in every model, gc included: after a
`pop` the slots past `length` are no longer fresh memory. Today's behaviour (new slots read as
zero; the default library's `Set`, `Map` and `Array.map` rely on it, see the comment in
Expand All @@ -169,7 +176,9 @@ unchanged.
- `00array_reference.ts`: shapes 1-14 of §2 as asserts; compile and JIT, rc and none corpora.
- An own test with the shapes own accepts, including a push through a borrowed parameter.
- `00array_rest_copy.ts` (#477).
- A `main(argc, argv: string[])` test for AOT and JIT.
- `main`'s argv error tests (`test/tester/lowering-errors/main_argv_string_array.ts` and
`main_args_string_array.ts`): an array-typed parameter of `main` is a compile error under the
JIT, `--emit=exe` and `--emit=obj`, and not in a DLL (§5).
- Gates: the full Windows Release suite; the full Linux (WSL) suite, which is required because
the Windows heap hides double frees; the own corpus with no file lost; own's memory measurement
flat; the default library rebuilt and its suite run under every model.
Expand Down
91 changes: 90 additions & 1 deletion tslang/include/TypeScript/LowerToLLVM/ArrayLayout.h
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,86 @@ class ArrayLayout : public LLVMCodeHelperBase
return fieldAddress(arrayType, header, ARRAY_CAPACITY_INDEX);
}

// room for `needed` elements in the non-null `header` (spec section 6): when `needed` is past
// the capacity, the data block grows to max(4, 2 * capacity, needed) elements and what lies
// past the old capacity is zeroed. Returns the data pointer, already stored in the header.
//
// Every slot in [length, capacity) reads zero: growth zeroes the new slots here, and pop,
// shift, splice and a smaller `length =` zero each slot they vacate. The block goes through
// MemoryRealloc, which keeps an rc data block's count across the move and births one grown
// from null with the header's reference.
//
// A static header (makeStatic) is broken under the ops that change an array in place (#479):
// it lives in a constant global with capacity = length over static data, and pop, shift,
// splice and `length =` store the new length into that constant global and zero the
// vacated slots in the static data - undefined behaviour, which today reads back silently
// wrong values. Nothing here guards against it.
mlir::Value ensureCapacity(mlir_ts::ArrayType arrayType, mlir::Value header, mlir::Value needed)
{
TypeHelper th(rewriter);
CodeLogicHelper clh(op, rewriter);
auto loc = op->getLoc();
auto ptrType = th.getPtrType();
auto llvmIndexType = typeConverter->convertType(th.getIndexType());

auto dataPtr = dataAddress(arrayType, header);
auto capacityPtr = capacityAddress(arrayType, header);
mlir::Value currentData = rewriter.create<LLVM::LoadOp>(loc, ptrType, dataPtr);
mlir::Value capacity = rewriter.create<LLVM::LoadOp>(loc, llvmIndexType, capacityPtr);
auto mustGrow = rewriter.create<LLVM::ICmpOp>(loc, LLVM::ICmpPredicate::ugt, needed, capacity);
return clh.conditionalExpressionLowering(
loc, ptrType, mustGrow,
[&](OpBuilder &, Location) -> mlir::Value {
auto constant = [&](int64_t value) -> mlir::Value {
return rewriter.create<LLVM::ConstantOp>(loc, llvmIndexType,
rewriter.getIntegerAttr(llvmIndexType, value));
};

mlir::Value doubled = rewriter.create<LLVM::MulOp>(loc, llvmIndexType, ValueRange{capacity, constant(2)});
auto doubledFits = rewriter.create<LLVM::ICmpOp>(loc, LLVM::ICmpPredicate::uge, doubled, needed);
mlir::Value newCapacity = rewriter.create<LLVM::SelectOp>(loc, doubledFits, doubled, needed);
auto belowMinimum =
rewriter.create<LLVM::ICmpOp>(loc, LLVM::ICmpPredicate::ult, newCapacity, constant(4));
newCapacity = rewriter.create<LLVM::SelectOp>(loc, belowMinimum, constant(4), newCapacity);

auto sizeOfElement = rewriter.create<mlir_ts::DialectCastOp>(
loc, llvmIndexType,
rewriter.create<mlir_ts::SizeOfOp>(loc, th.getIndexType(), arrayType.getElementType()));
auto newBytes = rewriter.create<LLVM::MulOp>(loc, llvmIndexType, ValueRange{sizeOfElement, newCapacity});
auto grown = MemoryRealloc(currentData, newBytes);

auto oldBytes = rewriter.create<LLVM::MulOp>(loc, llvmIndexType, ValueRange{sizeOfElement, capacity});
auto tailStart = rewriter.create<LLVM::GEPOp>(loc, ptrType, th.getI8Type(), grown, ValueRange{oldBytes});
auto tailBytes = rewriter.create<LLVM::SubOp>(loc, llvmIndexType, ValueRange{newBytes, oldBytes});
rewriter.create<LLVM::MemsetOp>(
loc, tailStart, rewriter.create<LLVM::ConstantOp>(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)),
tailBytes, /*isVolatile=*/false);

rewriter.create<LLVM::StoreOp>(loc, grown, dataPtr);
rewriter.create<LLVM::StoreOp>(loc, newCapacity, capacityPtr);
return grown;
},
[&](OpBuilder &, Location) -> mlir::Value { return currentData; });
}

// zero `count` elements of `data` from `index` on: the slots pop, shift, splice and a smaller
// `length =` vacate, so gc does not keep what they held alive and a later growth reads zero
void zeroElements(mlir_ts::ArrayType arrayType, mlir::Value data, mlir::Value index, mlir::Value count)
{
TypeHelper th(rewriter);
auto loc = op->getLoc();
auto llvmIndexType = typeConverter->convertType(th.getIndexType());
auto sizeOfElement = rewriter.create<mlir_ts::DialectCastOp>(
loc, llvmIndexType, rewriter.create<mlir_ts::SizeOfOp>(loc, th.getIndexType(), arrayType.getElementType()));
auto start = rewriter.create<LLVM::GEPOp>(loc, th.getPtrType(), th.getI8Type(), data,
ValueRange{rewriter.create<LLVM::MulOp>(
loc, llvmIndexType, ValueRange{sizeOfElement, index})});
auto bytes = rewriter.create<LLVM::MulOp>(loc, llvmIndexType, ValueRange{sizeOfElement, count});
rewriter.create<LLVM::MemsetOp>(
loc, start, rewriter.create<LLVM::ConstantOp>(loc, th.getI8Type(), rewriter.getI8IntegerAttr(0)), bytes,
/*isVolatile=*/false);
}

// of an array value; a null header reads as an empty array (R1)
mlir::Value data(mlir_ts::ArrayType arrayType, mlir::Value array)
{
Expand All @@ -117,10 +197,19 @@ class ArrayLayout : public LLVMCodeHelperBase
}

// a new array over `data` (a block just allocated, or null for an empty array) with `length`
// elements
// elements.
//
// The contract: `data` is either a literal null (an LLVM::ZeroOp) or a fresh non-null heap
// allocation (MemoryAlloc) that nothing else holds. Whether there is a block to count is
// decided by how `data` is spelled, not by its value at run time, so a pointer that is null
// only at run time would have its count stored through null, and a pointer into static data
// (an AddressOfOp: makeStatic's job) or into another array's block would have a count stored
// over memory that is not a block header.
mlir::Value make(mlir_ts::ArrayType arrayType, mlir::Value data, mlir::Value length)
{
auto loc = op->getLoc();
assert(data.getDefiningOp() && !data.getDefiningOp<LLVM::AddressOfOp>() &&
"ArrayLayout::make takes a literal null or a fresh allocation");
if (compileOptions.isRefCounted() && !data.getDefiningOp<LLVM::ZeroOp>())
{
// under rc the header holds a counted reference to its data block, as every copy of
Expand Down
3 changes: 0 additions & 3 deletions tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -1093,13 +1093,10 @@ class CastLogicHelper
return mlir::Value();
}

auto ptrType = th.getPtrType();
auto arrayTypeTs = mlir::cast<mlir_ts::ArrayType>(arrayType);
ArrayLayout layout(op, rewriter, tch.typeConverter, compileOptions);
auto llvmIndexType = tch.convertType(th.getIndexType());
auto sizeValue = clh.createIndexConstantOf(llvmIndexType, size);
auto destArrayElement = mlir::cast<mlir_ts::ArrayType>(arrayType).getElementType();
auto llvmDestArrayElement = tch.convertType(destArrayElement);

if (isUndef)
{
Expand Down
4 changes: 3 additions & 1 deletion tslang/lib/TypeScript/LowerToAffineLoops.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2714,10 +2714,12 @@ void TypeScriptToAffineLoweringTSFuncPass::runOnFunction()
//
// A `string[]` argv is refused in every build where `main` is the entry point (all but a DLL):
// ahead of time, the C runtime passes `char **` too, which would be read as an array header.
// Any array-typed parameter is refused, not only a second one: `main(args: string[])` takes
// `argc` as an array header and faulted ahead of time.
if (!tsContext.compileOptions.isDLL && function.getName() == MAIN_ENTRY_NAME)
{
auto mainInputs = function.getFunctionType().getInputs();
if (mainInputs.size() == 2 && isa<mlir_ts::ArrayType>(mainInputs[1]))
if (llvm::any_of(mainInputs, [](mlir::Type type) { return isa<mlir_ts::ArrayType>(type); }))
{
function.emitError("'main' takes argv as Ref<string> (C's char **), not string[]; read an argument with Deref(argv[i])");
return signalPassFailure();
Expand Down
Loading
Loading