Skip to content

rc: string resize, rest-parameter packs and grown array slots (DefaultLib rc failures 52 -> 20) - #413

Merged
ASDAlexander77 merged 3 commits into
mainfrom
rc-string-resize
Sep 30, 2026
Merged

ASDAlexander77 merged 3 commits into
mainfrom
rc-string-resize

Conversation

@ASDAlexander77

@ASDAlexander77 ASDAlexander77 commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Three compiler bugs behind most of the DefaultLib test failures under -mm=rc. Each has its own commit and its own regression test, and each test fails on main. Before this PR, 52 of the 314 rc DefaultLib test runs failed.

1. s.length = n makes a new string and leaves the old one alone

Assigning a string's length used to reallocate the block in place (ts.SetStringLength → realloc). When realloc moves a block it frees the old one, even though something else may still hold it:

  • the caller, when the string came in through a this: string parameter;
  • a temporary;
  • another variable.

Under rc, that holder later released the freed block, giving 0xC0000374.

DefaultLib's "".clone().resize(n) does exactly this. It is behind padStart, padEnd, repeat, the regexp match results and more, so this accounted for most of the string and regexp failures. The other models were affected too:

  • none: a second variable was left pointing at freed memory;
  • gc: GC_realloc can grow a block in place, so a second variable saw the resized string's writes.

A string is a value, so the fix treats the resize as s = <copy of s at size n>:

  • a new ts.StringResize op allocates the new block and copies what fits;
  • the result goes through the ordinary assignment path, which gives up the old string only if s owned it;
  • ts.SetStringLength is removed;
  • -mm=own counts the new op as fresh and as a borrow of its operand, the same as ts.StringConcat.

Test: 00string_length_resize.ts. On main it fails under all three models: rc with heap corruption, none and gc with the alias assert.

2. The array a rest parameter receives takes a reference to each element

A rest parameter's array is built at the call and, like an array literal's, releases every element when it dies. The packing done for calls through an extension function took no reference to the elements:

  1. In fruits.concat(["x", "y"]), the callee's first for (const item of other) took the only reference and gave it back, freeing the argument array.
  2. The second loop then read freed memory.

This is the array_concat failure. Test: 00rest_args_pack_owned.ts, which fails on main under rc.

3. A grown array's new slots read as zero under every model but gc

The slots added by arr.length = n used to be zeroed only under rc/own, and only when the element type owns heap memory. Everywhere else they held whatever was left in that memory.

DefaultLib's Set and Map grow their int[] buckets this way and treat an unwritten bucket as empty. Outside gc, their hash chains therefore started from garbage, and new Set([1, 2]).union(new Set([2, 3])) lost elements in about half the runs (the set_* tests). gc was never affected: the collector returns cleared memory, and GC_realloc clears what a shrink gives up.

Test: 00array_length_grow_zeroed.ts, which fails on main under rc and none.

Results

before after
Release ctest 3283/3283
DefaultLib rc (compile + JIT) 52 of 314 runs failed 20 of 316 runs fail
DefaultLib gc (compile + JIT) 157/157 158/158 (owning_copies.ts is new)

The DefaultLib numbers are with the companion DefaultLib PR (ASDAlexander77/TypeScriptCompilerDefaultLib#14). That PR fixes the last memory bug on the library side: raw memcpy of owning elements in concat, slice, copyWithin and the Set/Map resizeHelper.

How the Set bug was found

The Set failures were intermittent. To make them reproducible, I temporarily replaced ProcessHeap.h with a quarantine heap for diagnosis only; it is not part of this PR. That heap:

  • poisons freed blocks and never frees them;
  • sets the rc count of a freed block to 1, so a later release is caught;
  • makes every realloc move the block.

With it the failures became deterministic, and a 0xCDCDCDCC read in a bucket pointed straight at the unwritten slots.

Not in this PR

These remain:

  • sleep.ts, weakref_basic.ts: link errors. The async runtime's thread registration and weak refs reference GC_*, and an rc AOT program links no collector. This is a configuration issue, not a memory bug.
  • Regex and replace group (regexp_indices, regexp_test2/3, string_match, string_matchAll, which spins, and string_replace*), plus intermittent heap corruption (0xC0000374) in array_fill and string_search. The quarantine heap never frees, and Windows only validates heap metadata on free, so it could not see these. They are most likely writes past the end of a block. Guard bytes are the next step.
  • new Set([2, 4, 6, 8]) infers Set<any>: an inference gap. As a result, the DefaultLib Set tests exercise the any path.
  • -mm=own rejects return box.text.length: StringLengthOp is missing from own's borrow list. This was already the case before this PR.

🤖 Generated with Claude Code

ASDAlexander77 and others added 3 commits September 30, 2026 13:24
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 = <copy of s at size n>`: 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 <noreply@anthropic.com>
…ment

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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant