Repository navigation
A string cast over an array's data block dangles after the array grows; under rc it counts a block it does not own #481
Description
A string made over an array's data block with <string><Opaque>Ref(buffer[i]) keeps pointing into that block after the array reallocates it. Under rc, the string also takes a counted reference to a block it does not own.
The default library uses this pattern: convertNumber, convertInteger and the date formatters in lib.linux.ts, and the HTTP response code in lib.ts.
Repro: the string dangles once the array grows
declare function strcpy(dst: string, src: string): string;
function main() {
let buffer: char[] = [];
buffer.length = 16;
const s = <string><Opaque>Ref(buffer[0]);
strcpy(s, "hello");
print("before: ", s);
// the buffer grows: its data block moves, and the string still points at the old one
for (let i = 0; i < 4096; i++) buffer.push(<char>0);
// reuse freed memory
const fill: string[] = [];
for (let i = 0; i < 4096; i++) fill.push("filler-" + i);
print("after: ", s);
print("done.");
}Expected: after: hello.
Actual, tslang --emit=jit --no-default-lib:
| model | after: prints |
|---|---|
| gc | filler-0 |
| rc | filler-9 |
| none | filler-4 |
| own | filler-24 |
The string reads memory that was freed and reused. The results are the same with main fbd7edc plus the growth change (#453 step 3) and with main f013674 from before #453, so the bug predates the array-reference work.
The rc side: counting a block the string does not own
Under rc, the <string><Opaque> cast retains whatever pointer it is given. It assumes a heap block's count word sits one word before that pointer (tsretv on a GEP into the data block). Two cases follow:
Ref(buffer[0]): this works only because the pointer is exactly the start of the array's data block. After An array is a reference to a header that never moves (#453, #477) #480 the header also holds one counted reference to that block, so the block survives the array until the string is released.- When the array reallocates, the new block inherits the old count of 2 and leaks.
- The string's later release decrements the count word of the freed old block.
- The elements are never released if the string outlives the array. That matters only for element types other than
char.
Ref(buffer[1]), or any pointer that is not the start of a heap block: the retain and release increment and decrement whatever bytes sit one word before the pointer, which are element data or the previous block's tail. This did not show on Windows (the heap hides it); it follows from the code.
Under own, the same cast copies the string (strlen + alloc + memcpy), so it means two different things under rc and own.
Options
- Make the cast copy under every model, as own does:
<string><Opaque>Ref(x)gives an owned string copied from the bytes at that point. This breaks the "write through the string" idiom (snprintf(s, ...)into the buffer, then uses). The default library would then build the string after filling the buffer (snprintf(<string><Opaque>Ref(buffer[0]), ...), then copy). - Treat the cast's result as a borrowed view: no retain and no release under rc. Its lifetime is then the array's, and the default library must copy the string before the array dies (
convertNumberreturnss, so it would have to return a copy). - Keep it as is and document it: valid only while the array is alive and not reallocated.
Found while reviewing #453 (PR #480; spec section 4 amendment).
🤖 Generated with Claude Code
Activity
Arrays grow by doubling (#453 step 3) (#482)