From 65a14b9dc8ad47a08f89afa2ea889106c89140e7 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 19:19:59 +0200 Subject: [PATCH 01/10] Cut clang memory and IR volume of large builds: no MachineOutliner (a Laravel part 13.7 GB -> 4.65 GB), one cold raise per guard site, table dispatch for $o->$name on a known class, nounwind runtime helpers called not invoked, cell_int_bad slow path out of line --- src/Compile/Mir/Passes/EmitLlvmExceptions.php | 10 +++ src/Compile/Mir/Passes/EmitLlvmExpr.php | 84 +++++++++++++++---- src/Compile/Mir/Passes/EmitLlvmObjects.php | 19 +++++ src/Manticore/Main.php | 11 ++- 4 files changed, 106 insertions(+), 18 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmExceptions.php b/src/Compile/Mir/Passes/EmitLlvmExceptions.php index be598757..f75ff5f4 100644 --- a/src/Compile/Mir/Passes/EmitLlvmExceptions.php +++ b/src/Compile/Mir/Passes/EmitLlvmExceptions.php @@ -777,8 +777,18 @@ private function ehCallRest(string $l): string /** Runtime helpers that never run user code and never throw. */ private function ehNounwind(string $callee): bool { + // Each invoke costs a block and an unwind edge per call site: in the + // Laravel build the backtrace ring alone was 648K of them. return $callee === '@__mc_eh_catch' || $callee === '@__mir_elem_untag' + || $callee === '@__mir_bt_push' + || $callee === '@__mir_bt_push_rel' + || $callee === '@__mir_bt_pop' + || $callee === '@__manticore_deref' + || $callee === '@__manticore_tag' + || $callee === '@__manticore_cell_int_bad' + || $callee === '@__mir_obj_handle_alloc' + || \str_starts_with($callee, '@__mc_dynf_') || \str_starts_with($callee, '@__mir_arena_') || \str_starts_with($callee, '@__mir_rc_retain') || \str_starts_with($callee, '@__manticore_box_'); diff --git a/src/Compile/Mir/Passes/EmitLlvmExpr.php b/src/Compile/Mir/Passes/EmitLlvmExpr.php index a4a09b72..49c868bb 100644 --- a/src/Compile/Mir/Passes/EmitLlvmExpr.php +++ b/src/Compile/Mir/Passes/EmitLlvmExpr.php @@ -822,9 +822,14 @@ private function taggedToIntRuntime(): string private function cellIntBadRuntime(): string { $m = (string)\Compile\MemoryAbi::CELL_PAYLOAD_MASK; + // Inline only the untagged / int test; the string scan is out of line + // (inlined whole it was ~450K lines of the Laravel IR). $o = "\ndefine i32 @__manticore_cell_int_bad(i64 %v) alwaysinline {\n"; - $o .= "entry:\n %hi = lshr i64 %v, 48\n %isint = icmp eq i64 %hi, 65521\n br i1 %isint, label %ok, label %slow\n"; - $o .= "slow:\n %istag = icmp ugt i64 %v, -4503599627370496\n br i1 %istag, label %tagged, label %ok\n"; + $o .= "entry:\n %hi = lshr i64 %v, 48\n %isint = icmp eq i64 %hi, 65521\n %istag = icmp ugt i64 %v, -4503599627370496\n"; + $o .= " %nt = xor i1 %istag, true\n %fast = or i1 %isint, %nt\n br i1 %fast, label %ok, label %slow\n"; + $o .= "ok:\n ret i32 0\nslow:\n %r = call i32 @__manticore_cell_int_bad_slow(i64 %v)\n ret i32 %r\n}\n"; + $o .= "\ndefine i32 @__manticore_cell_int_bad_slow(i64 %v) noinline {\n"; + $o .= "entry:\n %hi = lshr i64 %v, 48\n br label %tagged\n"; $o .= "tagged:\n %nib = and i64 %hi, 15\n switch i64 %nib, label %ok [\n i64 3, label %null\n i64 7, label %arr\n i64 4, label %str\n ]\n"; $o .= "ok:\n ret i32 0\nnull:\n ret i32 1\narr:\n ret i32 2\n"; $o .= "str:\n %sp = and i64 %v, " . $m . "\n %sptr = inttoptr i64 %sp to ptr\n br label %ws\n"; @@ -6835,10 +6840,11 @@ private function emitObjArgGuard(string $head, string $given = ' given', string $vt = $this->lastValueType; $out = $this->cellTagIr($v); $tag = $this->cellTagReg; + $arms = []; foreach ([[1, 'int'], [4, 'string']] as $tn) { $c = $this->ssa->allocReg(); $out .= ' ' . $c . ' = icmp eq i64 ' . $tag . ', ' . (string)$tn[0] . "\n"; - $out .= $this->emitThrowIf($c, $cls, $head . $tn[1] . $given); + $arms[] = [$c, $head . $tn[1] . $given]; } $isB = $this->ssa->allocReg(); $bit = $this->ssa->allocReg(); @@ -6852,13 +6858,60 @@ private function emitObjArgGuard(string $head, string $given = ' given', string $out .= ' ' . $t . ' = icmp eq i64 ' . $bit . ", 1\n"; $out .= ' ' . $isF . ' = and i1 ' . $isB . ', ' . $f . "\n"; $out .= ' ' . $isT . ' = and i1 ' . $isB . ', ' . $t . "\n"; - $out .= $this->emitThrowIf($isF, $cls, $head . 'false' . $given); - $out .= $this->emitThrowIf($isT, $cls, $head . 'true' . $given); + $arms[] = [$isF, $head . 'false' . $given]; + $arms[] = [$isT, $head . 'true' . $given]; + $out .= $this->emitColdRaise($arms, $cls); $this->lastValue = $v; $this->lastValueType = $vt; return $out; } + /** + * Throw `$cls` with the message of the first arm whose i1 holds, as ONE cold + * block per site: the message is a `select` over the arms' literals and the + * raise is a single call. One {@see emitThrowIf} per arm was a branch, a raise + * and a dead block each — the per-kind guards were ~5% of the Laravel IR. + * Falls back to per-arm throws when `$cls` has no raise helper. + * @param list $arms [i1 register, message] + */ + private function emitColdRaise(array $arms, string $cls): string + { + $raise = $this->raiseHelper($cls); + if ($raise === '' || \count($arms) < 2) { + $out = ''; + foreach ($arms as $a) { $out .= $this->emitThrowIf($a[0], $cls, $a[1]); } + return $out; + } + $saved = $this->lastValue; + $savedT = $this->lastValueType; + $any = $arms[0][0]; + $out = ''; + for ($i = 1; $i < \count($arms); $i++) { + $o = $this->ssa->allocReg(); + $out .= ' ' . $o . ' = or i1 ' . $any . ', ' . $arms[$i][0] . "\n"; + $any = $o; + } + $thr = $this->ssa->allocLabel('arith.throw'); + $ok = $this->ssa->allocLabel('arith.ok'); + $out .= ' br i1 ' . $any . ', label %' . $thr . ', label %' . $ok . "\n" . $thr . ":\n"; + $msgs = []; + foreach ($arms as $a) { + $out .= $this->emitNode(new \Compile\Mir\StringConst($a[1], Type::string_())); + $out .= $this->coerceToI64(); + $msgs[] = $this->lastValue; + } + $msg = $msgs[\count($msgs) - 1]; + for ($i = \count($arms) - 2; $i >= 0; $i--) { + $s = $this->ssa->allocReg(); + $out .= ' ' . $s . ' = select i1 ' . $arms[$i][0] . ', i64 ' . $msgs[$i] . ', i64 ' . $msg . "\n"; + $msg = $s; + } + $out .= ' call void ' . $raise . '(i64 ' . $msg . ", i64 0)\n unreachable\n" . $ok . ":\n"; + $this->lastValue = $saved; + $this->lastValueType = $savedT; + return $out; + } + private function unboxCellArg(Node $a, array $ptypes, int $pi, array $ahmask = []): string { $this->cellStrOwn = ''; @@ -7030,17 +7083,18 @@ private function unboxCellToTypeRaw(Type $pt): string $keep = $this->lastValue; $out = ''; // A by-ref scratch holds a callee's OUTPUT: a null there is no argument. - foreach ($this->intGuardOff ? [] : [[1, 'null'], [2, 'array'], [3, 'string']] as $bw) { - if ($out === '') { - $bad = $this->ssa->allocReg(); - $out = ' ' . $bad . ' = call i32 @__manticore_cell_int_bad(i64 ' . $keep . ")\n"; + if (!$this->intGuardOff) { + $bad = $this->ssa->allocReg(); + $out = ' ' . $bad . ' = call i32 @__manticore_cell_int_bad(i64 ' . $keep . ")\n"; + $arms = []; + foreach ([[1, 'null'], [2, 'array'], [3, 'string']] as $bw) { + $cnd = $this->ssa->allocReg(); + $out .= ' ' . $cnd . ' = icmp eq i32 ' . $bad . ', ' . (string)$bw[0] . "\n"; + $arms[] = [$cnd, $this->unboxPropCtx !== '' + ? 'Cannot assign ' . $bw[1] . ' to property ' . $this->unboxPropCtx . ' of type int' + : 'Argument must be of type int, ' . $bw[1] . ' given']; } - $cnd = $this->ssa->allocReg(); - $out .= ' ' . $cnd . ' = icmp eq i32 ' . $bad . ', ' . (string)$bw[0] . "\n"; - $msg = $this->unboxPropCtx !== '' - ? 'Cannot assign ' . $bw[1] . ' to property ' . $this->unboxPropCtx . ' of type int' - : 'Argument must be of type int, ' . $bw[1] . ' given'; - $out .= $this->emitThrowIf($cnd, 'TypeError', $msg); + $out .= $this->emitColdRaise($arms, 'TypeError'); } $this->lastValue = $keep; $r = $this->ssa->allocReg(); diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index f88f3e8e..10b4de51 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -4907,6 +4907,24 @@ private function emitDynPropDispatch(DynProp_ $n, array $propTypes, ?ClassDef $b $out .= ' ' . $res . " = alloca i64\n"; $out .= ' store i64 0, ptr ' . $res . "\n"; $endL = $this->ssa->allocLabel('dynp.end'); + // Inlining the full read path per name made a site cost names × class + // switch: Carbon::roundUnit reached 348K lines of IR. Past a few names, + // probe the table of shared per-name readers instead, as the erased path does. + $tabled = \count($propTypes) >= self::DYNF_TABLE_MIN; + if ($tabled) { + $out .= $this->emitObjPtrOf($n->object); + $objPtr = $this->lastValue; + $rows = []; + foreach ($propTypes as $p => $pt) { $rows[(string)$p] = $this->cellPropReadHelperFor((string)$p); } + $out .= $this->dynPropTableProbe($keyP, $rows, 'dynp'); + $fp = $this->lastValue; + $r = $this->ssa->allocReg(); + $out .= ' ' . $r . ' = call i64 ' . $fp . '(ptr ' . $objPtr . ")\n"; + $out .= ' store i64 ' . $r . ', ptr ' . $res . "\n"; + $out .= ' br label %' . $endL . "\n"; + $out .= $this->dynPropMissLabel . ":\n"; + $propTypes = []; + } foreach ($propTypes as $p => $pt) { $hitL = $this->ssa->allocLabel('dynp.hit'); $nextL = $this->ssa->allocLabel('dynp.next'); @@ -4956,6 +4974,7 @@ private function emitDynPropDispatch(DynProp_ $n, array $propTypes, ?ClassDef $b $out .= ' ' . $loaded . ' = load i64, ptr ' . $res . "\n"; $this->lastValue = $loaded; $this->lastValueType = 'i64'; + if ($tabled) { $this->markCellOpaque($loaded); } return $out; } diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index b7daf3aa..3c0c35dd 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -1027,14 +1027,19 @@ function clang_opt_level(): string function clang_tuning_flags(): string { + // The MachineOutliner is a MODULE pass at the end of codegen, so every + // MachineFunction of the module stays alive until it runs: one Laravel part + // (193 MB bitcode) peaked at 13.7 GB RSS with it, 4.65 GB without, for a + // 1.5% bigger object. arm64 schedules it by default. + $f = ' -mllvm -enable-machine-outliner=never'; if (\Compile\Debug::$asan) { - return ' -fsanitize=address -mllvm -asan-stack=0 -mllvm -asan-globals=0'; + return $f . ' -fsanitize=address -mllvm -asan-stack=0 -mllvm -asan-globals=0'; } $e = \getenv("MANTICORE_FAT_FUNCTION_SPLIT"); if ($e !== false && $e !== "" && $e !== "0" && $e !== "off") { - return " -mllvm -enable-split-machine-functions"; + return $f . " -mllvm -enable-split-machine-functions"; } - return ""; + return $f; } /** From 9b910692cca3021457eb3ba631306ea19548904c Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 19:20:00 +0200 Subject: [PATCH 02/10] Release per-request leaks found in Laravel: a minting builtin's discarded result, and a cell or array union read as borrowed in a conditional arm or a local --- src/Compile/Mir/Ownership.php | 20 +++++++++++++++---- src/Compile/Mir/Passes/EmitLlvmCalls.php | 11 +++++++++- src/Compile/Mir/Passes/EmitLlvmControl.php | 8 ++++++++ tests/aot/cases/leak_cell_union_cond_arm.php | 12 +++++++++++ .../cases/leak_discarded_builtin_result.php | 7 +++++++ .../aot/expected/leak_cell_union_cond_arm.out | 2 ++ .../leak_discarded_builtin_result.out | 2 ++ 7 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 tests/aot/cases/leak_cell_union_cond_arm.php create mode 100644 tests/aot/cases/leak_discarded_builtin_result.php create mode 100644 tests/aot/expected/leak_cell_union_cond_arm.out create mode 100644 tests/aot/expected/leak_discarded_builtin_result.out diff --git a/src/Compile/Mir/Ownership.php b/src/Compile/Mir/Ownership.php index 8bf08a12..ca891317 100644 --- a/src/Compile/Mir/Ownership.php +++ b/src/Compile/Mir/Ownership.php @@ -203,6 +203,15 @@ private function ownCode(Type $t): int return $t->kind === Type::KIND_UNION ? self::OBJ : self::VEC; } + /** `+ - *` over cells run {@see Passes\EmitLlvmExpr::emitTaggedArith}, which mints a + * fresh value on every path: a numeric cell for all three, a union array for `+`. */ + public static function cellArithMints(Node $n): bool + { + $k = $n->kind; + if ($k === Node::KIND_ADD) { return $n->type->kind === Type::KIND_CELL; } + return ($k === Node::KIND_SUB || $k === Node::KIND_MUL) && $n->type->isNumericCell(); + } + public static function isClosureClass(string $cls): bool { return $cls === 'Closure' || \str_starts_with($cls, '__closure_'); @@ -349,6 +358,8 @@ private function storedOwned(Node $value): bool // A string / cell bitwise op mints its result like a concat, on the // heap whatever the allocKind says ({@see BitOp::mintsFresh}). if (BitOp::mintsFresh($value)) { return true; } + // …and so does a cell `+ - *` ({@see cellArithMints}, the temp half's twin). + if (self::cellArithMints($value)) { return true; } // `(string)$int` / `(string)$float` ALLOCATE — __mir_int_to_str and // __mir_float_to_str hand back a fresh rc=1 buffer exactly as a string // builtin does. This was the one producer nobody owned: the local took @@ -884,8 +895,10 @@ public function tempCellOwned(Node $n, bool $lastCallWasBuiltin): bool // `+ - *` over a numeric cell run {@see Passes\EmitLlvmExpr::emitTaggedArith}: // the helper boxes a NEW cell on every path, a counted heap box past the // inline int form. - if (($k === Node::KIND_ADD || $k === Node::KIND_SUB || $k === Node::KIND_MUL) - && $n->type->isNumericCell()) { return true; } + // A cell `+` of two arrays is the union (`__manticore_tagged_add` boxes a + // fresh `__mir_array_union`), so any cell-typed `+` mints too; read as a + // borrow, `$x = ($a[$k] ?? []) + ($b[$k] ?? []); return $x;` leaked the union. + if (self::cellArithMints($n)) { return true; } if (BitOp::mintsFresh($n)) { return true; } if ($k !== Node::KIND_CALL) { return false; } $fn = $n->function; @@ -1458,8 +1471,7 @@ public function returnBorrowsCell(Node $v, bool $moved, bool $erasedArray): bool if ($this->returnCondNormalized($v, $erasedArray)) { return false; } if ($this->isUserOffsetGetRead($v)) { return false; } // A numeric op's cell is minted by its helper ({@see tempCellOwned}). - if (($k === Node::KIND_ADD || $k === Node::KIND_SUB || $k === Node::KIND_MUL) - && $v->type->isNumericCell()) { return false; } + if (self::cellArithMints($v)) { return false; } return true; } diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index f21ac589..3de1aaab 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -5041,7 +5041,16 @@ private function emitDiscardedCallRelease(Node $s): string $out = $this->coerceToI64(); return $out . $this->rcReleaseReg($this->lastValue, $pf); } - if ($this->lastCallWasBuiltin) { return ''; } + // A MINTING builtin (`json_decode('[]');`, `str_repeat(…);`) hands its + // result at +1 like a body does; nothing binds it, so drop it here — + // Laravel's JsonResponse::setData leaked one array per request. A + // borrowing builtin classifies Borrow and is left alone. + if ($this->lastCallWasBuiltin) { + if ($this->own->classifyTemp($s, true) <= 0) { return ''; } + $bf = $this->isClosureValueType($s->type) ? 'closure' : $this->discardReleaseFlavor($s->type); + if ($bf === '') { return ''; } + return $this->coerceToI64() . $this->rcReleaseReg($this->lastValue, $bf); + } if (!isset($this->sigs->paramTypes[$fname])) { return ''; } // Value context derefs the address and takes a +1 copy // ({@see byRefValueCopyRetainIr}). Only an array copy is counted; a raw diff --git a/src/Compile/Mir/Passes/EmitLlvmControl.php b/src/Compile/Mir/Passes/EmitLlvmControl.php index a65c1ee4..6b87d0e1 100644 --- a/src/Compile/Mir/Passes/EmitLlvmControl.php +++ b/src/Compile/Mir/Passes/EmitLlvmControl.php @@ -903,6 +903,14 @@ private function armIsFresh(Node $arm, string $flavor): bool || $k === Node::KIND_INVOKE || \Compile\Mir\BitOp::mintsFresh($arm)) { return true; } + // An array `+` is `__mir_array_union`'s fresh +1 buffer ({@see + // \Compile\Mir\Ownership} owns it like a literal); retained again, Laravel's + // `RouteCollection::get` leaked the union on every request. + if ($k === Node::KIND_ADD && $arm->type->kind === Type::KIND_ARRAY + && $arm->allocKind === \Compile\Mir\AllocationKind::RC_HEAP) { + return true; + } + if (\Compile\Mir\Ownership::cellArithMints($arm)) { return true; } if ($k === Node::KIND_CALL) { $fn = $arm->function; if ($this->sigs->returnsByRef[$fn] ?? false) { return false; } diff --git a/tests/aot/cases/leak_cell_union_cond_arm.php b/tests/aot/cases/leak_cell_union_cond_arm.php new file mode 100644 index 00000000..3650d972 --- /dev/null +++ b/tests/aot/cases/leak_cell_union_cond_arm.php @@ -0,0 +1,12 @@ +r['GET']['k' . $i] = $i; } } + public function viaArm($m = null) { return is_null($m) ? [] : ($this->d[$m] ?? []) + ($this->r[$m] ?? []); } + public function viaLocal($m = null) { $x = ($this->d[$m] ?? []) + ($this->r[$m] ?? []); return $x; } } +function run(C $c): int { return count($c->viaArm('GET')) + count($c->viaLocal('GET')); } +$c = new C(); +echo run($c), "\n"; +$m0 = memory_get_peak_usage(); +for ($i = 0; $i < 20000; $i++) { run($c); } +echo ((memory_get_peak_usage() - $m0) >> 20) < 8 ? "flat\n" : "LEAK\n"; diff --git a/tests/aot/cases/leak_discarded_builtin_result.php b/tests/aot/cases/leak_discarded_builtin_result.php new file mode 100644 index 00000000..d78a43fd --- /dev/null +++ b/tests/aot/cases/leak_discarded_builtin_result.php @@ -0,0 +1,7 @@ +> 20) < 8 ? "flat\n" : "LEAK\n"; diff --git a/tests/aot/expected/leak_cell_union_cond_arm.out b/tests/aot/expected/leak_cell_union_cond_arm.out new file mode 100644 index 00000000..d3bd97d3 --- /dev/null +++ b/tests/aot/expected/leak_cell_union_cond_arm.out @@ -0,0 +1,2 @@ +400 +flat diff --git a/tests/aot/expected/leak_discarded_builtin_result.out b/tests/aot/expected/leak_discarded_builtin_result.out new file mode 100644 index 00000000..48b60a06 --- /dev/null +++ b/tests/aot/expected/leak_discarded_builtin_result.out @@ -0,0 +1,2 @@ +0 +flat From 5882eba1a1158a42abc194a3d989f1a0e9d5e561 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 20:33:31 +0200 Subject: [PATCH 03/10] Seed the init map of a fresh instance with its untyped properties: in a program that makes lazy ghosts every untyped read before a write was the uninitialized-property Error (Laravel Application::$appPath at boot) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 31 ++++++++++++++++++- .../cases/lazy_build_untyped_prop_null.php | 13 ++++++++ .../expected/lazy_build_untyped_prop_null.out | 4 +++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/aot/cases/lazy_build_untyped_prop_null.php create mode 100644 tests/aot/expected/lazy_build_untyped_prop_null.out diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 10b4de51..c816a691 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -158,11 +158,12 @@ private function emitObjAllocInitRaw(?\Compile\Mir\ClassDef $cd): string . $obj . ', i64 ' . (string)$cd->bagOffset() . "\n"; $out .= ' store i64 0, ptr ' . $bGep . "\n"; } + $seed = $this->initMapSeed($cd); for ($w = 0; $w < $cd->initMapWords; $w = $w + 1) { $mGep = $this->ssa->allocReg(); $out .= ' ' . $mGep . ' = getelementptr inbounds i8, ptr ' . $obj . ', i64 ' . (string)($cd->initMapOffset + 8 * $w) . "\n"; - $out .= ' store i64 0, ptr ' . $mGep . "\n"; + $out .= ' store i64 ' . (string)($seed[$w] ?? 0) . ', ptr ' . $mGep . "\n"; } } if (!$isStruct) { $out .= ' call void @__mir_obj_handle_alloc(ptr ' . $obj . ")\n"; } @@ -178,6 +179,30 @@ private function emitObjAllocInitRaw(?\Compile\Mir\ClassDef $cd): string * One `private` global per class, named by the class so the part a split * puts it in stays byte-stable across unrelated edits. */ + /** + * The init-map words a fresh instance starts with: an UNTYPED property is + * initialized from birth (php's implicit null), so its bit is set. A lazy + * ghost's maker clears the map and ORs this image back once its initializer + * ran ({@see __mc_lazy_make}). Starting it clear made every untyped read the + * "must not be accessed before initialization" Error once a program made + * ghosts — Laravel's `$this->appPath ?: …` at boot. + * @return array word index => bits + */ + private function initMapSeed(\Compile\Mir\ClassDef $cd): array + { + $seed = []; + $wb = \Compile\MemoryAbi::INIT_MAP_WORD_BITS; + foreach ($cd->initMapProps as $pn => $_) { + $pm = $cd->propertyMeta[$pn] ?? null; + if ($pm === null || $pm->typeHint !== '') { continue; } + $bit = $cd->initMapBit((string)$pn); + if ($bit < 0) { continue; } + $wi = \intdiv($bit, $wb); + $seed[$wi] = ($seed[$wi] ?? 0) | (1 << ($bit % $wb)); + } + return $seed; + } + private function objInitTemplate(?\Compile\Mir\ClassDef $cd): string { if ($cd === null || $cd->isStruct || \count($cd->propertyNames) < 2) { return ''; } @@ -200,6 +225,10 @@ private function objInitTemplate(?\Compile\Mir\ClassDef $cd): string $words[\intdiv($cd->propertyOffset($pname), 8)] = '-3659174697238528'; } } + foreach ($this->initMapSeed($cd) as $wi => $bits) { + $at = \intdiv($cd->initMapOffset, 8) + $wi; + if ($at < \count($words)) { $words[$at] = (string)$bits; } + } $sym = '@.otmpl.' . $this->mangle($cd->name); $parts = []; foreach ($words as $w) { $parts[] = 'i64 ' . $w; } diff --git a/tests/aot/cases/lazy_build_untyped_prop_null.php b/tests/aot/cases/lazy_build_untyped_prop_null.php new file mode 100644 index 00000000..f3dc995f --- /dev/null +++ b/tests/aot/cases/lazy_build_untyped_prop_null.php @@ -0,0 +1,13 @@ +appPath ?: $this->base . '/app') . $p; } + public function use(string $p): static { $this->appPath = $p; return $this; } } +class Svc { public int $x = 0; public $y; public function __construct() { $this->x = 7; } } +$a = new App(); +echo $a->path('/x'), "\n"; +var_dump($a->n); +echo $a->use('/z')->path('/y'), "\n"; +$r = new ReflectionClass(Svc::class); +$g = $r->newLazyGhost(function (Svc $s) { $s->__construct(); $s->y = 'init'; }); +echo $g->x, ' ', $g->y, "\n"; diff --git a/tests/aot/expected/lazy_build_untyped_prop_null.out b/tests/aot/expected/lazy_build_untyped_prop_null.out new file mode 100644 index 00000000..74bb765e --- /dev/null +++ b/tests/aot/expected/lazy_build_untyped_prop_null.out @@ -0,0 +1,4 @@ +/b/app/x +NULL +/z/y +7 init From be8004532b8fbff1b2a69bd69a4d13f91687699b Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 21:53:45 +0200 Subject: [PATCH 04/10] A closure generator owns its env in the frame (env@56), so an invoke releases the +1 a conditional or literal callee holds: Laravel's call_user_func($cb ?? $default, ...) leaked the closure and all it captured per request --- src/Compile/Mir/Passes/EmitLlvm.php | 7 ++++--- src/Compile/Mir/Passes/EmitLlvmCalls.php | 17 +++++++++++------ src/Compile/Mir/Passes/EmitLlvmGenerator.php | 15 +++++++++++++++ src/Compile/Mir/Passes/EmitLlvmRuntime.php | 5 +++-- tests/aot/cases/generator_closure_owns_env.php | 8 ++++++++ .../aot/cases/leak_conditional_cell_callee.php | 11 +++++++++++ .../aot/expected/generator_closure_owns_env.out | 7 +++++++ .../expected/leak_conditional_cell_callee.out | 2 ++ 8 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 tests/aot/cases/generator_closure_owns_env.php create mode 100644 tests/aot/cases/leak_conditional_cell_callee.php create mode 100644 tests/aot/expected/generator_closure_owns_env.out create mode 100644 tests/aot/expected/leak_conditional_cell_callee.out diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 6bd77a4b..56de0a13 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -3032,12 +3032,13 @@ private function isOwnElemFlavor(string $flavor): bool // Generator frame layout: // resume_fn@0, state@8, current@16, key@24, nextkey@32, - // sent@40, retval@48, locals@56+ + // sent@40, retval@48, env@56, locals@64+ // state: 0 = not started, k = suspended at yield k, -1 = finished (or // running: the resume entry stores it, a yield overwrites it); // -2 - s = being destroyed from state s (`__mir_gen_destroy`). - // The frame owns current / key / sent / retval, each a tagged cell. - private const GEN_HEADER = 56; + // The frame owns current / key / sent / retval / env, each a tagged cell; + // env is a closure generator's own closure (its captures are read from it). + private const GEN_HEADER = 64; /** Count `yield` nodes in a generator body (state-machine arity). */ private function countYields(Node $n): int diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index 3de1aaab..51ee9e4b 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1869,9 +1869,10 @@ private function emitInvoke(Invoke_ $n): string // gives the count back. Hold one for the call, as php does. if (!$this->invokePinsCallee($iv, $iv->callee)) { // A closure LITERAL callee (`(function () use ($x) {…})()`) is a fresh +1 - // nobody else binds: give it back once the call returns. - // A generator closure is the exception: the Generator keeps borrowing the env. - if ($iv->callee->kind === Node::KIND_CLOSURE && !$n->type->isGenerator()) { + // nobody else binds, and so is a conditional's (`($cb ?? $default)()` retains + // the arm it picked): give it back once the call returns. A generator closure + // holds its own env in the frame ({@see EmitLlvmGenerator}), so it is no exception. + if ($iv->callee->kind === Node::KIND_CLOSURE || $this->condOwnsResult($iv->callee)) { $this->rt->needsClosureRc = true; $env = $this->lastValue; $out .= $this->emitClosureStructInvoke($n, $env); @@ -1970,9 +1971,13 @@ private function emitErasedInvoke(Invoke_ $n): string { $shared = $this->erasedInvokeShareable($n); $out = $this->emitNode($n->callee); - // The `[$c, $m]` of `$c::$m()` has no other owner. Only an array: a generator keeps borrowing - // the closure that made it, and an erased result does not say whether it is one. - $calleeFlavor = $n->callee->type->isArray() ? $this->freshRcArgFlavor($n->callee) : ''; + // The `[$c, $m]` of `$c::$m()` has no other owner, nor has a conditional's pick + // (`call_user_func($this->cb ?? $default, …)` retained `$default` and leaked it + // with everything it captured, per Laravel request). A generator closure holds + // its own env ({@see EmitLlvmGenerator}), so releasing after the call is safe. + $calleeFlavor = $n->callee->type->isArray() ? $this->freshRcArgFlavor($n->callee) + : ($this->condOwnsResult($n->callee) ? $this->condFlavor($n->callee->type) + : ($n->callee->kind === Node::KIND_CLOSURE ? 'closure' : '')); $out .= $this->coerceToI64(); $raw = $this->lastValue; /** @var array $owned */ diff --git a/src/Compile/Mir/Passes/EmitLlvmGenerator.php b/src/Compile/Mir/Passes/EmitLlvmGenerator.php index 8fab6ea1..b21315ce 100644 --- a/src/Compile/Mir/Passes/EmitLlvmGenerator.php +++ b/src/Compile/Mir/Passes/EmitLlvmGenerator.php @@ -219,6 +219,21 @@ private function emitGenerator(FunctionDef $fn): string $out .= ' ' . $bn . " = call i64 @__manticore_box_null()\n"; $out .= $this->genStoreAt($fr, 40, $bn); // sent@40 = null cell $out .= $this->genStoreAt($fr, 48, $bn); // retval@48 = null cell + // env@56: a closure generator holds its own closure for as long as the frame + // lives — the captures the frame reads are the env's. Borrowed, it had to be + // kept alive by whoever called it, so no call site could release a closure it + // owned (`(fn () => yield 1)()`, `($cb ?? $default)()` leaked every env). + if ($isClosure) { + $this->rt->needsClosureRc = true; + $ew = $this->ssa->allocReg(); + $ec = $this->ssa->allocReg(); + $out .= ' ' . $ew . " = ptrtoint ptr %env to i64\n"; + $out .= ' ' . $ec . ' = or i64 ' . $ew . ', ' . (string)\Compile\MemoryAbi::CELL_OBJ . "\n"; + $out .= ' call void @__mir_cell_retain(i64 ' . $ec . ")\n"; + $out .= $this->genStoreAt($fr, 56, $ec); + } else { + $out .= $this->genStoreAt($fr, 56, '0'); + } $paramNames = []; $paramTypeByName = []; $paramByRef = []; diff --git a/src/Compile/Mir/Passes/EmitLlvmRuntime.php b/src/Compile/Mir/Passes/EmitLlvmRuntime.php index 01355a29..5aeab12e 100644 --- a/src/Compile/Mir/Passes/EmitLlvmRuntime.php +++ b/src/Compile/Mir/Passes/EmitLlvmRuntime.php @@ -771,7 +771,8 @@ private function allocRuntime(): string // re-entered at `-2 - state`, where the resume function carries the exit // of a generator abandoned there ({@see EmitLlvmGenerator}) — the // `finally` blocks around the yield and the drops of its owned locals. - // Then the header's own cells go: current@16, key@24, sent@40, retval@48. + // Then the header's own cells go: current@16, key@24, sent@40, retval@48, + // and a closure generator's env@56 (0 for a named one: a no-op drop). $this->rt->needsTagged = true; $out .= "define void @__mir_gen_destroy(ptr %g) {\n"; $out .= "entry:\n"; @@ -787,7 +788,7 @@ private function allocRuntime(): string $out .= " call i64 %fn(ptr %g)\n"; $out .= " br label %drop\n"; $out .= "drop:\n"; - foreach ([16, 24, 40, 48] as $goff) { + foreach ([16, 24, 40, 48, 56] as $goff) { $gs = (string)$goff; $out .= " %hp" . $gs . " = getelementptr inbounds i8, ptr %g, i64 " . $gs . "\n"; $out .= " %hv" . $gs . " = load i64, ptr %hp" . $gs . "\n"; diff --git a/tests/aot/cases/generator_closure_owns_env.php b/tests/aot/cases/generator_closure_owns_env.php new file mode 100644 index 00000000..0a01864d --- /dev/null +++ b/tests/aot/cases/generator_closure_owns_env.php @@ -0,0 +1,8 @@ +n}\n"; } } +function viaLiteral(): Generator { $h = new H('lit'); return (function () use ($h) { yield $h->n . '1'; yield $h->n . '2'; })(); } +function viaCond(?Closure $cb): Generator { $h = new H('cond'); $d = function () use ($h) { yield $h->n . 'A'; yield $h->n . 'B'; }; return ($cb ?? $d)(); } +$g = viaLiteral(); foreach ($g as $v) { echo $v, "\n"; } unset($g); +$g = viaCond(null); foreach ($g as $v) { echo $v, "\n"; } unset($g); +echo "end\n"; diff --git a/tests/aot/cases/leak_conditional_cell_callee.php b/tests/aot/cases/leak_conditional_cell_callee.php new file mode 100644 index 00000000..e1b4a40d --- /dev/null +++ b/tests/aot/cases/leak_conditional_cell_callee.php @@ -0,0 +1,11 @@ +pad = str_repeat('r', 2000); } } +class Router { public $cb; + public function viaCuf($route) { $default = fn () => strlen($route->pad); return call_user_func($this->cb ?? $default, $route); } + public function viaInvoke($route) { $default = fn () => strlen($route->pad); return ($this->cb ?? $default)($route); } } +$r = new Router(); +echo $r->viaCuf(new Route()), ' ', $r->viaInvoke(new Route()), "\n"; +$m0 = memory_get_peak_usage(); +for ($i = 0; $i < 20000; $i++) { $r->viaCuf(new Route()); $r->viaInvoke(new Route()); } +echo ((memory_get_peak_usage() - $m0) >> 20) < 8 ? "flat\n" : "LEAK\n"; diff --git a/tests/aot/expected/generator_closure_owns_env.out b/tests/aot/expected/generator_closure_owns_env.out new file mode 100644 index 00000000..5f26f9b6 --- /dev/null +++ b/tests/aot/expected/generator_closure_owns_env.out @@ -0,0 +1,7 @@ +lit1 +lit2 +drop lit +condA +condB +drop cond +end diff --git a/tests/aot/expected/leak_conditional_cell_callee.out b/tests/aot/expected/leak_conditional_cell_callee.out new file mode 100644 index 00000000..b898ed5d --- /dev/null +++ b/tests/aot/expected/leak_conditional_cell_callee.out @@ -0,0 +1,2 @@ +2000 2000 +flat From e85ca2b06aba65c079f21c5d5a4bb1cb45c1bf25 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 22:26:47 +0200 Subject: [PATCH 05/10] Walk an object in an untyped (cell) property as a cycle-collector child and release a collected node's other cell slots: no cycle through an untyped property was ever collected, which kept Laravel's per-request Request/Route/closure graph and every string it held --- src/Compile/Mir/Passes/EmitLlvmRuntime.php | 20 +++++++++++++++++-- .../cases/gc_cycle_through_untyped_props.php | 15 ++++++++++++++ .../gc_cycle_through_untyped_props.out | 3 +++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/aot/cases/gc_cycle_through_untyped_props.php create mode 100644 tests/aot/expected/gc_cycle_through_untyped_props.out diff --git a/src/Compile/Mir/Passes/EmitLlvmRuntime.php b/src/Compile/Mir/Passes/EmitLlvmRuntime.php index 5aeab12e..ad2f17fe 100644 --- a/src/Compile/Mir/Passes/EmitLlvmRuntime.php +++ b/src/Compile/Mir/Passes/EmitLlvmRuntime.php @@ -3432,6 +3432,13 @@ private function ccClosureIr(): string // object has an allocator magic at -8 and nothing owned below it, so -32 // is read only when -8 carries none (#413: an object first in its malloc // region put -32 on an unmapped page). + // A cell slot is an ordinary edge too: an OBJECT in it (allocator magic at + // -8) is the holder's child exactly as an obj-typed slot's is. Walking only + // a closure there left every cycle through an untyped property + // (`$route->req = $req; $req->route = $route`) uncollected — all of + // Laravel's per-request object graph. The release half drops what is + // neither an object (the walk accounted for it) nor a closure: a string or + // array a collected node held. $cellEnv = " %tg = icmp ugt i64 %v, " . (string)\Compile\MemoryAbi::CELL_TAGGED_MIN . "\n" . ' %sh = lshr i64 %v, ' . (string)\Compile\MemoryAbi::CELL_TAG_SHIFT . "\n" . ' %nib = and i64 %sh, ' . (string)\Compile\MemoryAbi::CELL_TAG_MASK . "\n" @@ -3440,14 +3447,14 @@ private function ccClosureIr(): string . ' %pl = and i64 %v, ' . (string)\Compile\MemoryAbi::CELL_PAYLOAD_MASK . "\n" . ' %big = icmp ugt i64 %pl, ' . (string)\Compile\MemoryAbi::CELL_OBJ_MIN_PAYLOAD . "\n" . " %okp = and i1 %okt, %big\n" - . " br i1 %okp, label %hdr, label %done\n" + . " br i1 %okp, label %hdr, label %other\n" . "hdr:\n" . " %p = inttoptr i64 %pl to ptr\n" . " %t8p = getelementptr inbounds i8, ptr %p, i64 -8\n" . " %t8 = load i64, ptr %t8p\n" . " %t8h = lshr i64 %t8, 48\n" . ' %t8m = icmp eq i64 %t8h, ' . (string)(\Compile\MemoryAbi::CLOSURE_TAG_MAGIC >> 48) . "\n" - . " br i1 %t8m, label %done, label %env\n" + . " br i1 %t8m, label %objc, label %env\n" . "env:\n" . ' %mp = getelementptr inbounds i8, ptr %p, i64 ' . $mOff . "\n" . " %m = load i64, ptr %mp\n" @@ -3455,11 +3462,20 @@ private function ccClosureIr(): string . ' %ism = icmp eq i64 %mm, ' . $magic . "\n" . " br i1 %ism, label %clo, label %done\n" . "clo:\n"; + $rcMagic = (string)\Compile\MemoryAbi::RC_TAG_MAGIC; $out .= "define void @__cc_cellclo_walk(i64 %v, i64 %a) {\nentry:\n" . $cellEnv . " call void @__cc_closure_walk(i64 %pl, i64 %a)\n br label %done\n" + . "objc:\n" + . ' %isrc = icmp eq i64 %t8, ' . $rcMagic . "\n" + . " br i1 %isrc, label %child, label %done\n" + . "child:\n" + . " call void @__manticore_cc_child_apply(ptr %p, i64 %a)\n br label %done\n" + . "other:\n br label %done\n" . "done:\n ret void\n}\n"; $out .= "define void @__cc_cellclo_free(i64 %v) {\nentry:\n" . $cellEnv . " call void @__cc_closure_free(i64 %pl)\n br label %done\n" + . "objc:\n br label %done\n" + . "other:\n call void @__mir_cell_drop(i64 %v)\n br label %done\n" . "done:\n ret void\n}\n"; return $out; } diff --git a/tests/aot/cases/gc_cycle_through_untyped_props.php b/tests/aot/cases/gc_cycle_through_untyped_props.php new file mode 100644 index 00000000..0654a18e --- /dev/null +++ b/tests/aot/cases/gc_cycle_through_untyped_props.php @@ -0,0 +1,15 @@ +name = str_repeat('n', 100) . $id; } public function __destruct() { global $d; $d++; } } +class Route { public $req; public $tags = []; } +$d = 0; +function direct(int $i) { $req = new Req($i); $route = new Route(); $route->req = $req; $req->route = $route; $route->tags[] = 'tag' . $i; } +function viaClosure(int $i) { $req = new Req($i); $route = new Route(); $route->req = $req; $req->resolver = fn () => $route; } +for ($i = 0; $i < 300; $i++) { direct($i); viaClosure($i); } +gc_collect_cycles(); +echo "destructed $d\n"; +$m0 = memory_get_peak_usage(); +for ($i = 0; $i < 20000; $i++) { direct($i); viaClosure($i); if ($i % 500 === 0) { gc_collect_cycles(); } } +gc_collect_cycles(); +echo ((memory_get_peak_usage() - $m0) >> 20) < 8 ? "flat\n" : "LEAK\n"; +echo "end\n"; diff --git a/tests/aot/expected/gc_cycle_through_untyped_props.out b/tests/aot/expected/gc_cycle_through_untyped_props.out new file mode 100644 index 00000000..3c8f8ffa --- /dev/null +++ b/tests/aot/expected/gc_cycle_through_untyped_props.out @@ -0,0 +1,3 @@ +destructed 600 +flat +end From 37cb125a8b858f93b8bb32cc8d76c1f9adb787eb Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 22:30:42 +0200 Subject: [PATCH 06/10] Promote the 8 known-bug repros that pass now (4 already had identical cases; spread_into_variadic_closure lands as _repro beside a different case of that name) Fixes #541 Fixes #375 Fixes #517 Fixes #463 Fixes #476 Fixes #477 Fixes #565 Fixes #464 --- .../array_union_bare_array_params_count.php | 1 - .../crash => cases}/interp_dollar_brace_varvar.php | 1 - .../new_dyn_unknown_class_returns_null.php | 1 - .../spread_into_variadic_closure_repro.php} | 1 - .../array_union_bare_array_params_count.out} | 0 .../interp_dollar_brace_varvar.out} | 0 .../new_dyn_unknown_class_returns_null.out} | 0 .../spread_into_variadic_closure_repro.out} | 0 .../repro/wrong/closure_named_args_after_spread.expected | 1 - .../aot/repro/wrong/closure_named_args_after_spread.php | 5 ----- .../wrong/dyn_method_spread_variadic_via_call.expected | 1 - .../repro/wrong/dyn_method_spread_variadic_via_call.php | 9 --------- tests/aot/repro/wrong/magic_call_named_args.expected | 1 - tests/aot/repro/wrong/magic_call_named_args.php | 5 ----- .../wrong/variadic_named_args_after_spread.expected | 1 - .../aot/repro/wrong/variadic_named_args_after_spread.php | 6 ------ 16 files changed, 33 deletions(-) rename tests/aot/{repro/wrong => cases}/array_union_bare_array_params_count.php (92%) rename tests/aot/{repro/crash => cases}/interp_dollar_brace_varvar.php (90%) rename tests/aot/{repro/wrong => cases}/new_dyn_unknown_class_returns_null.php (96%) rename tests/aot/{repro/crash/spread_into_variadic_closure.php => cases/spread_into_variadic_closure_repro.php} (93%) rename tests/aot/{repro/wrong/array_union_bare_array_params_count.expected => expected/array_union_bare_array_params_count.out} (100%) rename tests/aot/{repro/crash/interp_dollar_brace_varvar.expected => expected/interp_dollar_brace_varvar.out} (100%) rename tests/aot/{repro/wrong/new_dyn_unknown_class_returns_null.expected => expected/new_dyn_unknown_class_returns_null.out} (100%) rename tests/aot/{repro/crash/spread_into_variadic_closure.expected => expected/spread_into_variadic_closure_repro.out} (100%) delete mode 100644 tests/aot/repro/wrong/closure_named_args_after_spread.expected delete mode 100644 tests/aot/repro/wrong/closure_named_args_after_spread.php delete mode 100644 tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.expected delete mode 100644 tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.php delete mode 100644 tests/aot/repro/wrong/magic_call_named_args.expected delete mode 100644 tests/aot/repro/wrong/magic_call_named_args.php delete mode 100644 tests/aot/repro/wrong/variadic_named_args_after_spread.expected delete mode 100644 tests/aot/repro/wrong/variadic_named_args_after_spread.php diff --git a/tests/aot/repro/wrong/array_union_bare_array_params_count.php b/tests/aot/cases/array_union_bare_array_params_count.php similarity index 92% rename from tests/aot/repro/wrong/array_union_bare_array_params_count.php rename to tests/aot/cases/array_union_bare_array_params_count.php index b0061717..8253851b 100644 --- a/tests/aot/repro/wrong/array_union_bare_array_params_count.php +++ b/tests/aot/cases/array_union_bare_array_params_count.php @@ -1,5 +1,4 @@ 'v', 'j' => 'w']), "\n"; diff --git a/tests/aot/repro/crash/interp_dollar_brace_varvar.php b/tests/aot/cases/interp_dollar_brace_varvar.php similarity index 90% rename from tests/aot/repro/crash/interp_dollar_brace_varvar.php rename to tests/aot/cases/interp_dollar_brace_varvar.php index ac5a9448..03401023 100644 --- a/tests/aot/repro/crash/interp_dollar_brace_varvar.php +++ b/tests/aot/cases/interp_dollar_brace_varvar.php @@ -1,6 +1,5 @@ a, "\n"; diff --git a/tests/aot/repro/crash/spread_into_variadic_closure.php b/tests/aot/cases/spread_into_variadic_closure_repro.php similarity index 93% rename from tests/aot/repro/crash/spread_into_variadic_closure.php rename to tests/aot/cases/spread_into_variadic_closure_repro.php index bbc0f744..e7ee091e 100644 --- a/tests/aot/repro/crash/spread_into_variadic_closure.php +++ b/tests/aot/cases/spread_into_variadic_closure_repro.php @@ -1,5 +1,4 @@ implode(",", $p), [2, 3]), "\n"; diff --git a/tests/aot/repro/wrong/array_union_bare_array_params_count.expected b/tests/aot/expected/array_union_bare_array_params_count.out similarity index 100% rename from tests/aot/repro/wrong/array_union_bare_array_params_count.expected rename to tests/aot/expected/array_union_bare_array_params_count.out diff --git a/tests/aot/repro/crash/interp_dollar_brace_varvar.expected b/tests/aot/expected/interp_dollar_brace_varvar.out similarity index 100% rename from tests/aot/repro/crash/interp_dollar_brace_varvar.expected rename to tests/aot/expected/interp_dollar_brace_varvar.out diff --git a/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected b/tests/aot/expected/new_dyn_unknown_class_returns_null.out similarity index 100% rename from tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected rename to tests/aot/expected/new_dyn_unknown_class_returns_null.out diff --git a/tests/aot/repro/crash/spread_into_variadic_closure.expected b/tests/aot/expected/spread_into_variadic_closure_repro.out similarity index 100% rename from tests/aot/repro/crash/spread_into_variadic_closure.expected rename to tests/aot/expected/spread_into_variadic_closure_repro.out diff --git a/tests/aot/repro/wrong/closure_named_args_after_spread.expected b/tests/aot/repro/wrong/closure_named_args_after_spread.expected deleted file mode 100644 index 2fd5ef67..00000000 --- a/tests/aot/repro/wrong/closure_named_args_after_spread.expected +++ /dev/null @@ -1 +0,0 @@ -1 2 Q diff --git a/tests/aot/repro/wrong/closure_named_args_after_spread.php b/tests/aot/repro/wrong/closure_named_args_after_spread.php deleted file mode 100644 index 040970ee..00000000 --- a/tests/aot/repro/wrong/closure_named_args_after_spread.php +++ /dev/null @@ -1,5 +0,0 @@ - "$a $b $z"; -echo $c(...[1], z: 'Q'), "\n"; diff --git a/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.expected b/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.expected deleted file mode 100644 index ef18fe04..00000000 --- a/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.expected +++ /dev/null @@ -1 +0,0 @@ -many 3 diff --git a/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.php b/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.php deleted file mode 100644 index 17a713a7..00000000 --- a/tests/aot/repro/wrong/dyn_method_spread_variadic_via_call.php +++ /dev/null @@ -1,9 +0,0 @@ -forward(new Builder, $method, $parameters); } - protected function forward($object, $method, array $parameters) { return $object->{$method}(...$parameters); } -} -echo (new Model)->many(1, 2, 3), "\n"; diff --git a/tests/aot/repro/wrong/magic_call_named_args.expected b/tests/aot/repro/wrong/magic_call_named_args.expected deleted file mode 100644 index 4c4ddd75..00000000 --- a/tests/aot/repro/wrong/magic_call_named_args.expected +++ /dev/null @@ -1 +0,0 @@ -go:{"0":"x","b":"or","c":3} diff --git a/tests/aot/repro/wrong/magic_call_named_args.php b/tests/aot/repro/wrong/magic_call_named_args.php deleted file mode 100644 index 8192ae86..00000000 --- a/tests/aot/repro/wrong/magic_call_named_args.php +++ /dev/null @@ -1,5 +0,0 @@ -go('x', b: 'or', c: 3), "\n"; diff --git a/tests/aot/repro/wrong/variadic_named_args_after_spread.expected b/tests/aot/repro/wrong/variadic_named_args_after_spread.expected deleted file mode 100644 index e1095b2a..00000000 --- a/tests/aot/repro/wrong/variadic_named_args_after_spread.expected +++ /dev/null @@ -1 +0,0 @@ -3-5-T-0 diff --git a/tests/aot/repro/wrong/variadic_named_args_after_spread.php b/tests/aot/repro/wrong/variadic_named_args_after_spread.php deleted file mode 100644 index a0c90410..00000000 --- a/tests/aot/repro/wrong/variadic_named_args_after_spread.php +++ /dev/null @@ -1,6 +0,0 @@ - Date: Mon, 9 Nov 2026 08:36:49 +0200 Subject: [PATCH 07/10] is_a() with a runtime string subject releases a minted subject after the compares: the strval() an erased subject is unboxed by leaked one class name per Laravel container make() --- src/Compile/Mir/Passes/EmitLlvmBuiltins.php | 5 +++++ tests/aot/cases/leak_is_a_erased_string_subject.php | 13 +++++++++++++ .../expected/leak_is_a_erased_string_subject.out | 2 ++ 3 files changed, 20 insertions(+) create mode 100644 tests/aot/cases/leak_is_a_erased_string_subject.php create mode 100644 tests/aot/expected/leak_is_a_erased_string_subject.out diff --git a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index 9b9485a2..e79fd794 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -9552,6 +9552,10 @@ private function biIsAStringSubject(array $args, bool $strict, string $target): $this->rt->needsStrcmp = true; $out = $this->emitNode($args[0]); $out .= $this->coerceToI64(); + // A minted subject (`strval($x)` — what an erased subject is unboxed by, + // {@see biIsA} — or `strtolower(...)`) is consumed by the compares and + // dropped here: Laravel's Container::build leaked one class name per make(). + $fresh = $this->isFreshStringTemp($args[0]) ? $this->lastValue : ''; $strP = $this->ssa->allocReg(); $out .= ' ' . $strP . ' = inttoptr i64 ' . $this->lastValue . " to ptr\n"; $out .= $this->emitNode($args[1]); @@ -9569,6 +9573,7 @@ private function biIsAStringSubject(array $args, bool $strict, string $target): $out .= ' ' . $or . ' = or i1 ' . $acc . ', ' . $eq . "\n"; $acc = $or; } + if ($fresh !== '') { $out .= $this->rcReleaseReg($fresh, 'str'); } if ($acc === '') { return $this->biConstBool($out, false); } $r = $this->ssa->allocReg(); $out .= ' ' . $r . ' = zext i1 ' . $acc . " to i64\n"; diff --git a/tests/aot/cases/leak_is_a_erased_string_subject.php b/tests/aot/cases/leak_is_a_erased_string_subject.php new file mode 100644 index 00000000..450f2368 --- /dev/null +++ b/tests/aot/cases/leak_is_a_erased_string_subject.php @@ -0,0 +1,13 @@ +> 20) < 8 ? "flat\n" : "LEAK\n"; diff --git a/tests/aot/expected/leak_is_a_erased_string_subject.out b/tests/aot/expected/leak_is_a_erased_string_subject.out new file mode 100644 index 00000000..8e598786 --- /dev/null +++ b/tests/aot/expected/leak_is_a_erased_string_subject.out @@ -0,0 +1,2 @@ +4 1 +flat From 02a0d9e6406f5836ab9167f62c1d522ee73b189c Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 09:58:40 +0200 Subject: [PATCH 08/10] Release three more per-request Laravel leaks: an owned call result used as a callee (call_user_func($this->getRouteResolver())), a closure stored raw in a reference box whose cell drop skipped it ($default in Router::substituteImplicitBindings), and (array) over a minting operand retained again (Router::resolveMiddleware) --- src/Compile/Mir/Ownership.php | 9 +++++++ src/Compile/Mir/Passes/EmitLlvm.php | 5 ++++ src/Compile/Mir/Passes/EmitLlvmCalls.php | 22 +++++++++++++++-- src/Compile/Mir/Passes/EmitLlvmExpr.php | 6 +++++ src/Compile/Mir/Passes/EmitLlvmLocals.php | 24 +++++++++++++++++-- src/Compile/Mir/Passes/EmitLlvmMemory.php | 4 ++++ .../closure_in_referenced_local_released.php | 8 +++++++ .../cases/leak_array_cast_of_owned_call.php | 8 +++++++ .../closure_in_referenced_local_released.out | 5 ++++ .../leak_array_cast_of_owned_call.out | 2 ++ 10 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 tests/aot/cases/closure_in_referenced_local_released.php create mode 100644 tests/aot/cases/leak_array_cast_of_owned_call.php create mode 100644 tests/aot/expected/closure_in_referenced_local_released.out create mode 100644 tests/aot/expected/leak_array_cast_of_owned_call.out diff --git a/src/Compile/Mir/Ownership.php b/src/Compile/Mir/Ownership.php index ca891317..857eea83 100644 --- a/src/Compile/Mir/Ownership.php +++ b/src/Compile/Mir/Ownership.php @@ -1933,6 +1933,15 @@ public static function castArrayMints(Cast $c): bool { if ($c->target !== 'array') { return false; } $ok = $c->operand->type->kind; + // An ARRAY operand passes through, and with it its count: a fresh +1 producer's + // result stays owned. Read as a borrow, `fn ($n) => (array) R::resolve($n)` was + // retained again on return and the resolved array leaked (Laravel's + // Router::resolveMiddleware, once per request). + if ($ok === Type::KIND_ARRAY) { + $k = $c->operand->kind; + return $k === Node::KIND_METHOD_CALL || $k === Node::KIND_STATIC_CALL + || $k === Node::KIND_INVOKE || $k === Node::KIND_ARRAY_LIT; + } // An OBJECT mints too: its declared slots are built into a fresh assoc // (and a bag-only class's bag is retained). Read as a borrow, the // result was retained again on return and never released in a local — diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 56de0a13..79c6d052 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -4379,6 +4379,11 @@ private function retainCellPayload(Node $value): string && $k !== Type::KIND_CLOSURE && !$borrowedCellArray) { return ''; } + // `(array)` over a minting operand ({@see \Compile\Mir\Ownership::castArrayMints}) + // hands its +1 on: retained again, `$out[$k] = (array) resolve($n)` leaked it. + if ($value instanceof \Compile\Mir\Cast && \Compile\Mir\Ownership::castArrayMints($value)) { + return ''; + } $saveV = $this->lastValue; $saveT = $this->lastValueType; $out = $this->coerceToI64(); diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index 51ee9e4b..be8d0cf5 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1867,12 +1867,13 @@ private function emitInvoke(Invoke_ $n): string // `($this->hooks[$k])()` calls an env the ELEMENT owns, and the body // may take it off its slot (a hook that unregisters itself), which now // gives the count back. Hold one for the call, as php does. + $calleeCallOwned = $this->calleeIsOwnedCall($iv->callee); if (!$this->invokePinsCallee($iv, $iv->callee)) { // A closure LITERAL callee (`(function () use ($x) {…})()`) is a fresh +1 // nobody else binds, and so is a conditional's (`($cb ?? $default)()` retains // the arm it picked): give it back once the call returns. A generator closure // holds its own env in the frame ({@see EmitLlvmGenerator}), so it is no exception. - if ($iv->callee->kind === Node::KIND_CLOSURE || $this->condOwnsResult($iv->callee)) { + if ($iv->callee->kind === Node::KIND_CLOSURE || $this->condOwnsResult($iv->callee) || $calleeCallOwned) { $this->rt->needsClosureRc = true; $env = $this->lastValue; $out .= $this->emitClosureStructInvoke($n, $env); @@ -1967,6 +1968,22 @@ private function emitErasedExplicitInvoke(Node $n, \Compile\Mir\MethodCall_ $mc) } + /** + * A callee that is a call's result is +1 under the return convention — a method, a + * static method, an invoke, or a body this module defines (not a builtin, not by + * reference) — and nothing else binds it: `call_user_func($this->getRouteResolver())` + * leaked Laravel's route resolver closure, with the Route it captured, per request. + * Read right after the callee was emitted ({@see $lastCallWasBuiltin}). + */ + private function calleeIsOwnedCall(Node $c): bool + { + $k = $c->kind; + if ($k === Node::KIND_METHOD_CALL || $k === Node::KIND_STATIC_CALL || $k === Node::KIND_INVOKE) { return true; } + if ($k !== Node::KIND_CALL || $this->lastCallWasBuiltin) { return false; } + $fn = $c->function; + return isset($this->sigs->paramTypes[$fn]) && !($this->sigs->returnsByRef[$fn] ?? false); + } + private function emitErasedInvoke(Invoke_ $n): string { $shared = $this->erasedInvokeShareable($n); @@ -1977,7 +1994,8 @@ private function emitErasedInvoke(Invoke_ $n): string // its own env ({@see EmitLlvmGenerator}), so releasing after the call is safe. $calleeFlavor = $n->callee->type->isArray() ? $this->freshRcArgFlavor($n->callee) : ($this->condOwnsResult($n->callee) ? $this->condFlavor($n->callee->type) - : ($n->callee->kind === Node::KIND_CLOSURE ? 'closure' : '')); + : ($n->callee->kind === Node::KIND_CLOSURE ? 'closure' + : ($n->callee->type->kind === Type::KIND_CELL && $this->calleeIsOwnedCall($n->callee) ? 'cell' : ''))); $out .= $this->coerceToI64(); $raw = $this->lastValue; /** @var array $owned */ diff --git a/src/Compile/Mir/Passes/EmitLlvmExpr.php b/src/Compile/Mir/Passes/EmitLlvmExpr.php index 49c868bb..58bcca96 100644 --- a/src/Compile/Mir/Passes/EmitLlvmExpr.php +++ b/src/Compile/Mir/Passes/EmitLlvmExpr.php @@ -4049,6 +4049,11 @@ private function emitCast(Cast $n): string $c = $n; $ok = $c->operand->type->kind; $out = $c->target === 'float' ? $this->emitNodeFloatSink($c->operand) : $this->emitNode($c->operand); + // Every arm of a runtime-dispatched `(array)` takes its own count (the array arm + // retains, `[$v]` co-owns $v), so an OWNED temp operand is dead after it: + // `(array) Resolver::resolve($name)` leaked the resolved array per Laravel request. + $castOperandOwned = $c->target === 'array' + && $this->own->classifyTemp($c->operand, $this->lastCallWasBuiltin) > 0; // `(array) new Bag()` / `(array) make()`: the object is a temp with no // other owner; the array result co-owns everything it copied out. $castObjFlavor = $c->target === 'array' && $ok === Type::KIND_OBJ @@ -4401,6 +4406,7 @@ private function emitCast(Cast $n): string $out .= ' store ptr ' . $one . ', ptr ' . $slot . "\n"; $out .= ' br label %' . $endL . "\n"; $out .= $endL . ":\n"; + if ($castOperandOwned) { $out .= ' call void @__mir_cell_drop(i64 ' . $v . ")\n"; } $res = $this->ssa->allocReg(); $out .= ' ' . $res . ' = load ptr, ptr ' . $slot . "\n"; $this->lastValue = $res; diff --git a/src/Compile/Mir/Passes/EmitLlvmLocals.php b/src/Compile/Mir/Passes/EmitLlvmLocals.php index 89392c74..447eb66c 100644 --- a/src/Compile/Mir/Passes/EmitLlvmLocals.php +++ b/src/Compile/Mir/Passes/EmitLlvmLocals.php @@ -517,7 +517,14 @@ private function emitLoadLocal(LoadLocal $n): string $out = ' ' . $addr . ' = load i64, ptr ' . $this->locals->slots[$ll->name] . "\n"; $p = $this->ssa->allocReg(); $out .= ' ' . $p . ' = inttoptr i64 ' . $addr . " to ptr\n"; - $out .= ' ' . $reg . ' = load i64, ptr ' . $p . "\n"; + if (isset($this->locals->refCellTargets[$ll->name]) && $this->isClosureValueType($ll->type)) { + // The box holds the env as an object cell (see the store): the closure ABI wants it raw. + $cw = $this->ssa->allocReg(); + $out .= ' ' . $cw . ' = load i64, ptr ' . $p . "\n"; + $out .= ' ' . $reg . ' = and i64 ' . $cw . ', ' . (string)\Compile\MemoryAbi::CELL_PAYLOAD_MASK . "\n"; + } else { + $out .= ' ' . $reg . ' = load i64, ptr ' . $p . "\n"; + } } else { $out = ' ' . $reg . ' = load i64, ptr ' . $this->locals->slots[$ll->name] . "\n"; } @@ -1333,7 +1340,20 @@ private function emitStoreLocal(StoreLocal $n): string $sv = $sl->value; $out .= $this->refParamOverwriteIr($sl->name, $p, $val, $sv instanceof \Compile\Mir\LoadLocal && $sv->name === $sl->name, $sl->type); - $out .= ' store i64 ' . $val . ', ptr ' . $p . "\n"; + // A reference box holds a CELL ({@see EmitLlvmModule::ownedBoxFlavor}), and a + // closure env rides raw: stored untagged, the box's cell drop and every cell + // retain read it as a number and skipped it, so the closure — and all it + // captured — leaked (Laravel's `$default` in Router::substituteImplicitBindings, + // a by-ref-able local). Tag it as the object cell it is; a read unboxes it. + $boxed = $val; + if (isset($this->locals->refCellTargets[$sl->name]) + && ($this->isClosureValueType($sl->type) || $this->isClosureValueType($sv->type))) { + $pl = $this->ssa->allocReg(); + $boxed = $this->ssa->allocReg(); + $out .= ' ' . $pl . ' = and i64 ' . $val . ', ' . (string)\Compile\MemoryAbi::CELL_PAYLOAD_MASK . "\n"; + $out .= ' ' . $boxed . ' = or i64 ' . $pl . ', ' . (string)\Compile\MemoryAbi::CELL_OBJ . "\n"; + } + $out .= ' store i64 ' . $boxed . ', ptr ' . $p . "\n"; } else { // Release-before-overwrite, where OwnershipFlow owns the old value. $out .= $this->ownOldIr($sl); diff --git a/src/Compile/Mir/Passes/EmitLlvmMemory.php b/src/Compile/Mir/Passes/EmitLlvmMemory.php index b45b41e1..43f6e543 100644 --- a/src/Compile/Mir/Passes/EmitLlvmMemory.php +++ b/src/Compile/Mir/Passes/EmitLlvmMemory.php @@ -1092,6 +1092,10 @@ private function rcRetainByType(Node $valueNode, string $i64reg, ?Type $fallback // borrowed arrays (alias / read) need a co-owner retain. // An array `+` is `__mir_array_union`'s fresh +1, a literal's twin. if ($tk === Type::KIND_ARRAY && ($k === Node::KIND_ARRAY_LIT || $k === Node::KIND_SPREAD || $k === Node::KIND_ADD)) { return ''; } + // `(array)` over a minting operand hands its +1 on ({@see \Compile\Mir\Ownership::castArrayMints}): + // `$out[$k] = (array) resolve($n)` retained it again and leaked the array. + if ($tk === Type::KIND_ARRAY && $valueNode instanceof \Compile\Mir\Cast + && \Compile\Mir\Ownership::castArrayMints($valueNode)) { return ''; } // String owned producer: a concat is a fresh +1; a literal is // immortal (retain is a sentinel no-op — skip it). if ($tk === Type::KIND_STRING diff --git a/tests/aot/cases/closure_in_referenced_local_released.php b/tests/aot/cases/closure_in_referenced_local_released.php new file mode 100644 index 00000000..59699dc0 --- /dev/null +++ b/tests/aot/cases/closure_in_referenced_local_released.php @@ -0,0 +1,8 @@ +n}\n"; } } +function viaRef(): int { $h = new H('ref'); $d = function () use ($h) { return strlen($h->n); }; $r = &$d; return $r(); } +function viaRefCall(): int { $h = new H('call'); $d = fn () => strlen($h->n); $r = &$d; return call_user_func($d); } +echo viaRef(), "\n"; +echo viaRefCall(), "\n"; +echo "end\n"; diff --git a/tests/aot/cases/leak_array_cast_of_owned_call.php b/tests/aot/cases/leak_array_cast_of_owned_call.php new file mode 100644 index 00000000..1711155f --- /dev/null +++ b/tests/aot/cases/leak_array_cast_of_owned_call.php @@ -0,0 +1,8 @@ + (array) R::resolve($n), ['a', 'b', 'c']); return count($c); } +echo run(), "\n"; +$m0 = memory_get_peak_usage(); +for ($i = 0; $i < 30000; $i++) { run(); } +echo ((memory_get_peak_usage() - $m0) >> 20) < 8 ? "flat\n" : "LEAK\n"; diff --git a/tests/aot/expected/closure_in_referenced_local_released.out b/tests/aot/expected/closure_in_referenced_local_released.out new file mode 100644 index 00000000..835a7df8 --- /dev/null +++ b/tests/aot/expected/closure_in_referenced_local_released.out @@ -0,0 +1,5 @@ +drop ref +3 +drop call +4 +end diff --git a/tests/aot/expected/leak_array_cast_of_owned_call.out b/tests/aot/expected/leak_array_cast_of_owned_call.out new file mode 100644 index 00000000..e1da921d --- /dev/null +++ b/tests/aot/expected/leak_array_cast_of_owned_call.out @@ -0,0 +1,2 @@ +3 +flat From 78b8ffe136bbd80e188c55606cf9a99589dad093 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 10:26:53 +0200 Subject: [PATCH 09/10] Release a callable-typed call result invoked as a callee by its runtime representation: call_user_func($this->getRouteResolver()) with @return \Closure leaked Laravel's route resolver and its Route per request --- src/Compile/Mir/Passes/EmitLlvmCalls.php | 48 ++++++++++++++++++- .../leak_callable_call_result_callee.php | 12 +++++ .../leak_callable_call_result_callee.out | 1 + 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/aot/cases/leak_callable_call_result_callee.php create mode 100644 tests/aot/expected/leak_callable_call_result_callee.out diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index be8d0cf5..e98a8131 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1995,7 +1995,9 @@ private function emitErasedInvoke(Invoke_ $n): string $calleeFlavor = $n->callee->type->isArray() ? $this->freshRcArgFlavor($n->callee) : ($this->condOwnsResult($n->callee) ? $this->condFlavor($n->callee->type) : ($n->callee->kind === Node::KIND_CLOSURE ? 'closure' - : ($n->callee->type->kind === Type::KIND_CELL && $this->calleeIsOwnedCall($n->callee) ? 'cell' : ''))); + : ($n->callee->type->kind === Type::KIND_CELL && $this->calleeIsOwnedCall($n->callee) ? 'cell' + : (($n->callee->type->kind === Type::KIND_CLOSURE || $n->callee->type->kind === Type::KIND_UNKNOWN) + && $this->calleeIsOwnedCall($n->callee) ? 'callable' : '')))); $out .= $this->coerceToI64(); $raw = $this->lastValue; /** @var array $owned */ @@ -2010,7 +2012,7 @@ private function emitErasedInvoke(Invoke_ $n): string if ($calleeFlavor !== '' && \str_starts_with($raw, '%')) { $resReg = $this->lastValue; $resTy = $this->lastValueType; - $out .= $this->rcReleaseReg($raw, $calleeFlavor); + $out .= $calleeFlavor === 'callable' ? $this->callableWordDropIr($raw) : $this->rcReleaseReg($raw, $calleeFlavor); $this->lastValue = $resReg; $this->lastValueType = $resTy; } @@ -2314,6 +2316,48 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string return $sym; } + /** + * Drop a `callable` word whose representation is only known at run time: a + * tagged cell, a raw closure env, a raw callable array or a raw object. A raw + * pointer is tagged by its allocator header (array magic at -8 → array cell, + * anything else → object cell, whose drop tells an object from a closure env) + * and dropped as that cell. `call_user_func($this->getRouteResolver())` with + * `@return \Closure` leaked Laravel's route resolver and its Route per request. + */ + private function callableWordDropIr(string $w): string + { + $this->rt->needsTagged = true; + $tagL = $this->ssa->allocLabel('cwd.tag'); + $rawL = $this->ssa->allocLabel('cwd.raw'); + $ptrL = $this->ssa->allocLabel('cwd.ptr'); + $endL = $this->ssa->allocLabel('cwd.end'); + $istag = $this->ssa->allocReg(); + $out = ' ' . $istag . ' = icmp ugt i64 ' . $w . ', ' . (string)\Compile\MemoryAbi::CELL_TAGGED_MIN . "\n"; + $out .= ' br i1 ' . $istag . ', label %' . $tagL . ', label %' . $rawL . "\n"; + $out .= $tagL . ":\n call void @__mir_cell_drop(i64 " . $w . ")\n br label %" . $endL . "\n"; + $big = $this->ssa->allocReg(); + $out .= $rawL . ":\n " . $big . ' = icmp ugt i64 ' . $w . ', ' . (string)\Compile\MemoryAbi::CELL_OBJ_MIN_PAYLOAD . "\n"; + $out .= ' br i1 ' . $big . ', label %' . $ptrL . ', label %' . $endL . "\n"; + $p = $this->ssa->allocReg(); + $hp = $this->ssa->allocReg(); + $h = $this->ssa->allocReg(); + $hm = $this->ssa->allocReg(); + $isArr = $this->ssa->allocReg(); + $tag = $this->ssa->allocReg(); + $cell = $this->ssa->allocReg(); + $out .= $ptrL . ":\n " . $p . ' = inttoptr i64 ' . $w . " to ptr\n"; + $out .= ' ' . $hp . ' = getelementptr inbounds i8, ptr ' . $p . ", i64 -8\n"; + $out .= ' ' . $h . ' = load i64, ptr ' . $hp . "\n"; + // Array buffers carry RC_TAG_MAGIC + 1..3 at -8 (vec / hashed / arena), an object + 0. + $out .= ' ' . $hm . ' = sub i64 ' . $h . ', ' . (string)(\Compile\MemoryAbi::RC_TAG_MAGIC + 1) . "\n"; + $out .= ' ' . $isArr . ' = icmp ult i64 ' . $hm . ", 3\n"; + $out .= ' ' . $tag . ' = select i1 ' . $isArr . ', i64 ' . (string)\Compile\MemoryAbi::CELL_ARRAY_TAG_BITS + . ', i64 ' . (string)\Compile\MemoryAbi::CELL_OBJ . "\n"; + $out .= ' ' . $cell . ' = or i64 ' . $w . ', ' . $tag . "\n"; + $out .= ' call void @__mir_cell_drop(i64 ' . $cell . ")\n br label %" . $endL . "\n"; + return $out . $endL . ":\n"; + } + /** * Evaluate every argument that RUNS CODE (a call, an invoke, a `new`, a * concat, an array literal) once, after the callee, into a slot, and answer diff --git a/tests/aot/cases/leak_callable_call_result_callee.php b/tests/aot/cases/leak_callable_call_result_callee.php new file mode 100644 index 00000000..92ce876e --- /dev/null +++ b/tests/aot/cases/leak_callable_call_result_callee.php @@ -0,0 +1,12 @@ +pad = str_repeat('r', 2000); } public function __destruct() { global $d; $d++; } } +class Req { protected $routeResolver; + public function setRouteResolver(Closure $callback) { $this->routeResolver = $callback; return $this; } + /** @return \Closure */ + public function getRouteResolver() { return $this->routeResolver ?: function () { }; } + public function route($param = null, $default = null) { $route = call_user_func($this->getRouteResolver()); if (is_null($route) || is_null($param)) { return $route; } return $route; } } +$d = 0; +function one() { $req = new Req(); $route = new Route(); $req->setRouteResolver(fn () => $route); $r = $req->route(); $r2 = $req->route(); return $r === $r2 ? 1 : 0; } +$k = 0; for ($i = 0; $i < 3000; $i++) { $k += one(); } +echo "k=$k d=$d\n"; diff --git a/tests/aot/expected/leak_callable_call_result_callee.out b/tests/aot/expected/leak_callable_call_result_callee.out new file mode 100644 index 00000000..aa665be2 --- /dev/null +++ b/tests/aot/expected/leak_callable_call_result_callee.out @@ -0,0 +1 @@ +k=3000 d=3000 From 4f021857615b54fa68580078d1cd22ab4e417019 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 13:11:38 +0200 Subject: [PATCH 10/10] Keep one representation for an inherited property: once any class of the slot holds a bare cell, every class of it does. Model::$attributes celled through the parent while Note kept vec[cell], so Note's drop released a tagged word as an array buffer and every Laravel /api/notes request crashed --- src/Compile/Mir/Passes/InferScans.php | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Compile/Mir/Passes/InferScans.php b/src/Compile/Mir/Passes/InferScans.php index ea178416..5e58073f 100644 --- a/src/Compile/Mir/Passes/InferScans.php +++ b/src/Compile/Mir/Passes/InferScans.php @@ -145,6 +145,10 @@ private function setInheritedPropType(\Compile\Mir\ClassDef $cd, string $prop, T if ($p === null || $p->propDisplayName($p->propertyNames[$idx] ?? '') !== $bare) { break; } $root = $p; } + /** @var list $sharing */ + $sharing = []; + // Some class of the slot already holds a BARE cell — a tagged word, not a buffer pointer. + $bareCell = $t->kind === Type::KIND_CELL; foreach ($this->classes as $other) { if ($other === $cd) { continue; } // A subclass that redeclared the private calls this slot by another name. @@ -160,9 +164,24 @@ private function setInheritedPropType(\Compile\Mir\ClassDef $cd, string $prop, T $c = $up; } if (!$shares) { continue; } + $sharing[] = [$other, $slot]; + $oc = $other->propertyTypes[$slot] ?? null; + if ($oc !== null && $oc->kind === Type::KIND_CELL) { $bareCell = true; } + } + // One slot, ONE representation. A bare cell (a tagged word) does not agree + // with a cell-element ARRAY (a raw buffer pointer): Model::$attributes celled + // through the parent's `setRawAttributes(array $attributes)` while Note kept + // `vec[cell]`, so Note's class drop released the tagged word as a buffer — + // every Laravel `/api/notes` request crashed. Once any class of the slot is a + // bare cell, every class of it is. + if ($bareCell) { + $cd->propertyTypes[$prop] = Type::cell(); + foreach ($sharing as [$other, $slot]) { $other->propertyTypes[$slot] = Type::cell(); } + return; + } + foreach ($sharing as [$other, $slot]) { $cur = $other->propertyTypes[$slot] ?? null; - if ($cur !== null && ($cur->kind === Type::KIND_CELL - || ($cur->isArray() && ($cur->element->kind ?? '') === Type::KIND_CELL))) { continue; } + if ($cur !== null && $cur->isArray() && ($cur->element->kind ?? '') === Type::KIND_CELL) { continue; } // Keep the sibling's own key shape; only its element joins to cell. $other->propertyTypes[$slot] = ($t->isArray() && $cur !== null && $cur->isArray() && $cur->key !== null) ? Type::assoc($cur->key, Type::cell())