From f8b1f9ffc36ade3f470c4e6d98d7625f5124caff Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 05:02:40 -0500 Subject: [PATCH] Keep a spread tuple alive until all its fields are packed into the rest A spread tuple's first field carries the statements that evaluate the tuple into a hidden local, and its later fields read that local. When lowerArraySpreadItems stored the first field as one item's expression, the local's frame ended with that store, so a tuple returned by a call was released before its later fields were read (a use-after-free under SCRIPTC_SAN=1). The field's statements now join the packing sequence at the item's position, whose frame lasts until every item is stored. --- .../frontend/lowering/containers/array-construction.ts | 10 +++++++++- tests/corpus/8004-optional-element-conversions.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 63e2e6e07..5029abc36 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -174,7 +174,15 @@ export function lowerArraySpreadItems( ]; for (const node of nodes) { if (!(node instanceof AstNode)) { - const value = undefinedForNullish(lowerer, node, arrType); + // A spread tuple's first field declares the tuple's storage, which + // the later fields read: its statements join this sequence, whose + // frame lasts until every item is stored. + let item = node; + if (item.kind === "seqExpr") { + body.push(...item.stmts); + item = item.result; + } + const value = undefinedForNullish(lowerer, item, arrType); const stored = lowerer.runtimeOptionalWidening(value.type, elem) !== null || optionalElementValue(lowerer, value, elem) diff --git a/tests/corpus/8004-optional-element-conversions.ts b/tests/corpus/8004-optional-element-conversions.ts index 9a4ac55ac..a592a4f32 100644 --- a/tests/corpus/8004-optional-element-conversions.ts +++ b/tests/corpus/8004-optional-element-conversions.ts @@ -58,6 +58,14 @@ const mixedChanging: [boolean, boolean | undefined] = [true, undefined]; console.log(flags(...mixedChanging, ...mixedMore, flip(mixedChanging, true), ...mixedChanging)); const mixedWords: [string, string | undefined] = ["a", undefined]; console.log(label("z", ...["b", undefined, "c"], ...mixedWords), label("y", ...mixedWords, ...new Set(["s", undefined]))); +// A tuple returned by a call keeps its string fields alive until every +// field is packed beside the other spreads. +function makeTuple(n: number): [string, string | undefined, string] { + return ["p" + n.toString(16).repeat(4), n % 2 ? undefined : "q" + n, "r".repeat((n % 7) + 3) + n]; +} +for (let i = 0; i < 3; i++) { + console.log(label("z", ...["m", undefined], ...makeTuple(i), ...mixedWords), label("y", ...makeTuple(i + 10), ...new Set(["s" + i]))); +} function pick(start: (number | undefined)[] | string[], other: (number | undefined)[] | string[]): string { let a = start;