From 226ba9672f1e5109edc47dfb7d4ba8e9e7dd64df Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:05:09 -0500 Subject: [PATCH] Borrow typed-array locals read from fields and parameters A local initialized from a typed-array field or parameter, such as `const chars = this.chars`, took a reference and released it at the end of its block, like any owned binding. Class instances, records, arrays and strings already borrow such a projection when the rest of the block writes none of the locations it reads, and walk and rebound parameters use the same test. Typed arrays are one plain pointer as well, so they now take the same paths: the field or the caller's binding keeps the array alive while the local reads it, and a block that may replace the field still retains its own reference. --- packages/compiler/src/backend/llvm/emitter.ts | 5 +- .../test/bytes-field-borrow-emission.test.ts | 94 +++++++++++++++++++ .../5891-typed-array-field-borrows.ts.json | 6 ++ .../corpus/5891-typed-array-field-borrows.ts | 68 ++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/bytes-field-borrow-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json create mode 100644 tests/corpus/5891-typed-array-field-borrows.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 64a535027..1d745b981 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5385,8 +5385,8 @@ export class LlEmitter { } /** A reference held as one plain pointer that emitReadReceiver can read - * without owning: class instances, records, arrays, strings and - * nullable-pointer unions. Tagged union boxes, closures and dynamic + * without owning: class instances, records, arrays, strings, typed arrays + * and nullable-pointer unions. Tagged union boxes, closures and dynamic * values keep ordinary ownership. */ plainReference(type: IrType): boolean { switch (type.kind) { @@ -5394,6 +5394,7 @@ export class LlEmitter { case "record": case "array": case "string": + case "bytes": return true; case "union": return this.nullableUnions.has(type.unionId); diff --git a/packages/compiler/test/bytes-field-borrow-emission.test.ts b/packages/compiler/test/bytes-field-borrow-emission.test.ts new file mode 100644 index 000000000..168cd78ac --- /dev/null +++ b/packages/compiler/test/bytes-field-borrow-emission.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "vitest"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { + F64, + VOID, + bytesOf, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, +} from "../src/ir/ir.js"; +import { validateModule } from "../src/ir/validate.js"; + +const loc = { file: "bytes-field-borrow.ts", start: 0, end: 0 }; +const units = bytesOf("u16"); +const scanner = { kind: "object" as const, className: "Scanner" }; +const self = (): IrExpr => ({ kind: "varRef", localId: "this", type: scanner, loc }); +const chars = (): IrExpr => ({ + kind: "fieldGet", + obj: self(), + className: "Scanner", + field: "chars", + type: units, + loc, +}); +const local = (): IrExpr => ({ kind: "varRef", localId: "c", type: units, loc }); +const read = (index: number): IrExpr => ({ + kind: "bytesIntrinsic", + method: "get", + receiver: local(), + args: [{ kind: "numLit", value: index, type: F64, loc }], + type: F64, + loc, +}); + +/** `const c = this.chars; ...between; return c[0] + c[1]`. */ +function method(name: string, between: IrStmt[]): IrFunction { + return { + name, + params: [{ localId: "this", name: "this", type: scanner }], + returnType: F64, + locals: [ + { id: "this", name: "this", type: scanner, mutable: false }, + { id: "c", name: "c", type: units, mutable: false }, + ], + body: [ + { kind: "varDecl", localId: "c", init: chars(), loc }, + ...between, + { + kind: "return", + value: { kind: "bin", op: "+", left: read(0), right: read(1), type: F64 }, + loc, + }, + ], + loc, + } as IrFunction; +} + +test("a typed-array local read from a field the rest of its block never writes borrows it", () => { + const replace: IrStmt = { + kind: "fieldSet", + obj: self(), + className: "Scanner", + field: "chars", + value: { + kind: "bytesNew", + source: { kind: "numLit", value: 4, type: F64, loc }, + type: units, + loc, + }, + loc, + }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + functions: [ + { name: "main", params: [], returnType: VOID, locals: [], body: [], loc }, + method("%Scanner.peek", []), + method("%Scanner.swap", [replace]), + ], + classes: [{ name: "Scanner", fields: [{ name: "chars", type: units }], methods: [], loc }], + }; + expect(validateModule(mod)).toEqual([]); + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + const body = (name: string): string => + new RegExp(`^define [^\\n]*@sc_bf__x25_Scanner_${name}\\b[^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + expect(body("peek")).not.toBe(""); + // The field keeps the array alive while the borrowed local reads it. + expect(body("peek")).not.toContain("bytes_retain"); + expect(body("peek")).not.toContain("bytes_release"); + // Replacing the field could free the array the local holds: it keeps a reference. + expect(body("swap")).toContain("call ptr @scr_bytes_retain_v("); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json new file mode 100644 index 000000000..efd0044d0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5891-typed-array-field-borrows.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5891-typed-array-field-borrows.ts b/tests/corpus/5891-typed-array-field-borrows.ts new file mode 100644 index 000000000..cdd315191 --- /dev/null +++ b/tests/corpus/5891-typed-array-field-borrows.ts @@ -0,0 +1,68 @@ +// Typed-array locals read from fields and parameters: reads while nothing +// replaces the field, the field replaced directly or by a callee while the +// local is in use, rebound parameters, views and detached-length reads. + +class Scanner { + chars: Uint16Array; + pos = 0; + constructor(text: string) { + this.chars = Scanner.units(text); + } + static units(text: string): Uint16Array { + const out = new Uint16Array(text.length + 2); + for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i); + return out; + } + sum(): number { + const chars = this.chars; + let total = 0; + for (let i = 0; i < chars.length; i++) total += chars[i]!; + return total; + } + replaceThenRead(): number { + const chars = this.chars; + this.chars = Scanner.units("zz"); + return chars[0]! * 1000 + this.chars[0]!; + } + reset(text: string): void { + this.chars = Scanner.units(text); + } + readAcrossReset(): string { + const chars = this.chars; + const before = chars[0]!; + this.reset("q"); + return before + ":" + chars[1] + ":" + this.chars[0] + ":" + chars.length; + } +} + +function firstOf(units: Uint16Array, fallback: Uint16Array): number { + if (units.length === 0) units = fallback; + let n = 0; + while (n < units.length && units[n] !== 0) n++; + return n; +} + +function walk(a: Uint8Array): number { + let total = 0; + while (a.length > 1) { + total += a[0]!; + a = a.subarray(1); + } + return total + a.length; +} + +const s = new Scanner("hello"); +console.log(s.sum(), s.replaceThenRead(), s.sum()); +const t = new Scanner("abc"); +console.log(t.readAcrossReset(), t.sum()); +console.log(firstOf(new Uint16Array(0), Scanner.units("xyz")), firstOf(Scanner.units("ab"), new Uint16Array(1))); +console.log(walk(new Uint8Array([1, 2, 3, 4, 5])), walk(new Uint8Array(0))); + +const holders: Scanner[] = []; +for (let i = 0; i < 50; i++) holders.push(new Scanner("item" + i)); +let acc = 0; +for (const h of holders) { + acc += h.sum(); + acc += h.replaceThenRead(); +} +console.log(acc);