diff --git a/README.md b/README.md
index 69ac98da761..6ea3d92c36f 100755
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-**Snapshot (2026-09-05, from [`docs/status-snapshot.json`](docs/status-snapshot.json)):** self-host spine **8217** / **8217** · capability matrix **4054** rows (VM yes **4021**, JIT **1883**, AOT **1737**) · differential cases **323** · apps corpus **1**/**12** ready · VM driver probe target ~**20**ms · local CI streak **2**d ([#36401](https://github.com/PurHur/php-compiler/issues/36401))
+**Snapshot (2026-09-05, from [`docs/status-snapshot.json`](docs/status-snapshot.json)):** self-host spine **8218** / **8218** · capability matrix **4054** rows (VM yes **4021**, JIT **1883**, AOT **1737**) · differential cases **324** · apps corpus **1**/**12** ready · VM driver probe target ~**20**ms · local CI streak **2**d ([#36401](https://github.com/PurHur/php-compiler/issues/36401))
## Current implementation status
@@ -22,7 +22,7 @@
| **VM (`phpc run`)** | ✅ Production-shaped for dev/CI | Broadest language coverage; reference executor and JIT/AOT fallback |
| **AOT (`phpc build`)** | ✅ For curated subset | Standalone binaries for examples **000–009** and small CGI apps; Composer stacks tracked in [#36382](https://github.com/PurHur/php-compiler/issues/36382) / [#36380](https://github.com/PurHur/php-compiler/issues/36380) |
| **JIT (`bin/jit.php`)** | 🚧 Partial | LLVM IR for many constructs; **MCJIT execute** still flaky ([#98](https://github.com/PurHur/php-compiler/issues/98)) |
-| **Self-host north star** | 🚧 | Spine **8217** / **8217** ✅ · M3 HelloWorld `emit_path=native` via gen-0 argv helper ([#22178](https://github.com/PurHur/php-compiler/issues/22178)) · `BOOTSTRAP_M4_REQUIRE_NATIVE_EMIT=1` refuses sidecar COPY ([#36146](https://github.com/PurHur/php-compiler/issues/36146)) · daily `make north-star5-verify-fast` · `--strict` only before bootstrap merges |
+| **Self-host north star** | 🚧 | Spine **8218** / **8218** ✅ · M3 HelloWorld `emit_path=native` via gen-0 argv helper ([#22178](https://github.com/PurHur/php-compiler/issues/22178)) · `BOOTSTRAP_M4_REQUIRE_NATIVE_EMIT=1` refuses sidecar COPY ([#36146](https://github.com/PurHur/php-compiler/issues/36146)) · daily `make north-star5-verify-fast` · `--strict` only before bootstrap merges |
### What you can rely on today
@@ -38,7 +38,7 @@ Counts from `php script/bootstrap-spine-count.php` / `docs/status-snapshot.json`
| Milestone | Status | What it means |
|-----------|--------|----------------|
| **M0–M1** | ✅ | `compiler_minimal` + compile-smoke bundles link and run natively |
-| **M2** | ✅ **8217** / **8217** | Full Phase A inventory in spine smoke |
+| **M2** | ✅ **8218** / **8218** | Full Phase A inventory in spine smoke |
| **M3** | ✅ / 🚧 | HelloWorld `emit_path=native` via gen-0 argv helper ([#22178](https://github.com/PurHur/php-compiler/issues/22178)); `BOOTSTRAP_M3_REQUIRE_NATIVE_EMIT=1` refuses sidecar COPY ([#21860](https://github.com/PurHur/php-compiler/issues/21860), [#36146](https://github.com/PurHur/php-compiler/issues/36146)) |
| **M4** | 🚧 | `BOOTSTRAP_M4_REQUIRE_NATIVE_EMIT=1` refuses sidecar COPY ([#36146](https://github.com/PurHur/php-compiler/issues/36146)); default `bootstrap-loop-probe` may exit **2** when the ladder is degraded — not a false OK |
| **M5** | 🚧 | Daily: `make north-star5-verify-fast` · before bootstrap merges: `make north-star5-verify ARGS=--strict` |
@@ -255,7 +255,7 @@ Full matrices (auto-generated): [`docs/capabilities.md`](docs/capabilities.md) (
**Self-host (experimental, not “stable app” scope)**
-See [Current implementation status](#current-implementation-status-july-2026) for the full M0–M5 ladder. Summary: M0–M5 bootstrap gates ✅; spine **8215** / **8215**; M3 strict native + inventory argv ✅; M4 full `bootstrap-loop-probe` ✅; M5 **`north-star5-verify-fast`** (daily) ✅ — **`--strict` red at step 4a2** ([#21417](https://github.com/PurHur/php-compiler/issues/21417)) ([#1492](https://github.com/PurHur/php-compiler/issues/1492), [#8559](https://github.com/PurHur/php-compiler/issues/8559)). Recent: native spine bundle probe, fast VM execute smoke ([#2201](https://github.com/PurHur/php-compiler/issues/2201)), `GeneratorYieldSourceMarker` spine unit ([#10356](https://github.com/PurHur/php-compiler/pull/10356)).
+See [Current implementation status](#current-implementation-status-july-2026) for the full M0–M5 ladder. Summary: M0–M5 bootstrap gates ✅; spine **8218** / **8218**; M3 strict native + inventory argv ✅; M4 full `bootstrap-loop-probe` ✅; M5 **`north-star5-verify-fast`** (daily) ✅ — **`--strict` red at step 4a2** ([#21417](https://github.com/PurHur/php-compiler/issues/21417)) ([#1492](https://github.com/PurHur/php-compiler/issues/1492), [#8559](https://github.com/PurHur/php-compiler/issues/8559)). Recent: native spine bundle probe, fast VM execute smoke ([#2201](https://github.com/PurHur/php-compiler/issues/2201)), `GeneratorYieldSourceMarker` spine unit ([#10356](https://github.com/PurHur/php-compiler/pull/10356)).
**What we do not target in v1.x**
diff --git a/docs/bootstrap-inventory.md b/docs/bootstrap-inventory.md
index bc868f7f170..ea654d6c54e 100644
--- a/docs/bootstrap-inventory.md
+++ b/docs/bootstrap-inventory.md
@@ -8,11 +8,11 @@ Regenerate: `php script/bootstrap-inventory.php`
| Metric | Count |
|--------|------:|
-| PHP files on vm.php path | 8217 |
-| Phase A inventory files (M2 ratio SSOT) | 8217 |
+| PHP files on vm.php path | 8218 |
+| Phase A inventory files (M2 ratio SSOT) | 8218 |
| Phase A ratio-deferred paths | 0 |
| Source constructs flagged (blockers) | 0 |
-| Source constructs flagged (warnings) | 26019 |
+| Source constructs flagged (warnings) | 26021 |
## Compiler CFG gaps (`lib/Compiler.php`)
@@ -7068,6 +7068,7 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
| `lib/JIT/Concern/EmitJitReturn.php` | 0 | 2 |
| `lib/JIT/Concern/InitJitMethodCall.php` | 0 | 3 |
| `lib/JIT/Concern/InitJitStaticCall.php` | 0 | 15 |
+| `lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php` | 0 | 2 |
| `lib/JIT/Concern/M3EmitTuSidecarLinktime.php` | 0 | 2 |
| `lib/JIT/Concern/PropertyFetchCoalesceAndCompileTimeString.php` | 0 | 6 |
| `lib/JIT/Concern/PropertyIncDecCompile.php` | 0 | 19 |
@@ -50483,87 +50484,87 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
### `lib/JIT.php`
**Warnings** (review for bootstrap subset):
-- new JIT\Call\ExternalMethod (line 363)
-- new JIT\Scope (line 392)
-- new Operand\Literal (line 642)
-- new Operand\Literal (line 658)
-- new JIT\Call\Native (line 1136)
-- new Variable (line 2142)
-- new JIT\Call\Native (line 2168)
-- new Variable (line 2258)
-- new JIT\Call\Vararg (line 2285)
-- new JIT\Call\Native (line 2295)
-- new JIT\Call\Native (line 2584)
-- new JIT\Variable (line 2824)
-- new JIT\Call\Native (line 2841)
-- new JIT\Call\Native (line 2886)
-- new JIT\Call\Native (line 2956)
-- new JIT\Call\Native (line 2998)
-- new JIT\Call\Native (line 3043)
-- new JIT\Call\Native (line 3098)
-- new JIT\Call\Native (line 4285)
-- new JIT\Call\Native (line 4316)
-- new JIT\Call\Native (line 4363)
-- new JIT\Call\Native (line 4437)
-- new JIT\Call\Native (line 4491)
-- new JIT\Call\Native (line 4578)
-- new JIT\Call\Native (line 5071)
-- new JIT\Call\Native (line 5102)
-- new JIT\Call\Native (line 5138)
-- new JIT\Call\Native (line 5173)
-- new JIT\Call\Native (line 5208)
-- new JIT\Call\Native (line 5243)
-- new JIT\Call\Native (line 5355)
-- new JIT\Call\Native (line 5597)
-- new JIT\Call\Native (line 5635)
-- new JIT\Call\Native (line 5669)
-- new JIT\Call\Native (line 5790)
-- new JIT\Call\Native (line 6440)
-- new JIT\Call\Native (line 6512)
-- new Operand\Literal (line 7023)
-- new Variable (line 7077)
-- new Variable (line 7121)
-- new Variable (line 7181)
-- new Variable (line 7877)
-- new Variable (line 7897)
-- new Variable (line 7918)
-- new Variable (line 7970)
-- new JIT\Call\ForeachIndexedClosureCall (line 8452)
-- new Variable (line 8541)
-- new Variable (line 8571)
-- new Variable (line 8590)
-- new Variable (line 8620)
-- new Variable (line 8657)
-- new Variable (line 8677)
-- new Variable (line 8690)
-- new Variable (line 8733)
-- new JIT\Variable (line 9523)
-- new JIT\Variable (line 9586)
-- new Variable (line 9686)
-- new Variable (line 9730)
-- new Variable (line 9782)
-- new Variable (line 9793)
-- new Variable (line 9804)
-- new Variable (line 9824)
-- new Variable (line 9887)
-- new Variable (line 9950)
-- new Variable (line 10020)
-- new Variable (line 10051)
-- new Variable (line 10119)
-- new Operand\Literal (line 10128)
-- new Variable (line 10143)
-- new Variable (line 10477)
-- new JIT\Call\VmCoerceVariableToString (line 10626)
-- new JIT\Call\NoOpConstruct (line 10829)
-- new Operand\Literal (line 11071)
-- new VM (line 11271)
-- new VM\Variable (line 11280)
-- new VM\ClassEntry (line 11385)
-- new Func\PHP (line 11404)
-- new VM\ClassEntry (line 11450)
-- new Variable (line 11467)
-- new Variable (line 12672)
-- 365 class method(s)
+- new JIT\Call\ExternalMethod (line 366)
+- new JIT\Scope (line 395)
+- new Operand\Literal (line 645)
+- new Operand\Literal (line 661)
+- new JIT\Call\Native (line 1139)
+- new Variable (line 2145)
+- new JIT\Call\Native (line 2171)
+- new Variable (line 2261)
+- new JIT\Call\Vararg (line 2288)
+- new JIT\Call\Native (line 2298)
+- new JIT\Call\Native (line 2587)
+- new JIT\Variable (line 2827)
+- new JIT\Call\Native (line 2844)
+- new JIT\Call\Native (line 2889)
+- new JIT\Call\Native (line 2959)
+- new JIT\Call\Native (line 3001)
+- new JIT\Call\Native (line 3046)
+- new JIT\Call\Native (line 3101)
+- new JIT\Call\Native (line 4288)
+- new JIT\Call\Native (line 4319)
+- new JIT\Call\Native (line 4366)
+- new JIT\Call\Native (line 4440)
+- new JIT\Call\Native (line 4494)
+- new JIT\Call\Native (line 4581)
+- new JIT\Call\Native (line 5074)
+- new JIT\Call\Native (line 5105)
+- new JIT\Call\Native (line 5141)
+- new JIT\Call\Native (line 5176)
+- new JIT\Call\Native (line 5211)
+- new JIT\Call\Native (line 5246)
+- new JIT\Call\Native (line 5358)
+- new JIT\Call\Native (line 5600)
+- new JIT\Call\Native (line 5638)
+- new JIT\Call\Native (line 5672)
+- new JIT\Call\Native (line 5793)
+- new JIT\Call\Native (line 6443)
+- new JIT\Call\Native (line 6515)
+- new Operand\Literal (line 7026)
+- new Variable (line 7080)
+- new Variable (line 7124)
+- new Variable (line 7184)
+- new Variable (line 7880)
+- new Variable (line 7900)
+- new Variable (line 7921)
+- new Variable (line 7973)
+- new JIT\Call\ForeachIndexedClosureCall (line 8455)
+- new Variable (line 8544)
+- new Variable (line 8574)
+- new Variable (line 8593)
+- new Variable (line 8623)
+- new Variable (line 8660)
+- new Variable (line 8680)
+- new Variable (line 8693)
+- new Variable (line 8736)
+- new JIT\Variable (line 9526)
+- new JIT\Variable (line 9589)
+- new Variable (line 9689)
+- new Variable (line 9733)
+- new Variable (line 9785)
+- new Variable (line 9796)
+- new Variable (line 9807)
+- new Variable (line 9827)
+- new Variable (line 9890)
+- new Variable (line 9953)
+- new Variable (line 10023)
+- new Variable (line 10054)
+- new Variable (line 10122)
+- new Operand\Literal (line 10131)
+- new Variable (line 10146)
+- new Variable (line 10480)
+- new JIT\Call\VmCoerceVariableToString (line 10629)
+- new JIT\Call\NoOpConstruct (line 10832)
+- new Operand\Literal (line 11074)
+- new VM (line 11274)
+- new VM\Variable (line 11283)
+- new VM\ClassEntry (line 11388)
+- new Func\PHP (line 11407)
+- new VM\ClassEntry (line 11453)
+- new Variable (line 11470)
+- new Variable (line 11637)
+- 335 class method(s)
- 15 closure(s)
### `lib/JIT/Analyzer.php`
@@ -58180,6 +58181,12 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
- 12 class method(s)
- 1 closure(s)
+### `lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php`
+
+**Warnings** (review for bootstrap subset):
+- trait LocalReleaseUnsetAndVarFetchDest (line 23)
+- 30 class method(s)
+
### `lib/JIT/Concern/M3EmitTuSidecarLinktime.php`
**Warnings** (review for bootstrap subset):
@@ -58584,7 +58591,7 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
### `lib/JIT/DiscardedPureCallElision.php`
**Warnings** (review for bootstrap subset):
-- 81 class method(s)
+- 83 class method(s)
### `lib/JIT/DnfParamCheck.php`
@@ -59719,8 +59726,8 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
### `lib/JIT/NoThrowCallElision.php`
**Warnings** (review for bootstrap subset):
-- new Operand\Literal (line 2869)
-- 92 class method(s)
+- new Operand\Literal (line 2959)
+- 94 class method(s)
### `lib/JIT/NonObjectPropertyFetchHelper.php`
@@ -60074,7 +60081,7 @@ Rank live CFG gaps across inventory files: `php script/bootstrap-inventory-triag
### `lib/JIT/SpineChunkRuntimeMethodDemote.php`
**Warnings** (review for bootstrap subset):
-- new OpCode (line 199)
+- new OpCode (line 200)
- 11 class method(s)
### `lib/JIT/SpineChunkStandardHelperBind.php`
diff --git a/docs/bootstrap-profile.json b/docs/bootstrap-profile.json
index d47076e5afc..6b362e40164 100644
--- a/docs/bootstrap-profile.json
+++ b/docs/bootstrap-profile.json
@@ -7155,6 +7155,7 @@
"lib/JIT/Concern/EmitJitReturn.php",
"lib/JIT/Concern/InitJitMethodCall.php",
"lib/JIT/Concern/InitJitStaticCall.php",
+ "lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php",
"lib/JIT/Concern/M3EmitTuSidecarLinktime.php",
"lib/JIT/Concern/PropertyFetchCoalesceAndCompileTimeString.php",
"lib/JIT/Concern/PropertyIncDecCompile.php",
@@ -8451,9 +8452,9 @@
"test/bootstrap-aot/lib_opcode/main.php"
],
"totals": {
- "inventory_files": 8217,
+ "inventory_files": 8218,
"excluded": 0,
- "eligible": 8217,
+ "eligible": 8218,
"aot_lint_targets": 113,
"aot_link_targets": 100,
"aot_link_lib_targets": 1
diff --git a/docs/bootstrap-selfhost.md b/docs/bootstrap-selfhost.md
index aac956e9cfc..606035a967d 100644
--- a/docs/bootstrap-selfhost.md
+++ b/docs/bootstrap-selfhost.md
@@ -2,7 +2,7 @@
**Gen-0 without Zend:** `BOOTSTRAP_M5_NO_ZEND=1 make bootstrap-selfhost-link` installs `prelinked/bootstrap-gen0/bin-compile-aot` and links `compiler_minimal` without `php bin/compile.php` ([#3053](https://github.com/PurHur/php-compiler/issues/3053)). **M5 lib spine compile:** `BOOTSTRAP_NO_ZEND_FALLBACK=1 make bootstrap-selfhost-lib-spine-smoke` (default in link script) refuses host `php bin/compile.php` on the spine emit path ([#8716](https://github.com/PurHur/php-compiler/issues/8716)). **Contributor workflow:** [bootstrap-dev-workflow.md](bootstrap-dev-workflow.md) (tiered gen-1+ path, spine checklist, CI).
-**Project north star:** The **compiler fully compiles itself** — native AOT from `lib/` (no `vendor/` at cold boot), then compiles PHP and rebuilds the next compiler revision without Zend. **M2 spine:** **8215** / **8215** Phase A inventory (`php script/bootstrap-spine-count.php`; `check-selfhost-spine-coverage-sync.php`). **M5 daily gate:** `make north-star5-verify-fast` (~1–2 min) ✅; **`--strict`** (~1h) pre-merge only. Committed `prelinked/bootstrap-gen0/` sidecars + vendor **7410/7412** cold boot. **Hot loop:** VM driver execute probe ~**20ms**; full spine relink only with `BOOTSTRAP_VM_DRIVER_EXECUTE_PROBE_FULL_LINK=1` ([#2201](https://github.com/PurHur/php-compiler/issues/2201)). **Living tracker:** [#1492](https://github.com/PurHur/php-compiler/issues/1492) (was [#1056](https://github.com/PurHur/php-compiler/issues/1056)) · **re-root doc:** [self-host-target.md](self-host-target.md) · **generation ladder:** [bootstrap-generations.md](bootstrap-generations.md) · **M5 fast path:** [bootstrap-m5-fast-path.md](bootstrap-m5-fast-path.md) · public status: [development-status § North star](https://purhur.github.io/php-compiler/development-status.html#north-star-self-host). Parent tracking: [#78](https://github.com/PurHur/php-compiler/issues/78) (roadmap), [#212](https://github.com/PurHur/php-compiler/issues/212) (closed umbrella).
+**Project north star:** The **compiler fully compiles itself** — native AOT from `lib/` (no `vendor/` at cold boot), then compiles PHP and rebuilds the next compiler revision without Zend. **M2 spine:** **8218** / **8218** Phase A inventory (`php script/bootstrap-spine-count.php`; `check-selfhost-spine-coverage-sync.php`). **M5 daily gate:** `make north-star5-verify-fast` (~1–2 min) ✅; **`--strict`** (~1h) pre-merge only. Committed `prelinked/bootstrap-gen0/` sidecars + vendor **7410/7412** cold boot. **Hot loop:** VM driver execute probe ~**20ms**; full spine relink only with `BOOTSTRAP_VM_DRIVER_EXECUTE_PROBE_FULL_LINK=1` ([#2201](https://github.com/PurHur/php-compiler/issues/2201)). **Living tracker:** [#1492](https://github.com/PurHur/php-compiler/issues/1492) (was [#1056](https://github.com/PurHur/php-compiler/issues/1056)) · **re-root doc:** [self-host-target.md](self-host-target.md) · **generation ladder:** [bootstrap-generations.md](bootstrap-generations.md) · **M5 fast path:** [bootstrap-m5-fast-path.md](bootstrap-m5-fast-path.md) · public status: [development-status § North star](https://purhur.github.io/php-compiler/development-status.html#north-star-self-host). Parent tracking: [#78](https://github.com/PurHur/php-compiler/issues/78) (roadmap), [#212](https://github.com/PurHur/php-compiler/issues/212) (closed umbrella).
## Current gates
diff --git a/docs/index.md b/docs/index.md
index 7acd589b5eb..e19bdb69b35 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -26,7 +26,7 @@ Bench history page: [`pages/bench.html`](pages/bench.html). Do not quote a ratio
## Internals
-[`architecture-review-2026-07.md`](architecture-review-2026-07.md) · [`self-host-target.md`](self-host-target.md) · [`bootstrap-m5-fast-path.md`](bootstrap-m5-fast-path.md) · ADRs under [`adr/`](adr/). Spine coverage **8217** / **8217**.
+[`architecture-review-2026-07.md`](architecture-review-2026-07.md) · [`self-host-target.md`](self-host-target.md) · [`bootstrap-m5-fast-path.md`](bootstrap-m5-fast-path.md) · ADRs under [`adr/`](adr/). Spine coverage **8218** / **8218**.
## Contributing for agents
diff --git a/docs/pages/development-status.md b/docs/pages/development-status.md
index ce80917487e..83bb881ee95 100644
--- a/docs/pages/development-status.md
+++ b/docs/pages/development-status.md
@@ -15,7 +15,7 @@ permalink: /development-status.html
| **North star** | Compiler compiles itself without Zend ([#1492](https://github.com/PurHur/php-compiler/issues/1492)) |
| **v1.1.0 theme** | M5 fast-path stability; enum/property hooks; `preg_match` JIT; `spl_autoload*`; php-in-PHP JIT helpers ([#78](https://github.com/PurHur/php-compiler/issues/78)) |
| **Wave 3** | Language **7410/7412** · Stdlib **7410/7412** on master ([#1380](https://github.com/PurHur/php-compiler/issues/1380)) |
-| **Spine SSOT** | `php script/bootstrap-spine-count.php` → **8215** / **8215** |
+| **Spine SSOT** | `php script/bootstrap-spine-count.php` → **8218** / **8218** |
| **Builtin matrix** | **1555** functions ([`docs/capabilities.md`](https://github.com/PurHur/php-compiler/blob/master/docs/capabilities.md)) |
| **Try it** | [`docs/GETTING-STARTED.md`](https://github.com/PurHur/php-compiler/blob/master/docs/GETTING-STARTED.md) |
@@ -50,7 +50,7 @@ permalink: /development-status.html
### Still open (high signal)
- **MCJIT execute** — `bin/jit.php -r` SIGSEGV ([#98](https://github.com/PurHur/php-compiler/issues/98))
-- **Literal spine ratio** — **8215/8215** ✅ (Jul 2026)
+- **Literal spine ratio** — **8218/8218** ✅ (Jul 2026)
- **Compile-spine stub retirement** — shrink `PHP_COMPILER_SELFHOST_AOT` on M3 allowlist ([#1402](https://github.com/PurHur/php-compiler/issues/1402))
- **007-ThrowsWeb AOT execute** — invalid POST segfault at runtime (link OK; slice `EXAMPLES_AOT_SMOKE_ONLY=007`)
- **LLVM 14+ upgrade** — experimental `script/install-llvm14.sh` ([#174](https://github.com/PurHur/php-compiler/issues/174))
@@ -62,7 +62,7 @@ permalink: /development-status.html
- **`phpc` CLI** — `run`, `serve`, `build`, `deploy`, `lint`, `test`, `init`, `doctor`
- **Examples 000–009** — VM and AOT link/execute for the curated web subset
- **Self-host M0** — `compiler_minimal bundle OK` ✅
-- **Self-host M2** — spine **8215/8215** ✅; native link + lint ✅
+- **Self-host M2** — spine **8218/8218** ✅; native link + lint ✅
- **Self-host M3** — HelloWorld strict `emit_path=native` ✅ ([#1493](https://github.com/PurHur/php-compiler/issues/1493)); inventory argv `bin/compile.php` ✅ ([#3024](https://github.com/PurHur/php-compiler/issues/3024) closed); compile-smoke strict native ✅ ([#1937](https://github.com/PurHur/php-compiler/issues/1937))
- **Self-host M4** — `make bootstrap-loop-probe` full ladder ✅; gen-2→gen-3 full-spine recompile ✅
- **Self-host M3–M5** — vendor prelink **7410/7412** ✅; **`make north-star5-verify-fast`** daily ✅; VM probe ~**20ms**. **`--strict` red** ([#21417](https://github.com/PurHur/php-compiler/issues/21417)) and **M3/M4 emit paths are prelinked blob COPIES, not native compiles** ([#21860](https://github.com/PurHur/php-compiler/issues/21860)) — the byte-identical gen-0/gen-2/gen-3 result follows from copying and is not fixpoint evidence
@@ -92,7 +92,7 @@ See [`docs/bootstrap-selfhost.md`](https://github.com/PurHur/php-compiler/blob/m
|-----------|--------|
| **M0** — Small `lib/` bundle runs | ✅ |
| **M1** — Compiler-shaped bundle + compile-smoke | ✅ |
-| **M2** — Spine toward full inventory | ✅ **8215** / **8215** |
+| **M2** — Spine toward full inventory | ✅ **8218** / **8218** |
| **M3** — Native compiles PHP (no Zend emit) | ✅ Smoke + inventory argv driver strict native |
| **M4** — Bootstrap loop (next revision) | ✅ `bootstrap-loop-probe` full ladder |
| **M5** — Full self-host, no `vendor/` cold boot | ✅ Presenter strict + compiled-only empty `build/` cold boot ([#3053](https://github.com/PurHur/php-compiler/issues/3053)) |
diff --git a/docs/pages/index.html b/docs/pages/index.html
index 217b5b84904..b76f4e919c0 100644
--- a/docs/pages/index.html
+++ b/docs/pages/index.html
@@ -391,7 +391,7 @@
Self-host progress
fix/spine-aot-jit-blockers @ a8cffaa0e (15 Jun 2026, LLVM 9).
- M2 spine SSOT: php script/bootstrap-spine-count.php → **8215** / **8215** Phase A inventory (literal coverage ✅).
+ M2 spine SSOT: php script/bootstrap-spine-count.php → **8218** / **8218** Phase A inventory (literal coverage ✅).
M4: make bootstrap-loop-probe full ladder ✅.
M5: make north-star5-verify-fast ✅ daily; --strict pre-merge; VM driver probe ~20ms in CI loops.
Remaining: MCJIT execute ([#98](https://github.com/PurHur/php-compiler/issues/98)); honest PHP main() in full spine AOT (native bundle-OK probe is bootstrap smoke).
@@ -437,7 +437,7 @@
M4
✓
M5
Vendor prelink 7410/7410 ✅; committed .o cold boot ✅;
- north-star5-verify-fast ✅ daily; --strict pre-merge; spine **8215** / **8215**; prelinked gen-0 refreshed;
+ north-star5-verify-fast ✅ daily; --strict pre-merge; spine **8218** / **8218**; prelinked gen-0 refreshed;
fast VM execute probe (~20ms); compiled-only cold boot via BOOTSTRAP_M5_NO_ZEND=1 ✅
(#3053,
#8559,
diff --git a/docs/pages/status.html b/docs/pages/status.html
index 202bada2453..a82e14a8e43 100644
--- a/docs/pages/status.html
+++ b/docs/pages/status.html
@@ -11,13 +11,13 @@
← Overview
Status snapshot
- Generated by script/status/snapshot.php from docs/status-snapshot.json (#36395) at .
+ Generated by script/status/snapshot.php from docs/status-snapshot.json (#36395) at .
| Metric | Value |
- | Spine / inventory | 8217 / 8217 |
+ | Spine / inventory | 8218 / 8218 |
| Capability matrix rows | 4054 (VM 4021 / JIT 1883 / AOT 1737) |
- | Differential cases | 323 |
+ | Differential cases | 324 |
| Apps corpus ready | 1 / 12 |
| VM driver probe target | ~20 ms |
diff --git a/docs/roadmap-wave3.md b/docs/roadmap-wave3.md
index 970f710d48e..bea542a11d1 100644
--- a/docs/roadmap-wave3.md
+++ b/docs/roadmap-wave3.md
@@ -19,7 +19,7 @@ Regenerate capability truth: `php script/capability-matrix.php`, `php script/cap
Related merges outside this wave: `goto` ([#1228](https://github.com/PurHur/php-compiler/issues/1228) / [#1333](https://github.com/PurHur/php-compiler/pull/1333)), anonymous classes ([#1233](https://github.com/PurHur/php-compiler/issues/1233) / [#1386](https://github.com/PurHur/php-compiler/pull/1386)).
-**M2 spine:** **8215** / **8215** (`php script/bootstrap-spine-count.php`) — full Phase A inventory in `compiler_lib_spine_smoke`; coverage sync ✅ (`check-selfhost-spine-coverage-sync.php`). Native spine **link** + **lint** ✅ ([#2134](https://github.com/PurHur/php-compiler/issues/2134), [#8559](https://github.com/PurHur/php-compiler/issues/8559)). **M5 daily gate:** `make north-star5-verify-fast` + VM probe ~20ms ([#2201](https://github.com/PurHur/php-compiler/issues/2201)); `--strict` pre-merge only. M4 gen-2→gen-3 recompile ✅. Target doc: [self-host-target.md](self-host-target.md)
+**M2 spine:** **8218** / **8218** (`php script/bootstrap-spine-count.php`) — full Phase A inventory in `compiler_lib_spine_smoke`; coverage sync ✅ (`check-selfhost-spine-coverage-sync.php`). Native spine **link** + **lint** ✅ ([#2134](https://github.com/PurHur/php-compiler/issues/2134), [#8559](https://github.com/PurHur/php-compiler/issues/8559)). **M5 daily gate:** `make north-star5-verify-fast` + VM probe ~20ms ([#2201](https://github.com/PurHur/php-compiler/issues/2201)); `--strict` pre-merge only. M4 gen-2→gen-3 recompile ✅. Target doc: [self-host-target.md](self-host-target.md)
## Language (#1354–#1366)
diff --git a/docs/self-host-target.md b/docs/self-host-target.md
index 784c767ab96..2fc7339ad69 100644
--- a/docs/self-host-target.md
+++ b/docs/self-host-target.md
@@ -45,7 +45,7 @@ Research stretch (not a release gate): that same binary can compile the next rev
|-------|-------|--------|
| **Bootstrap driver** | Prelinked gen-0 refreshed via honest inventory argv emit; native `build/bin-compile-aot-inventory` for M4/M5 | Compiled `bin/compile.php` only |
| **Bundle size** | **7410/7412** literal Phase A inventory in spine smoke | Full vm.php closure |
-| **Inventory coverage** | **8215** / **8215** ✅ | Full closure |
+| **Inventory coverage** | **8218** / **8218** ✅ | Full closure |
| **HelloWorld** | ✅ `emit_path=native` via gen-0 argv emit helper (`DRIVER -o OUT SOURCE`; [#22178](https://github.com/PurHur/php-compiler/issues/22178)) | Native compile for arbitrary PHP |
| **Bootstrap loop (M4)** | `make bootstrap-loop-probe` full ladder ✅ — gen-1→gen-2, gen-2→gen-3 full spine, full-revision argv | Native full revision rebuild |
| **Vendor** | **7410/7412** vendor `object_ok`; committed `.o` cold boot without `vendor/` ✅; `make north-star5-verify-fast` daily ✅; `--strict` ❌ **red at step 4a2** ([#21417](https://github.com/PurHur/php-compiler/issues/21417)) | No Zend `vendor/autoload.php` at bootstrap |
@@ -79,7 +79,7 @@ Research stretch (not a release gate): that same binary can compile the next rev
|-----------|----------------|--------|-----|
| **M0** | AOT can link a **small** honest `lib/` subset | ✅ | 100% |
| **M1** | Bundle is **compiler-shaped** (lint + compile-smoke) | ✅ | 100% |
-| **M2** | Spine grows toward full `bin/vm.php` inventory | ✅ **8215** / **8215** | **100%** |
+| **M2** | Spine grows toward full `bin/vm.php` inventory | ✅ **8218** / **8218** | **100%** |
| **M3** | Self-host binary **compiles external PHP** (HelloWorld) without Zend emit | ✅ `emit_path=native` via gen-0 argv helper ([#22178](https://github.com/PurHur/php-compiler/issues/22178)) | **~90%** |
| **M4** | Self-host binary **rebuilds** the next compiler tree | 🚧 ladder runs but gen-1→gen-2 is a COPY ([#21860](https://github.com/PurHur/php-compiler/issues/21860)) | **~60%** |
| **M5 (release)** | Static `phpc` builds user/Composer projects | 🚧 fast gate ✅; corpus app smoke open ([#36380](https://github.com/PurHur/php-compiler/issues/36380)); see [ADR #36393](adr/36393-selfhost-user-payoff.md) | **payoff** |
@@ -91,7 +91,7 @@ Research stretch (not a release gate): that same binary can compile the next rev
| Indicator | Formula | Jul 2026 |
|-----------|---------|----------|
-| **M2 spine progress** | `require_once` units in `compiler_lib_spine_smoke` ÷ Phase A inventory file count | **8215** / **8215** (`php script/bootstrap-spine-count.php`) |
+| **M2 spine progress** | `require_once` units in `compiler_lib_spine_smoke` ÷ Phase A inventory file count | **8218** / **8218** (`php script/bootstrap-spine-count.php`) |
| **Public “Self-host” row** | Same M2 ratio until M3–M5 gates add weight ([`development-status.md`](pages/development-status.md)) | **~97%** |
| **M5 vendor prelink** | `object_ok` packages ÷ 3 | **3 / 3** (cfg, types, llvm) |
| **Composite (internal)** | Milestone weights in table above (M0–M1 = 100%, M2 = spine %, M3–M5 = gate %) | **~65%** |
diff --git a/docs/status-snapshot.json b/docs/status-snapshot.json
index 373c931bbb6..d7c89cf163a 100644
--- a/docs/status-snapshot.json
+++ b/docs/status-snapshot.json
@@ -1,14 +1,14 @@
{
"generated_by": "script/status/snapshot.php",
"issue": 36395,
- "generated_at": "2026-09-05T04:33:06Z",
- "spine": 8217,
- "inventory": 8217,
+ "generated_at": "2026-09-05T04:53:30Z",
+ "spine": 8218,
+ "inventory": 8218,
"builtins_matrix_rows": 4054,
"builtins_vm_yes": 4021,
"builtins_jit_yes": 1883,
"builtins_aot_yes": 1737,
- "differential_cases": 323,
+ "differential_cases": 324,
"apps_packages": 12,
"apps_ready": 1,
"vm_driver_probe_ms_target": 20,
diff --git a/lib/JIT.php b/lib/JIT.php
index 5ee30449177..677a810e272 100644
--- a/lib/JIT.php
+++ b/lib/JIT.php
@@ -32,6 +32,7 @@
require_once __DIR__.'/JIT/Concern/CallResultOperandAssign.php';
require_once __DIR__.'/JIT/Concern/ByRefFormalAssignAndCallArgAdapt.php';
require_once __DIR__.'/JIT/Concern/ResolveJitOutgoingCall.php';
+require_once __DIR__.'/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php';
require_once __DIR__.'/JIT/RuntimeInitVmContext.php';
require_once __DIR__.'/JIT/RuntimeInitCompiler.php';
require_once __DIR__.'/JIT/RuntimeInitParsePipeline.php';
@@ -81,6 +82,8 @@ class JIT {
use CallResultOperandAssign;
use ByRefFormalAssignAndCallArgAdapt;
use ResolveJitOutgoingCall;
+ use LocalReleaseUnsetAndVarFetchDest;
+
private static int $functionNumber = 0;
private static int $blockNumber = 0;
/** Nested php-in-PHP helper compiles during an outer JIT::compile() (#10528). */
@@ -11477,1044 +11480,6 @@ private function jitVariableFromVmArray(VM\Variable $vm): Variable
return JIT\VmConstantJit::toVariable($this->context, $vm);
}
- /** Release boxed locals before user function return (Zend end of scope; #4096). */
- private function releaseJitFunctionLocalsAtReturn(Block $block): void
- {
- if (null === $block->func) {
- return;
- }
- $fnName = $block->func->name;
- if ('{main}' === $fnName || str_ends_with($fnName, '::__destruct')) {
- return;
- }
- $byRefParamNames = [];
- foreach ($block->paramByRef as $paramIdx => $_) {
- if (isset($block->paramNames[$paramIdx]) && '' !== $block->paramNames[$paramIdx]) {
- $byRefParamNames[$block->paramNames[$paramIdx]] = true;
- }
- }
- /** @var array $released */
- $released = [];
- /** @var array $localNames */
- $localNames = [];
- foreach ($this->jitFunctionNamedScopeSlots($block) as [$name, ,]) {
- if ('this' !== $name && '' !== $name) {
- $localNames[$name] = true;
- }
- }
- foreach ($this->jitFunctionAssignTargets($block) as $destOp) {
- $name = JIT\OperandName::resolve($destOp);
- if (null !== $name && '' !== $name) {
- $localNames[$name] = true;
- }
- }
- foreach ($block->orig->deadOperands ?? [] as $deadOp) {
- $name = JIT\OperandName::resolve($deadOp);
- if (null !== $name && '' !== $name) {
- $localNames[$name] = true;
- }
- }
- foreach (array_keys($localNames) as $name) {
- if (isset($released[$name])) {
- continue;
- }
- $resolved = $this->context->resolveRefAliasName($name);
- $var = $this->context->namedVariableBindings[$resolved] ?? null;
- if (null === $var) {
- continue;
- }
- $this->releaseJitCanonicalNamedLocalAtReturn(
- $name,
- $var,
- $byRefParamNames,
- $released
- );
- }
- }
-
- /**
- * @param array $byRefParamNames
- * @param array $released
- */
- private function releaseJitCanonicalNamedLocalAtReturn(
- string $name,
- Variable $var,
- array $byRefParamNames,
- array &$released
- ): void {
- if ('this' === $name || isset($released[$name])) {
- return;
- }
- if (isset($byRefParamNames[$name])) {
- return;
- }
- if (Variable::KIND_VARIABLE !== $var->kind) {
- return;
- }
- if ($var->borrowedValueEntry || null !== $var->valueBoxAliasPtr) {
- return;
- }
- if (Variable::TYPE_VALUE === $var->type) {
- $this->jitWriteNullForUnset(JIT\JitValueBox::valuePtrFromVariable($this->context, $var));
- $released[$name] = true;
-
- return;
- }
- // Native packed arrays (e.g. `string[1]`) still have IS_REFCOUNTED on the
- // element type. loadValue+delref would bitcast the array aggregate
- // (`[1 x %__string__*]`) to `__ref__virtual*` / i8* and fail module verify
- // (#36382 Slim/nyholm; php-src zend_array_destroy walks buckets).
- if (0 !== ($var->type & Variable::IS_NATIVE_ARRAY)) {
- $var->free();
- $released[$name] = true;
-
- return;
- }
- if ($var->type & Variable::IS_REFCOUNTED) {
- if (null !== $var->objectPropertySlot) {
- return;
- }
- $ptr = Variable::KIND_VALUE === $var->kind
- ? $var->value
- : $this->context->helper->loadValue($var);
- if ($this->context->type->object->hasUserDestructors()) {
- \PHPCompiler\JIT\Builtin\GcCollectCyclesRuntime::ensureLinked($this->context);
- $this->context->builder->call(
- $this->context->lookupFunction('phpc_destruct_try_invoke'),
- $this->context->builder->pointerCast(
- $ptr,
- $this->context->getTypeFromString('int8*')
- )
- );
- }
- JIT\Builtin\WeakRefRuntime::ensureLinked($this->context);
- $this->context->builder->call(
- $this->context->lookupFunction('phpc_weakref_clear_object'),
- $this->context->builder->pointerCast(
- $ptr,
- $this->context->getTypeFromString('int8*')
- )
- );
- $this->context->refcount->delref($ptr);
- if (Variable::KIND_VARIABLE === $var->kind && null !== $var->value) {
- $slotTy = $var->value->typeOf();
- if (\PHPLLVM\Type::KIND_POINTER === $slotTy->getKind()) {
- $this->context->builder->store(
- $slotTy->getElementType()->constNull(),
- $var->value
- );
- }
- }
- $released[$name] = true;
- }
- }
-
- /**
- * @param array $byRefParamNames
- * @param array $released
- */
- private function releaseJitNamedLocalAtReturn(
- Block $returnBlock,
- string $name,
- int $slotIdx,
- Block $scopeBlock,
- array $byRefParamNames,
- array &$released
- ): void {
- if ('this' === $name || isset($released[$name])) {
- return;
- }
- if (isset($byRefParamNames[$name])) {
- return;
- }
- $resolved = $this->context->resolveRefAliasName($name);
- $var = $this->context->namedVariableBindings[$resolved] ?? null;
- if (null !== $var) {
- $this->releaseJitCanonicalNamedLocalAtReturn($name, $var, $byRefParamNames, $released);
-
- return;
- }
- if ($slotIdx < 0) {
- return;
- }
- $scopedOp = $scopeBlock->operandForScopeSlot($slotIdx);
- if (null === $scopedOp) {
- return;
- }
- try {
- $var = $this->context->getVariableFromOp($scopedOp);
- } catch (\LogicException) {
- return;
- }
- $this->releaseJitCanonicalNamedLocalAtReturn($name, $var, $byRefParamNames, $released);
- }
-
- /**
- * @return list<\PHPCfg\Operand>
- */
- private function jitFunctionAssignTargets(Block $returnBlock): array
- {
- /** @var list<\PHPCfg\Operand> $targets */
- $targets = [];
- $seen = new \SplObjectStorage();
- foreach ($this->jitFunctionNamedScopeSlots($returnBlock) as [, , $scopeBlock]) {
- foreach ($this->listUnpackAssignTargetsInBlock($scopeBlock) as $dest) {
- if ($seen->contains($dest)) {
- continue;
- }
- $seen[$dest] = true;
- $targets[] = $dest;
- }
- }
-
- return $targets;
- }
-
- /**
- * All named CV slots in the returning function — return-block scope alone omits
- * live-at-return locals php-cfg already marked dead (#36245 make_pair).
- *
- * @return \Generator
- */
- private function jitFunctionNamedScopeSlots(Block $returnBlock): \Generator
- {
- $root = $this->context->jitFunctionRootBlock ?? $returnBlock;
- /** @var array $seenBlocks */
- $seenBlocks = [];
- /** @var list $queue */
- $queue = [$root];
- while ([] !== $queue) {
- $scan = array_shift($queue);
- $blockId = spl_object_id($scan);
- if (isset($seenBlocks[$blockId])) {
- continue;
- }
- $seenBlocks[$blockId] = true;
- foreach ($scan->eachNamedScopeSlot() as [$name, $slotIdx]) {
- yield [$name, $slotIdx, $scan];
- }
- foreach ($scan->opCodes as $op) {
- foreach ([$op->block1 ?? null, $op->block2 ?? null, $op->block3 ?? null] as $target) {
- if ($target instanceof Block && !isset($seenBlocks[spl_object_id($target)])) {
- $queue[] = $target;
- }
- }
- }
- }
- }
-
- /**
- * unset($var) on boxed locals: run __destruct before nulling when {main} defers delref destroy (#4096).
- * Also clear WeakMap/WeakReference immediately — {main} may defer __ref__delref free (#27621 / #26795).
- */
- private function jitWriteNullForUnset(\PHPLLVM\Value $valueBoxPtr): void
- {
- $map = $this->context->structFieldMap['__value__'];
- $i8 = $this->context->getTypeFromString('int8');
- $i8p = $this->context->getTypeFromString('int8*');
- $typeByte = $this->context->builder->load(
- $this->context->builder->structGep($valueBoxPtr, $map['type'])
- );
- $isObject = $this->context->builder->icmp(
- \PHPLLVM\Builder::INT_EQ,
- $typeByte,
- $i8->constInt(Variable::TYPE_OBJECT, false)
- );
- $objBlock = JIT\BasicBlockHelper::append($this->context, 'unset_object_side');
- $doneBlock = JIT\BasicBlockHelper::append($this->context, 'unset_object_done');
- $this->context->builder->branchIf($isObject, $objBlock, $doneBlock);
- $this->context->builder->positionAtEnd($objBlock);
- $obj = $this->context->builder->call(
- $this->context->lookupFunction('__value__readObject'),
- $valueBoxPtr
- );
- $objI8 = $this->context->builder->pointerCast($obj, $i8p);
- if ($this->context->type->object->hasUserDestructors()) {
- \PHPCompiler\JIT\Builtin\GcCollectCyclesRuntime::ensureLinked($this->context);
- $this->context->builder->call(
- $this->context->lookupFunction('phpc_destruct_try_invoke'),
- $objI8
- );
- }
- // WeakMap keys must drop before count() even when delref destroy is deferred (#27621).
- // Save insert point — WeakRefRuntime::ensureLinked clears the builder (#27621).
- $insertBefore = $this->context->builder->getInsertBlock();
- JIT\Builtin\WeakRefRuntime::ensureLinked($this->context);
- if (null !== $insertBefore) {
- $this->context->builder->positionAtEnd($insertBefore);
- }
- $this->context->builder->call(
- $this->context->lookupFunction('phpc_weakref_clear_object'),
- $objI8
- );
- // Zend decrements refcount on unset — valueDelref alone leaves extra GC roots (#36245).
- $this->context->refcount->delref($obj);
- $this->context->builder->branch($doneBlock);
- $this->context->builder->positionAtEnd($doneBlock);
- $this->jitNoteMemoryReleaseForUnset($valueBoxPtr);
- $this->context->builder->call(
- $this->context->lookupFunction('__value__writeNull'),
- $valueBoxPtr
- );
- }
-
- /**
- * Named-storage `$a = new T` keeps the object in the NEW/ASSIGN result
- * {@see __object__**} alloca as well as the CV value-box. Unset (and null
- * assign) must null those mirrors without delref — otherwise the next
- * loop-body NEW freeObjectMirrorUnlessNull double-delrefs the orphan and
- * GC sees roots=0 (#36245 loop_unset). Distinct Operand instances share a
- * CFG slot, so clear via getOperand (assign's operand) not only
- * operandForScopeSlot (prologue).
- */
- private function jitClearAssignResultObjectMirrorForNamedUnset(Block $block, ?int $unsetArgSlot): void
- {
- if (null === $unsetArgSlot) {
- return;
- }
- $targetSlot = (int) $unsetArgSlot;
- $seen = new \SplObjectStorage();
- foreach ($block->opCodes as $assignOp) {
- if (OpCode::TYPE_ASSIGN !== $assignOp->type || null === $assignOp->arg2) {
- continue;
- }
- if ((int) $assignOp->arg2 !== $targetSlot) {
- continue;
- }
- // Property/dim assigns use arg1 === arg2; still clear RHS object mirrors.
- $slots = [];
- if (null !== $assignOp->arg1 && $assignOp->arg1 !== $assignOp->arg2) {
- $slots[] = (int) $assignOp->arg1;
- }
- try {
- $rhs = $this->assignRhsSlot($assignOp);
- if ($rhs !== $targetSlot) {
- $slots[] = $rhs;
- }
- } catch (\LogicException $e) {
- // Missing RHS slot — named unset still clears assign-result mirrors.
- }
- foreach ($slots as $slot) {
- $this->jitNullObjectMirrorForScopeSlot($block, $slot, $seen);
- }
- }
- }
-
- /**
- * Null every {@see __object__**} alloca bound to $slot (map + all Operand aliases).
- *
- * @param \SplObjectStorage<\PHPLLVM\Value, mixed> $seen
- */
- private function jitNullObjectMirrorForScopeSlot(Block $block, int $slot, \SplObjectStorage $seen): void
- {
- $nullObj = $this->context->getTypeFromString('__object__*')->constNull();
- if (isset($this->context->scopeSlotObjectMirrorLlvmBySlot[$slot])) {
- $llvmMirror = $this->context->scopeSlotObjectMirrorLlvmBySlot[$slot];
- if (!$seen->contains($llvmMirror)) {
- $seen[$llvmMirror] = true;
- $this->context->builder->store($nullObj, $llvmMirror);
- }
- }
- $operands = [];
- $scoped = $block->operandForScopeSlot($slot);
- if (null !== $scoped) {
- $operands[] = $scoped;
- }
- // Prefer the exact Operand getOperand returns — assign/NEW lower against it (#36245).
- $fromOpcode = $block->getOperand($slot);
- if (null !== $fromOpcode) {
- $operands[] = $fromOpcode;
- }
- foreach ($block->scopedOperands() as $scopedOp) {
- if ($block->slotForOperand($scopedOp) === $slot) {
- $operands[] = $scopedOp;
- }
- }
- foreach ($operands as $op) {
- if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
- continue;
- }
- $mirror = $this->context->hasVariableOp($op)
- ? $this->context->getVariableFromOp($op)
- : $this->context->scope->variables[$op];
- if (
- Variable::TYPE_OBJECT !== $mirror->type
- || Variable::KIND_VARIABLE !== $mirror->kind
- || null !== $mirror->objectPropertySlot
- || $mirror->functionStaticGlobal
- ) {
- continue;
- }
- if (!str_contains($this->context->getStringFromType($mirror->value->typeOf()), '__object__')) {
- continue;
- }
- if ($seen->contains($mirror->value)) {
- continue;
- }
- $seen[$mirror->value] = true;
- $this->context->builder->store($nullObj, $mirror->value);
- $this->context->scopeSlotObjectMirrorLlvmBySlot[$slot] = $mirror->value;
- }
- }
-
- /** Delref an {@see __object__**} mirror only when it still holds a non-null pointer (#36245). */
- private function freeObjectMirrorUnlessNull(Variable $mirror): void
- {
- $nullObj = $this->context->getTypeFromString('__object__*')->constNull();
- $loaded = $this->context->builder->load($mirror->value);
- $hasObj = $this->context->builder->icmp(
- \PHPLLVM\Builder::INT_NE,
- $loaded,
- $nullObj
- );
- $delrefBlock = JIT\BasicBlockHelper::append($this->context, 'obj_mirror_delref');
- $skipBlock = JIT\BasicBlockHelper::append($this->context, 'obj_mirror_skip');
- $this->context->builder->branchIf($hasObj, $delrefBlock, $skipBlock);
- $this->context->builder->positionAtEnd($delrefBlock);
- $this->context->refcount->delref($loaded);
- $this->context->builder->branch($skipBlock);
- $this->context->builder->positionAtEnd($skipBlock);
- // Always clear — unset may have nulled without delref; next NEW must not
- // load a stale pointer (#36245 / Variable::free peer).
- $this->context->builder->store($nullObj, $mirror->value);
- }
-
- /**
- * After === / !==, drop anonymous Temporary value boxes (call results). Named
- * locals stay; freeDeadVariables at block edges is too late for unset (#27118).
- */
- private function jitReleaseTempValueBoxAfterCompare(Block $block, Operand $op): void
- {
- $this->context->aliasVariableOpFromSlot($block, $op);
- if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
- $slot = $block->slotForOperand($op);
- if (null === $slot) {
- return;
- }
- $scoped = $block->operandForScopeSlot($slot);
- if (null === $scoped) {
- return;
- }
- $op = $scoped;
- }
- if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
- return;
- }
- $var = $this->context->hasVariableOp($op)
- ? $this->context->getVariableFromOp($op)
- : $this->context->scope->variables[$op];
- $name = JIT\OperandName::resolve($op);
- if (null !== $name && '' !== $name) {
- // Named locals/params must survive identical/not-identical (#31101 MiniWebApp
- // $route after !== "api/status"). Only anonymous temps are statement-end released
- // for WeakReference::get (#27118).
- return;
- }
- if (
- Variable::TYPE_VALUE !== $var->type
- || $var->functionStaticGlobal
- || $var->borrowedValueEntry
- || null !== $var->superglobalName
- || null !== $var->valueBoxAliasPtr
- ) {
- return;
- }
- if (
- Variable::KIND_VARIABLE !== $var->kind
- && Variable::KIND_VALUE !== $var->kind
- ) {
- return;
- }
- $this->jitWriteNullForUnset(
- JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
- );
- if ($this->context->scope->variables->contains($op)) {
- $this->context->scope->variables->detach($op);
- }
- }
-
- private function jitReleasePendingWeakReferenceGetResult(): void
- {
- $op = $this->context->pendingWeakReferenceGetResult;
- $this->context->pendingWeakReferenceGetResult = null;
- if (null === $op) {
- return;
- }
- if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
- return;
- }
- $var = $this->context->hasVariableOp($op)
- ? $this->context->getVariableFromOp($op)
- : $this->context->scope->variables[$op];
- if (
- Variable::TYPE_VALUE !== $var->type
- || $var->functionStaticGlobal
- || $var->borrowedValueEntry
- ) {
- return;
- }
- $this->jitWriteNullForUnset(
- JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
- );
- if ($this->context->scope->variables->contains($op)) {
- $this->context->scope->variables->detach($op);
- }
- }
-
- /**
- * VM releaseVmJumpIfCondTemps (#14103) for JIT: drop anonymous TYPE_VALUE boxes
- * that die in this block before branching so WeakReference::get() results do not
- * keep referents across unset in a ternary-echo merge block (#27118).
- *
- * Only considers {@see Block::$orig} deadOperands for this block — after successor
- * arms are compiled, scope also holds merge-block bindings that must not be freed here.
- */
- private function jitReleaseJumpIfAnonValueBoxes(Block $block, OpCode $jumpIf): void
- {
- $keepOps = new \SplObjectStorage();
- if (null !== $jumpIf->arg1) {
- $condOp = $this->operandAt($block, $jumpIf->arg1, 'branch condition');
- $keepOps[$condOp] = true;
- }
- foreach ($this->context->coalesceAssignTargets as $mergeOp) {
- $keepOps[$mergeOp] = true;
- }
- $toFree = [];
- $seen = new \SplObjectStorage();
- foreach ($block->orig->deadOperands as $deadOp) {
- $candidates = [$deadOp];
- $slot = $block->slotForOperand($deadOp);
- if (null !== $slot) {
- $scoped = $block->operandForScopeSlot($slot);
- if (null !== $scoped) {
- $candidates[] = $scoped;
- }
- }
- foreach ($candidates as $op) {
- if ($seen->contains($op)) {
- continue;
- }
- $seen[$op] = true;
- if ($keepOps->contains($op)) {
- continue;
- }
- $name = JIT\OperandName::resolve($op);
- // Named CVs (params / locals) are never "anon" temps — nulling them at a
- // JUMPIF edge clears live values still read in ternary/if arms (#27624:
- // DNF `__value__*` param `$x` + `is_array($x) ? count($x) : …`).
- if (null !== $name && '' !== $name) {
- continue;
- }
- if (!$this->context->scope->variables->contains($op) && !$this->context->hasVariableOp($op)) {
- continue;
- }
- $var = $this->context->hasVariableOp($op)
- ? $this->context->getVariableFromOp($op)
- : $this->context->scope->variables[$op];
- // Only owned KIND_VARIABLE allocas. KIND_VALUE often aliases a live CV /
- // caller `__value__*` (DNF/mixed params); writeNull would clear storage
- // still read in ternary arms (#27624).
- if (
- Variable::TYPE_VALUE !== $var->type
- || Variable::KIND_VARIABLE !== $var->kind
- || $var->functionStaticGlobal
- || $var->borrowedValueEntry
- || null !== $var->superglobalName
- || null !== $var->valueBoxAliasPtr
- ) {
- continue;
- }
- $toFree[] = $op;
- }
- }
- foreach ($toFree as $op) {
- $var = $this->context->hasVariableOp($op)
- ? $this->context->getVariableFromOp($op)
- : $this->context->scope->variables[$op];
- $this->jitWriteNullForUnset(
- JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
- );
- if ($this->context->scope->variables->contains($op)) {
- $this->context->scope->variables->detach($op);
- }
- }
- }
-
- /** Zend emalloc parity: drop tracked bytes when unset frees a string (#7310). */
- private function jitNoteMemoryReleaseForUnset(\PHPLLVM\Value $valueBoxPtr): void
- {
- JIT\Builtin\MemoryRuntime::ensureLinked($this->context);
- $map = $this->context->structFieldMap['__value__'];
- $stringMap = $this->context->structFieldMap['__string__'];
- $i8 = $this->context->getTypeFromString('int8');
- $i64 = $this->context->getTypeFromString('int64');
- $zero = $i64->constInt(0, false);
- $typeByte = $this->context->builder->load(
- $this->context->builder->structGep($valueBoxPtr, $map['type'])
- );
- $isString = $this->context->builder->icmp(
- \PHPLLVM\Builder::INT_EQ,
- $typeByte,
- $i8->constInt(Variable::TYPE_STRING, false)
- );
- $doneBlock = JIT\BasicBlockHelper::append($this->context, 'unset_mem_done');
- $stringBlock = JIT\BasicBlockHelper::append($this->context, 'unset_mem_string');
- $this->context->builder->branchIf($isString, $stringBlock, $doneBlock);
- $this->context->builder->positionAtEnd($stringBlock);
- $strPtr = $this->context->builder->call(
- $this->context->lookupFunction('__value__readString'),
- $valueBoxPtr
- );
- $len = $this->context->builder->load(
- $this->context->builder->structGep($strPtr, $stringMap['length'])
- );
- $negLen = $this->context->builder->sub($zero, $len);
- JIT\Builtin\MemoryRuntime::noteAlloc($this->context, $negLen);
- $this->context->builder->branch($doneBlock);
- $this->context->builder->positionAtEnd($doneBlock);
- }
-
- /** Drop assign RHS / result temps so block-end dead-operand free cannot re-delref (#4096). */
- private function jitClearAssignTempOperand(Operand $op): void
- {
- $this->jitWriteNullOperand($op);
- if ($this->context->scope->variables->contains($op)) {
- $this->context->scope->variables->detach($op);
- }
- }
-
- /** Mirror VM assign: clear dead assign-result / RHS temps (#4096). */
- private function jitWriteNullOperand(Operand $op): void
- {
- if (!$this->context->hasVariableOp($op)) {
- return;
- }
- $var = $this->context->getVariableFromOp($op);
- if (Variable::KIND_VARIABLE === $var->kind && Variable::TYPE_VALUE === $var->type) {
- $this->context->builder->call(
- $this->context->lookupFunction('__value__writeNull'),
- $var->value
- );
-
- return;
- }
- if (
- Variable::TYPE_OBJECT === $var->type
- && Variable::KIND_VARIABLE === $var->kind
- && null !== $var->value
- && \in_array($var->value, $this->context->scopeSlotObjectMirrorLlvmBySlot, true)
- ) {
- $isCanonicalCv = false;
- foreach ($this->context->namedVariableBindings as $bound) {
- if ($bound === $var) {
- $isCanonicalCv = true;
- break;
- }
- }
- if (!$isCanonicalCv) {
- $slotTy = $var->value->typeOf();
- if (\PHPLLVM\Type::KIND_POINTER === $slotTy->getKind()) {
- $this->context->builder->store(
- $slotTy->getElementType()->constNull(),
- $var->value
- );
- }
-
- return;
- }
- }
- $var->free();
- }
-
- /**
- * php-cfg ASSIGN(resultTemp, namedAlias, rhs) — mark the named CV, not only the dead temp (#36405).
- */
- private function propagateAssignAliasBinding(
- ?Operand $destOp,
- Operand $aliasOp,
- bool $namedAliasReceivedAssign
- ): void {
- $aliasName = JIT\OperandName::resolve($aliasOp);
- if (null === $aliasName || '' === $aliasName) {
- return;
- }
- if ($namedAliasReceivedAssign) {
- if ($this->context->hasVariableOp($aliasOp)) {
- JIT\UndefinedVariableHelper::markAssigned(
- $this->context,
- $aliasOp,
- $this->context->getVariableFromOp($aliasOp)
- );
- }
-
- return;
- }
- if (null !== $destOp && $this->context->hasVariableOp($destOp)) {
- $destVar = $this->context->getVariableFromOp($destOp);
- $this->context->setVariableOp($aliasOp, $destVar);
- $this->context->bindVariableByName(
- $this->context->resolveRefAliasName($aliasName),
- $destVar
- );
- JIT\UndefinedVariableHelper::markAssigned($this->context, $aliasOp, $destVar);
- }
- }
-
- private function maybeBindNamedVariable(Operand $op): void
- {
- if (!$this->context->hasVariableOp($op)) {
- return;
- }
- $name = JIT\OperandName::resolve($op);
- if (null === $name || '' === $name) {
- return;
- }
- $var = $this->context->getVariableFromOp($op);
- $this->context->bindVariableByName($name, $var);
- // TYPE_ASSIGN dest is a defined CV for later ZEND_CHECK_UNDEFINED_VAR (#32041).
- JIT\UndefinedVariableHelper::markAssigned($this->context, $op, $var);
- }
-
- /**
- * After KIND_VALUE→alloca CONCAT promotion, rebind every Operand for the dest
- * scope slot (named local + unnamed Temporary) so in-place `$out .=` loads and
- * stores the same alloca across loop iterations (#22845).
- */
- private function bindPromotedStringConcatDest(Block $block, Operand $destOp, Variable $promoted): void
- {
- $names = [];
- $destName = JIT\OperandName::resolve($destOp);
- if (null !== $destName && '' !== $destName) {
- $names[$destName] = true;
- }
- $slot = $block->slotForOperand($destOp);
- if (null !== $slot) {
- foreach ($block->scopedOperands() as $scopeOp) {
- if ($block->slotForOperand($scopeOp) !== $slot) {
- continue;
- }
- $this->context->setVariableOp($scopeOp, $promoted);
- $scopeName = JIT\OperandName::resolve($scopeOp);
- if (null !== $scopeName && '' !== $scopeName) {
- $names[$scopeName] = true;
- }
- }
- }
- foreach ($names as $name => $_) {
- $this->context->bindVariableByName((string) $name, $promoted);
- }
- $this->markScopeVariableAssignedIfTracked($destOp, $promoted);
- if (null !== $slot) {
- foreach ($block->scopedOperands() as $scopeOp) {
- if ($block->slotForOperand($scopeOp) !== $slot) {
- continue;
- }
- $this->markScopeVariableAssignedIfTracked($scopeOp, $promoted);
- }
- }
- }
-
- /**
- * When php-cfg assigns through a named temporary with no downstream usages, the name slot
- * may still be skipped by assignOperand; fold from the matching TYPE_ASSIGN constant (#1226).
- */
- private function foldVarFetchNameFromAssign(Block $block, int $nameSlot, Variable $nameVar): void
- {
- if (null !== $nameVar->compileTimeString) {
- return;
- }
- if (isset($block->constants[$nameSlot])) {
- $nameVar->compileTimeString = $block->constants[$nameSlot]->toString();
-
- return;
- }
- foreach ($block->opCodes as $prior) {
- if (OpCode::TYPE_ASSIGN !== $prior->type) {
- continue;
- }
- if (!\in_array($prior->arg2, $this->jitNamedScopeSlotAliases($block, $nameSlot), true)) {
- continue;
- }
- if (!isset($block->constants[$prior->arg3])) {
- continue;
- }
- $nameVar->compileTimeString = $block->constants[$prior->arg3]->toString();
-
- return;
- }
- }
-
- private function varFetchDestUsedAsAssignLvalue(Block $block, int $opIndex, int $destSlot): bool
- {
- // Immediate next only — later ASSIGN is often dead-temp reuse, not a write (#23986).
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next) {
- return false;
- }
- if (!OpCode::destSlotUsedAsAssignLvalue($next, $destSlot)) {
- return false;
- }
- // php-cfg folds `($o->prop . '=')` into in-place CONCAT on the ?: echo phi slot.
- // That CONCAT writes the stack phi, not the property — a write-mode fetch empties
- // virtual DOM props (nodeName) and AOT prints "=" then after= is blank (#33849).
- if (
- OpCode::TYPE_CONCAT === $next->type
- && isset($this->context->coalesceMergeSlotOperands[$destSlot])
- ) {
- return false;
- }
-
- return true;
- }
-
- /**
- * True when fetch dest is the operand of an immediate TYPE_RETURN in a by-ref function
- * (`function &f(){ return C::$x; }` → ZEND_FETCH_STATIC_PROP_W, #34727).
- */
- private function varFetchDestUsedAsByRefReturn(Block $block, int $opIndex, int $destSlot): bool
- {
- if (!$this->cfgFunctionReturnsByRef($block->func)) {
- return false;
- }
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next || OpCode::TYPE_RETURN !== $next->type) {
- return false;
- }
-
- return (int) $next->arg1 === $destSlot;
- }
-
- /**
- * True when the fetch dest is the LHS of the immediately following TYPE_ASSIGN
- * (`$this->x = $rhs`). Skip the VALUE-slot load for those writes (#32349).
- */
- private function varFetchDestUsedAsPlainAssignStore(Block $block, int $opIndex, int $destSlot): bool
- {
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next || OpCode::TYPE_ASSIGN !== $next->type) {
- return false;
- }
-
- return OpCode::destSlotUsedAsAssignLvalue($next, $destSlot);
- }
-
- /** True when fetch dest is lhs of a following compound assign ($a[$k] += …, #31991). */
- private function varFetchDestUsedAsCompoundAssign(Block $block, int $opIndex, int $destSlot): bool
- {
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next) {
- return false;
- }
-
- return OpCode::destSlotUsedAsCompoundAssignRead($next, $destSlot)
- || OpCode::destSlotUsedAsInPlaceCompoundAssign($next, $destSlot);
- }
-
- /** True when fetch dest is lhs of a following compound read ($prop += …, #30077). */
- private function varFetchDestUsedAsCompoundAssignRead(Block $block, int $opIndex, int $destSlot): bool
- {
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next) {
- return false;
- }
-
- return OpCode::destSlotUsedAsCompoundAssignRead($next, $destSlot);
- }
-
- /**
- * True when the next meaningful use of the fetch dest is TYPE_ISSET (?? / isset).
- * Those are BP_VAR_IS and must not raise typed-uninit (#29688 / #33886).
- */
- private function propertyFetchResultUsedOnlyAsIsset(Block $block, int $opIndex, int $destSlot): bool
- {
- $ops = $block->opCodes;
- $n = \count($ops);
- for ($i = $opIndex + 1; $i < $n; ++$i) {
- $next = $ops[$i];
- if (OpCode::TYPE_ISSET === $next->type) {
- return (int) $next->arg2 === $destSlot || (int) $next->arg1 === $destSlot;
- }
- // Any other consumer of this slot (echo, assign, call, …) is BP_VAR_R.
- if (
- (int) $next->arg1 === $destSlot
- || (int) ($next->arg2 ?? -1) === $destSlot
- || (int) ($next->arg3 ?? -1) === $destSlot
- ) {
- return false;
- }
- }
-
- return false;
- }
-
- /**
- * True when fetch dest is the container for `$prop[]=` / `$prop[$k]=` / unset dim (#29748).
- */
- private function varFetchDestUsedAsDimWriteContainer(Block $block, int $opIndex, int $destSlot): bool
- {
- $ops = $block->opCodes;
- $n = \count($ops);
- for ($i = $opIndex + 1; $i < $n; ++$i) {
- $next = $ops[$i];
- if (OpCode::destSlotUsedAsDimWriteContainer($next, $destSlot)) {
- return true;
- }
- if (
- OpCode::TYPE_PROPERTY_FETCH === $next->type
- || OpCode::TYPE_PROPERTY_FETCH_WRITE === $next->type
- ) {
- if ((int) $next->arg1 === $destSlot) {
- return false;
- }
- continue;
- }
- if (
- OpCode::TYPE_ARRAY_DIM_FETCH === $next->type
- || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
- ) {
- continue;
- }
- if (OpCode::TYPE_UNSET === $next->type) {
- continue;
- }
-
- return false;
- }
-
- return false;
- }
-
- /**
- * True when fetch dest is the container of a later FETCH_DIM_W (`$a[i][j]` / #34745).
- *
- * @see php-src Zend/zend_execute.c ZEND_FETCH_DIM_W (nested dimension address)
- */
- private function varFetchDestUsedAsNestedDimWriteContainer(Block $block, int $opIndex, int $destSlot): bool
- {
- $ops = $block->opCodes;
- $n = \count($ops);
- for ($i = $opIndex + 1; $i < $n; ++$i) {
- $next = $ops[$i];
- if (
- OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
- && (int) $next->arg2 === $destSlot
- ) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Expected type for dimFetch: force TYPE_ARRAY on nested FETCH_DIM_W intermediates (#24011 / #34745)
- * and on FETCH_DIM_W prefixes that feed unset($a[i][k]) (#36380).
- *
- * CFG often leaves `$a[0]` as mixed when `$a` is a by-ref formal; without TYPE_ARRAY the outer
- * write returns a prepareIndexWrite orphan and the inner write/unset mutates a detached HT.
- *
- * php-src: Zend/zend_execute.c ZEND_FETCH_DIM_W (nested dimension address) + ZEND_UNSET_DIM.
- */
- private function dimFetchExpectedType(
- Block $block,
- int $opIndex,
- int $destSlot,
- ?\PHPTypes\Type $resultType,
- bool $forWrite
- ): ?\PHPTypes\Type {
- if (
- $forWrite
- && (
- $this->varFetchDestUsedAsNestedDimWriteContainer($block, $opIndex, $destSlot)
- || $this->varFetchDestUsedAsDimWriteContainer($block, $opIndex, $destSlot)
- )
- ) {
- return \PHPTypes\Type::fromDecl('array');
- }
-
- return $resultType;
- }
-
- /**
- * True when property fetch feeds dim RW (++/--/+=) — Zend BP_VAR_RW (#31784).
- */
- private function varFetchDestUsedAsDimRwContainer(Block $block, int $opIndex, int $destSlot): bool
- {
- $ops = $block->opCodes;
- $n = \count($ops);
- for ($i = $opIndex + 1; $i < $n; ++$i) {
- $next = $ops[$i];
- if (
- OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
- && (int) $next->arg2 === $destSlot
- ) {
- $dimSlot = (int) $next->arg1;
- for ($j = $i + 1; $j < $n; ++$j) {
- $consumer = $ops[$j];
- if (OpCode::dimSlotUsedAsRwOp($consumer, $dimSlot)) {
- return true;
- }
- if (
- OpCode::TYPE_ASSIGN === $consumer->type
- && (int) $consumer->arg2 === $dimSlot
- && (int) $consumer->arg3 !== $dimSlot
- ) {
- return false;
- }
- if ((int) $consumer->arg1 === $dimSlot) {
- if (
- OpCode::TYPE_PROPERTY_FETCH === $consumer->type
- || OpCode::TYPE_PROPERTY_FETCH_WRITE === $consumer->type
- || OpCode::TYPE_ARRAY_DIM_FETCH === $consumer->type
- || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $consumer->type
- ) {
- return false;
- }
- }
- }
-
- return false;
- }
- if (
- OpCode::TYPE_PROPERTY_FETCH === $next->type
- || OpCode::TYPE_PROPERTY_FETCH_WRITE === $next->type
- ) {
- if ((int) $next->arg1 === $destSlot) {
- return false;
- }
- continue;
- }
- if (
- OpCode::TYPE_ARRAY_DIM_FETCH === $next->type
- || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
- ) {
- continue;
- }
- if (OpCode::TYPE_UNSET === $next->type) {
- continue;
- }
-
- return false;
- }
-
- return false;
- }
-
- private function varFetchDestUsedAsIncDec(Block $block, int $opIndex, int $destSlot): bool
- {
- $next = $block->opCodes[$opIndex + 1] ?? null;
- if (null === $next) {
- return false;
- }
-
- return \in_array($next->type, [
- OpCode::TYPE_PRE_INC,
- OpCode::TYPE_POST_INC,
- OpCode::TYPE_PRE_DEC,
- OpCode::TYPE_POST_DEC,
- ], true) && $next->arg3 === $destSlot;
- }
-
/**
* Resolve the JIT variable for a scope slot (issue #1226).
*
diff --git a/lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php b/lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php
new file mode 100644
index 00000000000..dabcf4c98ec
--- /dev/null
+++ b/lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php
@@ -0,0 +1,1062 @@
+func) {
+ return;
+ }
+ $fnName = $block->func->name;
+ if ('{main}' === $fnName || str_ends_with($fnName, '::__destruct')) {
+ return;
+ }
+ $byRefParamNames = [];
+ foreach ($block->paramByRef as $paramIdx => $_) {
+ if (isset($block->paramNames[$paramIdx]) && '' !== $block->paramNames[$paramIdx]) {
+ $byRefParamNames[$block->paramNames[$paramIdx]] = true;
+ }
+ }
+ /** @var array $released */
+ $released = [];
+ /** @var array $localNames */
+ $localNames = [];
+ foreach ($this->jitFunctionNamedScopeSlots($block) as [$name, ,]) {
+ if ('this' !== $name && '' !== $name) {
+ $localNames[$name] = true;
+ }
+ }
+ foreach ($this->jitFunctionAssignTargets($block) as $destOp) {
+ $name = JIT\OperandName::resolve($destOp);
+ if (null !== $name && '' !== $name) {
+ $localNames[$name] = true;
+ }
+ }
+ foreach ($block->orig->deadOperands ?? [] as $deadOp) {
+ $name = JIT\OperandName::resolve($deadOp);
+ if (null !== $name && '' !== $name) {
+ $localNames[$name] = true;
+ }
+ }
+ foreach (array_keys($localNames) as $name) {
+ if (isset($released[$name])) {
+ continue;
+ }
+ $resolved = $this->context->resolveRefAliasName($name);
+ $var = $this->context->namedVariableBindings[$resolved] ?? null;
+ if (null === $var) {
+ continue;
+ }
+ $this->releaseJitCanonicalNamedLocalAtReturn(
+ $name,
+ $var,
+ $byRefParamNames,
+ $released
+ );
+ }
+ }
+
+ /**
+ * @param array $byRefParamNames
+ * @param array $released
+ */
+ private function releaseJitCanonicalNamedLocalAtReturn(
+ string $name,
+ Variable $var,
+ array $byRefParamNames,
+ array &$released
+ ): void {
+ if ('this' === $name || isset($released[$name])) {
+ return;
+ }
+ if (isset($byRefParamNames[$name])) {
+ return;
+ }
+ if (Variable::KIND_VARIABLE !== $var->kind) {
+ return;
+ }
+ if ($var->borrowedValueEntry || null !== $var->valueBoxAliasPtr) {
+ return;
+ }
+ if (Variable::TYPE_VALUE === $var->type) {
+ $this->jitWriteNullForUnset(JIT\JitValueBox::valuePtrFromVariable($this->context, $var));
+ $released[$name] = true;
+
+ return;
+ }
+ // Native packed arrays (e.g. `string[1]`) still have IS_REFCOUNTED on the
+ // element type. loadValue+delref would bitcast the array aggregate
+ // (`[1 x %__string__*]`) to `__ref__virtual*` / i8* and fail module verify
+ // (#36382 Slim/nyholm; php-src zend_array_destroy walks buckets).
+ if (0 !== ($var->type & Variable::IS_NATIVE_ARRAY)) {
+ $var->free();
+ $released[$name] = true;
+
+ return;
+ }
+ if ($var->type & Variable::IS_REFCOUNTED) {
+ if (null !== $var->objectPropertySlot) {
+ return;
+ }
+ $ptr = Variable::KIND_VALUE === $var->kind
+ ? $var->value
+ : $this->context->helper->loadValue($var);
+ if ($this->context->type->object->hasUserDestructors()) {
+ \PHPCompiler\JIT\Builtin\GcCollectCyclesRuntime::ensureLinked($this->context);
+ $this->context->builder->call(
+ $this->context->lookupFunction('phpc_destruct_try_invoke'),
+ $this->context->builder->pointerCast(
+ $ptr,
+ $this->context->getTypeFromString('int8*')
+ )
+ );
+ }
+ JIT\Builtin\WeakRefRuntime::ensureLinked($this->context);
+ $this->context->builder->call(
+ $this->context->lookupFunction('phpc_weakref_clear_object'),
+ $this->context->builder->pointerCast(
+ $ptr,
+ $this->context->getTypeFromString('int8*')
+ )
+ );
+ $this->context->refcount->delref($ptr);
+ if (Variable::KIND_VARIABLE === $var->kind && null !== $var->value) {
+ $slotTy = $var->value->typeOf();
+ if (\PHPLLVM\Type::KIND_POINTER === $slotTy->getKind()) {
+ $this->context->builder->store(
+ $slotTy->getElementType()->constNull(),
+ $var->value
+ );
+ }
+ }
+ $released[$name] = true;
+ }
+ }
+
+ /**
+ * @param array $byRefParamNames
+ * @param array $released
+ */
+ private function releaseJitNamedLocalAtReturn(
+ Block $returnBlock,
+ string $name,
+ int $slotIdx,
+ Block $scopeBlock,
+ array $byRefParamNames,
+ array &$released
+ ): void {
+ if ('this' === $name || isset($released[$name])) {
+ return;
+ }
+ if (isset($byRefParamNames[$name])) {
+ return;
+ }
+ $resolved = $this->context->resolveRefAliasName($name);
+ $var = $this->context->namedVariableBindings[$resolved] ?? null;
+ if (null !== $var) {
+ $this->releaseJitCanonicalNamedLocalAtReturn($name, $var, $byRefParamNames, $released);
+
+ return;
+ }
+ if ($slotIdx < 0) {
+ return;
+ }
+ $scopedOp = $scopeBlock->operandForScopeSlot($slotIdx);
+ if (null === $scopedOp) {
+ return;
+ }
+ try {
+ $var = $this->context->getVariableFromOp($scopedOp);
+ } catch (\LogicException) {
+ return;
+ }
+ $this->releaseJitCanonicalNamedLocalAtReturn($name, $var, $byRefParamNames, $released);
+ }
+
+ /**
+ * @return list<\PHPCfg\Operand>
+ */
+ private function jitFunctionAssignTargets(Block $returnBlock): array
+ {
+ /** @var list<\PHPCfg\Operand> $targets */
+ $targets = [];
+ $seen = new \SplObjectStorage();
+ foreach ($this->jitFunctionNamedScopeSlots($returnBlock) as [, , $scopeBlock]) {
+ foreach ($this->listUnpackAssignTargetsInBlock($scopeBlock) as $dest) {
+ if ($seen->contains($dest)) {
+ continue;
+ }
+ $seen[$dest] = true;
+ $targets[] = $dest;
+ }
+ }
+
+ return $targets;
+ }
+
+ /**
+ * All named CV slots in the returning function — return-block scope alone omits
+ * live-at-return locals php-cfg already marked dead (#36245 make_pair).
+ *
+ * @return \Generator
+ */
+ private function jitFunctionNamedScopeSlots(Block $returnBlock): \Generator
+ {
+ $root = $this->context->jitFunctionRootBlock ?? $returnBlock;
+ /** @var array $seenBlocks */
+ $seenBlocks = [];
+ /** @var list $queue */
+ $queue = [$root];
+ while ([] !== $queue) {
+ $scan = array_shift($queue);
+ $blockId = spl_object_id($scan);
+ if (isset($seenBlocks[$blockId])) {
+ continue;
+ }
+ $seenBlocks[$blockId] = true;
+ foreach ($scan->eachNamedScopeSlot() as [$name, $slotIdx]) {
+ yield [$name, $slotIdx, $scan];
+ }
+ foreach ($scan->opCodes as $op) {
+ foreach ([$op->block1 ?? null, $op->block2 ?? null, $op->block3 ?? null] as $target) {
+ if ($target instanceof Block && !isset($seenBlocks[spl_object_id($target)])) {
+ $queue[] = $target;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * unset($var) on boxed locals: run __destruct before nulling when {main} defers delref destroy (#4096).
+ * Also clear WeakMap/WeakReference immediately — {main} may defer __ref__delref free (#27621 / #26795).
+ */
+ private function jitWriteNullForUnset(\PHPLLVM\Value $valueBoxPtr): void
+ {
+ $map = $this->context->structFieldMap['__value__'];
+ $i8 = $this->context->getTypeFromString('int8');
+ $i8p = $this->context->getTypeFromString('int8*');
+ $typeByte = $this->context->builder->load(
+ $this->context->builder->structGep($valueBoxPtr, $map['type'])
+ );
+ $isObject = $this->context->builder->icmp(
+ \PHPLLVM\Builder::INT_EQ,
+ $typeByte,
+ $i8->constInt(Variable::TYPE_OBJECT, false)
+ );
+ $objBlock = JIT\BasicBlockHelper::append($this->context, 'unset_object_side');
+ $doneBlock = JIT\BasicBlockHelper::append($this->context, 'unset_object_done');
+ $this->context->builder->branchIf($isObject, $objBlock, $doneBlock);
+ $this->context->builder->positionAtEnd($objBlock);
+ $obj = $this->context->builder->call(
+ $this->context->lookupFunction('__value__readObject'),
+ $valueBoxPtr
+ );
+ $objI8 = $this->context->builder->pointerCast($obj, $i8p);
+ if ($this->context->type->object->hasUserDestructors()) {
+ \PHPCompiler\JIT\Builtin\GcCollectCyclesRuntime::ensureLinked($this->context);
+ $this->context->builder->call(
+ $this->context->lookupFunction('phpc_destruct_try_invoke'),
+ $objI8
+ );
+ }
+ // WeakMap keys must drop before count() even when delref destroy is deferred (#27621).
+ // Save insert point — WeakRefRuntime::ensureLinked clears the builder (#27621).
+ $insertBefore = $this->context->builder->getInsertBlock();
+ JIT\Builtin\WeakRefRuntime::ensureLinked($this->context);
+ if (null !== $insertBefore) {
+ $this->context->builder->positionAtEnd($insertBefore);
+ }
+ $this->context->builder->call(
+ $this->context->lookupFunction('phpc_weakref_clear_object'),
+ $objI8
+ );
+ // Zend decrements refcount on unset — valueDelref alone leaves extra GC roots (#36245).
+ $this->context->refcount->delref($obj);
+ $this->context->builder->branch($doneBlock);
+ $this->context->builder->positionAtEnd($doneBlock);
+ $this->jitNoteMemoryReleaseForUnset($valueBoxPtr);
+ $this->context->builder->call(
+ $this->context->lookupFunction('__value__writeNull'),
+ $valueBoxPtr
+ );
+ }
+
+ /**
+ * Named-storage `$a = new T` keeps the object in the NEW/ASSIGN result
+ * {@see __object__**} alloca as well as the CV value-box. Unset (and null
+ * assign) must null those mirrors without delref — otherwise the next
+ * loop-body NEW freeObjectMirrorUnlessNull double-delrefs the orphan and
+ * GC sees roots=0 (#36245 loop_unset). Distinct Operand instances share a
+ * CFG slot, so clear via getOperand (assign's operand) not only
+ * operandForScopeSlot (prologue).
+ */
+ private function jitClearAssignResultObjectMirrorForNamedUnset(Block $block, ?int $unsetArgSlot): void
+ {
+ if (null === $unsetArgSlot) {
+ return;
+ }
+ $targetSlot = (int) $unsetArgSlot;
+ $seen = new \SplObjectStorage();
+ foreach ($block->opCodes as $assignOp) {
+ if (OpCode::TYPE_ASSIGN !== $assignOp->type || null === $assignOp->arg2) {
+ continue;
+ }
+ if ((int) $assignOp->arg2 !== $targetSlot) {
+ continue;
+ }
+ // Property/dim assigns use arg1 === arg2; still clear RHS object mirrors.
+ $slots = [];
+ if (null !== $assignOp->arg1 && $assignOp->arg1 !== $assignOp->arg2) {
+ $slots[] = (int) $assignOp->arg1;
+ }
+ try {
+ $rhs = $this->assignRhsSlot($assignOp);
+ if ($rhs !== $targetSlot) {
+ $slots[] = $rhs;
+ }
+ } catch (\LogicException $e) {
+ // Missing RHS slot — named unset still clears assign-result mirrors.
+ }
+ foreach ($slots as $slot) {
+ $this->jitNullObjectMirrorForScopeSlot($block, $slot, $seen);
+ }
+ }
+ }
+
+ /**
+ * Null every {@see __object__**} alloca bound to $slot (map + all Operand aliases).
+ *
+ * @param \SplObjectStorage<\PHPLLVM\Value, mixed> $seen
+ */
+ private function jitNullObjectMirrorForScopeSlot(Block $block, int $slot, \SplObjectStorage $seen): void
+ {
+ $nullObj = $this->context->getTypeFromString('__object__*')->constNull();
+ if (isset($this->context->scopeSlotObjectMirrorLlvmBySlot[$slot])) {
+ $llvmMirror = $this->context->scopeSlotObjectMirrorLlvmBySlot[$slot];
+ if (!$seen->contains($llvmMirror)) {
+ $seen[$llvmMirror] = true;
+ $this->context->builder->store($nullObj, $llvmMirror);
+ }
+ }
+ $operands = [];
+ $scoped = $block->operandForScopeSlot($slot);
+ if (null !== $scoped) {
+ $operands[] = $scoped;
+ }
+ // Prefer the exact Operand getOperand returns — assign/NEW lower against it (#36245).
+ $fromOpcode = $block->getOperand($slot);
+ if (null !== $fromOpcode) {
+ $operands[] = $fromOpcode;
+ }
+ foreach ($block->scopedOperands() as $scopedOp) {
+ if ($block->slotForOperand($scopedOp) === $slot) {
+ $operands[] = $scopedOp;
+ }
+ }
+ foreach ($operands as $op) {
+ if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
+ continue;
+ }
+ $mirror = $this->context->hasVariableOp($op)
+ ? $this->context->getVariableFromOp($op)
+ : $this->context->scope->variables[$op];
+ if (
+ Variable::TYPE_OBJECT !== $mirror->type
+ || Variable::KIND_VARIABLE !== $mirror->kind
+ || null !== $mirror->objectPropertySlot
+ || $mirror->functionStaticGlobal
+ ) {
+ continue;
+ }
+ if (!str_contains($this->context->getStringFromType($mirror->value->typeOf()), '__object__')) {
+ continue;
+ }
+ if ($seen->contains($mirror->value)) {
+ continue;
+ }
+ $seen[$mirror->value] = true;
+ $this->context->builder->store($nullObj, $mirror->value);
+ $this->context->scopeSlotObjectMirrorLlvmBySlot[$slot] = $mirror->value;
+ }
+ }
+
+ /** Delref an {@see __object__**} mirror only when it still holds a non-null pointer (#36245). */
+ private function freeObjectMirrorUnlessNull(Variable $mirror): void
+ {
+ $nullObj = $this->context->getTypeFromString('__object__*')->constNull();
+ $loaded = $this->context->builder->load($mirror->value);
+ $hasObj = $this->context->builder->icmp(
+ \PHPLLVM\Builder::INT_NE,
+ $loaded,
+ $nullObj
+ );
+ $delrefBlock = JIT\BasicBlockHelper::append($this->context, 'obj_mirror_delref');
+ $skipBlock = JIT\BasicBlockHelper::append($this->context, 'obj_mirror_skip');
+ $this->context->builder->branchIf($hasObj, $delrefBlock, $skipBlock);
+ $this->context->builder->positionAtEnd($delrefBlock);
+ $this->context->refcount->delref($loaded);
+ $this->context->builder->branch($skipBlock);
+ $this->context->builder->positionAtEnd($skipBlock);
+ // Always clear — unset may have nulled without delref; next NEW must not
+ // load a stale pointer (#36245 / Variable::free peer).
+ $this->context->builder->store($nullObj, $mirror->value);
+ }
+
+ /**
+ * After === / !==, drop anonymous Temporary value boxes (call results). Named
+ * locals stay; freeDeadVariables at block edges is too late for unset (#27118).
+ */
+ private function jitReleaseTempValueBoxAfterCompare(Block $block, Operand $op): void
+ {
+ $this->context->aliasVariableOpFromSlot($block, $op);
+ if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
+ $slot = $block->slotForOperand($op);
+ if (null === $slot) {
+ return;
+ }
+ $scoped = $block->operandForScopeSlot($slot);
+ if (null === $scoped) {
+ return;
+ }
+ $op = $scoped;
+ }
+ if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
+ return;
+ }
+ $var = $this->context->hasVariableOp($op)
+ ? $this->context->getVariableFromOp($op)
+ : $this->context->scope->variables[$op];
+ $name = JIT\OperandName::resolve($op);
+ if (null !== $name && '' !== $name) {
+ // Named locals/params must survive identical/not-identical (#31101 MiniWebApp
+ // $route after !== "api/status"). Only anonymous temps are statement-end released
+ // for WeakReference::get (#27118).
+ return;
+ }
+ if (
+ Variable::TYPE_VALUE !== $var->type
+ || $var->functionStaticGlobal
+ || $var->borrowedValueEntry
+ || null !== $var->superglobalName
+ || null !== $var->valueBoxAliasPtr
+ ) {
+ return;
+ }
+ if (
+ Variable::KIND_VARIABLE !== $var->kind
+ && Variable::KIND_VALUE !== $var->kind
+ ) {
+ return;
+ }
+ $this->jitWriteNullForUnset(
+ JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
+ );
+ if ($this->context->scope->variables->contains($op)) {
+ $this->context->scope->variables->detach($op);
+ }
+ }
+
+ private function jitReleasePendingWeakReferenceGetResult(): void
+ {
+ $op = $this->context->pendingWeakReferenceGetResult;
+ $this->context->pendingWeakReferenceGetResult = null;
+ if (null === $op) {
+ return;
+ }
+ if (!$this->context->hasVariableOp($op) && !$this->context->scope->variables->contains($op)) {
+ return;
+ }
+ $var = $this->context->hasVariableOp($op)
+ ? $this->context->getVariableFromOp($op)
+ : $this->context->scope->variables[$op];
+ if (
+ Variable::TYPE_VALUE !== $var->type
+ || $var->functionStaticGlobal
+ || $var->borrowedValueEntry
+ ) {
+ return;
+ }
+ $this->jitWriteNullForUnset(
+ JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
+ );
+ if ($this->context->scope->variables->contains($op)) {
+ $this->context->scope->variables->detach($op);
+ }
+ }
+
+ /**
+ * VM releaseVmJumpIfCondTemps (#14103) for JIT: drop anonymous TYPE_VALUE boxes
+ * that die in this block before branching so WeakReference::get() results do not
+ * keep referents across unset in a ternary-echo merge block (#27118).
+ *
+ * Only considers {@see Block::$orig} deadOperands for this block — after successor
+ * arms are compiled, scope also holds merge-block bindings that must not be freed here.
+ */
+ private function jitReleaseJumpIfAnonValueBoxes(Block $block, OpCode $jumpIf): void
+ {
+ $keepOps = new \SplObjectStorage();
+ if (null !== $jumpIf->arg1) {
+ $condOp = $this->operandAt($block, $jumpIf->arg1, 'branch condition');
+ $keepOps[$condOp] = true;
+ }
+ foreach ($this->context->coalesceAssignTargets as $mergeOp) {
+ $keepOps[$mergeOp] = true;
+ }
+ $toFree = [];
+ $seen = new \SplObjectStorage();
+ foreach ($block->orig->deadOperands as $deadOp) {
+ $candidates = [$deadOp];
+ $slot = $block->slotForOperand($deadOp);
+ if (null !== $slot) {
+ $scoped = $block->operandForScopeSlot($slot);
+ if (null !== $scoped) {
+ $candidates[] = $scoped;
+ }
+ }
+ foreach ($candidates as $op) {
+ if ($seen->contains($op)) {
+ continue;
+ }
+ $seen[$op] = true;
+ if ($keepOps->contains($op)) {
+ continue;
+ }
+ $name = JIT\OperandName::resolve($op);
+ // Named CVs (params / locals) are never "anon" temps — nulling them at a
+ // JUMPIF edge clears live values still read in ternary/if arms (#27624:
+ // DNF `__value__*` param `$x` + `is_array($x) ? count($x) : …`).
+ if (null !== $name && '' !== $name) {
+ continue;
+ }
+ if (!$this->context->scope->variables->contains($op) && !$this->context->hasVariableOp($op)) {
+ continue;
+ }
+ $var = $this->context->hasVariableOp($op)
+ ? $this->context->getVariableFromOp($op)
+ : $this->context->scope->variables[$op];
+ // Only owned KIND_VARIABLE allocas. KIND_VALUE often aliases a live CV /
+ // caller `__value__*` (DNF/mixed params); writeNull would clear storage
+ // still read in ternary arms (#27624).
+ if (
+ Variable::TYPE_VALUE !== $var->type
+ || Variable::KIND_VARIABLE !== $var->kind
+ || $var->functionStaticGlobal
+ || $var->borrowedValueEntry
+ || null !== $var->superglobalName
+ || null !== $var->valueBoxAliasPtr
+ ) {
+ continue;
+ }
+ $toFree[] = $op;
+ }
+ }
+ foreach ($toFree as $op) {
+ $var = $this->context->hasVariableOp($op)
+ ? $this->context->getVariableFromOp($op)
+ : $this->context->scope->variables[$op];
+ $this->jitWriteNullForUnset(
+ JIT\JitValueBox::valuePtrFromVariable($this->context, $var)
+ );
+ if ($this->context->scope->variables->contains($op)) {
+ $this->context->scope->variables->detach($op);
+ }
+ }
+ }
+
+ /** Zend emalloc parity: drop tracked bytes when unset frees a string (#7310). */
+ private function jitNoteMemoryReleaseForUnset(\PHPLLVM\Value $valueBoxPtr): void
+ {
+ JIT\Builtin\MemoryRuntime::ensureLinked($this->context);
+ $map = $this->context->structFieldMap['__value__'];
+ $stringMap = $this->context->structFieldMap['__string__'];
+ $i8 = $this->context->getTypeFromString('int8');
+ $i64 = $this->context->getTypeFromString('int64');
+ $zero = $i64->constInt(0, false);
+ $typeByte = $this->context->builder->load(
+ $this->context->builder->structGep($valueBoxPtr, $map['type'])
+ );
+ $isString = $this->context->builder->icmp(
+ \PHPLLVM\Builder::INT_EQ,
+ $typeByte,
+ $i8->constInt(Variable::TYPE_STRING, false)
+ );
+ $doneBlock = JIT\BasicBlockHelper::append($this->context, 'unset_mem_done');
+ $stringBlock = JIT\BasicBlockHelper::append($this->context, 'unset_mem_string');
+ $this->context->builder->branchIf($isString, $stringBlock, $doneBlock);
+ $this->context->builder->positionAtEnd($stringBlock);
+ $strPtr = $this->context->builder->call(
+ $this->context->lookupFunction('__value__readString'),
+ $valueBoxPtr
+ );
+ $len = $this->context->builder->load(
+ $this->context->builder->structGep($strPtr, $stringMap['length'])
+ );
+ $negLen = $this->context->builder->sub($zero, $len);
+ JIT\Builtin\MemoryRuntime::noteAlloc($this->context, $negLen);
+ $this->context->builder->branch($doneBlock);
+ $this->context->builder->positionAtEnd($doneBlock);
+ }
+
+ /** Drop assign RHS / result temps so block-end dead-operand free cannot re-delref (#4096). */
+ private function jitClearAssignTempOperand(Operand $op): void
+ {
+ $this->jitWriteNullOperand($op);
+ if ($this->context->scope->variables->contains($op)) {
+ $this->context->scope->variables->detach($op);
+ }
+ }
+
+ /** Mirror VM assign: clear dead assign-result / RHS temps (#4096). */
+ private function jitWriteNullOperand(Operand $op): void
+ {
+ if (!$this->context->hasVariableOp($op)) {
+ return;
+ }
+ $var = $this->context->getVariableFromOp($op);
+ if (Variable::KIND_VARIABLE === $var->kind && Variable::TYPE_VALUE === $var->type) {
+ $this->context->builder->call(
+ $this->context->lookupFunction('__value__writeNull'),
+ $var->value
+ );
+
+ return;
+ }
+ if (
+ Variable::TYPE_OBJECT === $var->type
+ && Variable::KIND_VARIABLE === $var->kind
+ && null !== $var->value
+ && \in_array($var->value, $this->context->scopeSlotObjectMirrorLlvmBySlot, true)
+ ) {
+ $isCanonicalCv = false;
+ foreach ($this->context->namedVariableBindings as $bound) {
+ if ($bound === $var) {
+ $isCanonicalCv = true;
+ break;
+ }
+ }
+ if (!$isCanonicalCv) {
+ $slotTy = $var->value->typeOf();
+ if (\PHPLLVM\Type::KIND_POINTER === $slotTy->getKind()) {
+ $this->context->builder->store(
+ $slotTy->getElementType()->constNull(),
+ $var->value
+ );
+ }
+
+ return;
+ }
+ }
+ $var->free();
+ }
+
+ /**
+ * php-cfg ASSIGN(resultTemp, namedAlias, rhs) — mark the named CV, not only the dead temp (#36405).
+ */
+ private function propagateAssignAliasBinding(
+ ?Operand $destOp,
+ Operand $aliasOp,
+ bool $namedAliasReceivedAssign
+ ): void {
+ $aliasName = JIT\OperandName::resolve($aliasOp);
+ if (null === $aliasName || '' === $aliasName) {
+ return;
+ }
+ if ($namedAliasReceivedAssign) {
+ if ($this->context->hasVariableOp($aliasOp)) {
+ JIT\UndefinedVariableHelper::markAssigned(
+ $this->context,
+ $aliasOp,
+ $this->context->getVariableFromOp($aliasOp)
+ );
+ }
+
+ return;
+ }
+ if (null !== $destOp && $this->context->hasVariableOp($destOp)) {
+ $destVar = $this->context->getVariableFromOp($destOp);
+ $this->context->setVariableOp($aliasOp, $destVar);
+ $this->context->bindVariableByName(
+ $this->context->resolveRefAliasName($aliasName),
+ $destVar
+ );
+ JIT\UndefinedVariableHelper::markAssigned($this->context, $aliasOp, $destVar);
+ }
+ }
+
+ private function maybeBindNamedVariable(Operand $op): void
+ {
+ if (!$this->context->hasVariableOp($op)) {
+ return;
+ }
+ $name = JIT\OperandName::resolve($op);
+ if (null === $name || '' === $name) {
+ return;
+ }
+ $var = $this->context->getVariableFromOp($op);
+ $this->context->bindVariableByName($name, $var);
+ // TYPE_ASSIGN dest is a defined CV for later ZEND_CHECK_UNDEFINED_VAR (#32041).
+ JIT\UndefinedVariableHelper::markAssigned($this->context, $op, $var);
+ }
+
+ /**
+ * After KIND_VALUE→alloca CONCAT promotion, rebind every Operand for the dest
+ * scope slot (named local + unnamed Temporary) so in-place `$out .=` loads and
+ * stores the same alloca across loop iterations (#22845).
+ */
+ private function bindPromotedStringConcatDest(Block $block, Operand $destOp, Variable $promoted): void
+ {
+ $names = [];
+ $destName = JIT\OperandName::resolve($destOp);
+ if (null !== $destName && '' !== $destName) {
+ $names[$destName] = true;
+ }
+ $slot = $block->slotForOperand($destOp);
+ if (null !== $slot) {
+ foreach ($block->scopedOperands() as $scopeOp) {
+ if ($block->slotForOperand($scopeOp) !== $slot) {
+ continue;
+ }
+ $this->context->setVariableOp($scopeOp, $promoted);
+ $scopeName = JIT\OperandName::resolve($scopeOp);
+ if (null !== $scopeName && '' !== $scopeName) {
+ $names[$scopeName] = true;
+ }
+ }
+ }
+ foreach ($names as $name => $_) {
+ $this->context->bindVariableByName((string) $name, $promoted);
+ }
+ $this->markScopeVariableAssignedIfTracked($destOp, $promoted);
+ if (null !== $slot) {
+ foreach ($block->scopedOperands() as $scopeOp) {
+ if ($block->slotForOperand($scopeOp) !== $slot) {
+ continue;
+ }
+ $this->markScopeVariableAssignedIfTracked($scopeOp, $promoted);
+ }
+ }
+ }
+
+ /**
+ * When php-cfg assigns through a named temporary with no downstream usages, the name slot
+ * may still be skipped by assignOperand; fold from the matching TYPE_ASSIGN constant (#1226).
+ */
+ private function foldVarFetchNameFromAssign(Block $block, int $nameSlot, Variable $nameVar): void
+ {
+ if (null !== $nameVar->compileTimeString) {
+ return;
+ }
+ if (isset($block->constants[$nameSlot])) {
+ $nameVar->compileTimeString = $block->constants[$nameSlot]->toString();
+
+ return;
+ }
+ foreach ($block->opCodes as $prior) {
+ if (OpCode::TYPE_ASSIGN !== $prior->type) {
+ continue;
+ }
+ if (!\in_array($prior->arg2, $this->jitNamedScopeSlotAliases($block, $nameSlot), true)) {
+ continue;
+ }
+ if (!isset($block->constants[$prior->arg3])) {
+ continue;
+ }
+ $nameVar->compileTimeString = $block->constants[$prior->arg3]->toString();
+
+ return;
+ }
+ }
+
+ private function varFetchDestUsedAsAssignLvalue(Block $block, int $opIndex, int $destSlot): bool
+ {
+ // Immediate next only — later ASSIGN is often dead-temp reuse, not a write (#23986).
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next) {
+ return false;
+ }
+ if (!OpCode::destSlotUsedAsAssignLvalue($next, $destSlot)) {
+ return false;
+ }
+ // php-cfg folds `($o->prop . '=')` into in-place CONCAT on the ?: echo phi slot.
+ // That CONCAT writes the stack phi, not the property — a write-mode fetch empties
+ // virtual DOM props (nodeName) and AOT prints "=" then after= is blank (#33849).
+ if (
+ OpCode::TYPE_CONCAT === $next->type
+ && isset($this->context->coalesceMergeSlotOperands[$destSlot])
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * True when fetch dest is the operand of an immediate TYPE_RETURN in a by-ref function
+ * (`function &f(){ return C::$x; }` → ZEND_FETCH_STATIC_PROP_W, #34727).
+ */
+ private function varFetchDestUsedAsByRefReturn(Block $block, int $opIndex, int $destSlot): bool
+ {
+ if (!$this->cfgFunctionReturnsByRef($block->func)) {
+ return false;
+ }
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next || OpCode::TYPE_RETURN !== $next->type) {
+ return false;
+ }
+
+ return (int) $next->arg1 === $destSlot;
+ }
+
+ /**
+ * True when the fetch dest is the LHS of the immediately following TYPE_ASSIGN
+ * (`$this->x = $rhs`). Skip the VALUE-slot load for those writes (#32349).
+ */
+ private function varFetchDestUsedAsPlainAssignStore(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next || OpCode::TYPE_ASSIGN !== $next->type) {
+ return false;
+ }
+
+ return OpCode::destSlotUsedAsAssignLvalue($next, $destSlot);
+ }
+
+ /** True when fetch dest is lhs of a following compound assign ($a[$k] += …, #31991). */
+ private function varFetchDestUsedAsCompoundAssign(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next) {
+ return false;
+ }
+
+ return OpCode::destSlotUsedAsCompoundAssignRead($next, $destSlot)
+ || OpCode::destSlotUsedAsInPlaceCompoundAssign($next, $destSlot);
+ }
+
+ /** True when fetch dest is lhs of a following compound read ($prop += …, #30077). */
+ private function varFetchDestUsedAsCompoundAssignRead(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next) {
+ return false;
+ }
+
+ return OpCode::destSlotUsedAsCompoundAssignRead($next, $destSlot);
+ }
+
+ /**
+ * True when the next meaningful use of the fetch dest is TYPE_ISSET (?? / isset).
+ * Those are BP_VAR_IS and must not raise typed-uninit (#29688 / #33886).
+ */
+ private function propertyFetchResultUsedOnlyAsIsset(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $ops = $block->opCodes;
+ $n = \count($ops);
+ for ($i = $opIndex + 1; $i < $n; ++$i) {
+ $next = $ops[$i];
+ if (OpCode::TYPE_ISSET === $next->type) {
+ return (int) $next->arg2 === $destSlot || (int) $next->arg1 === $destSlot;
+ }
+ // Any other consumer of this slot (echo, assign, call, …) is BP_VAR_R.
+ if (
+ (int) $next->arg1 === $destSlot
+ || (int) ($next->arg2 ?? -1) === $destSlot
+ || (int) ($next->arg3 ?? -1) === $destSlot
+ ) {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * True when fetch dest is the container for `$prop[]=` / `$prop[$k]=` / unset dim (#29748).
+ */
+ private function varFetchDestUsedAsDimWriteContainer(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $ops = $block->opCodes;
+ $n = \count($ops);
+ for ($i = $opIndex + 1; $i < $n; ++$i) {
+ $next = $ops[$i];
+ if (OpCode::destSlotUsedAsDimWriteContainer($next, $destSlot)) {
+ return true;
+ }
+ if (
+ OpCode::TYPE_PROPERTY_FETCH === $next->type
+ || OpCode::TYPE_PROPERTY_FETCH_WRITE === $next->type
+ ) {
+ if ((int) $next->arg1 === $destSlot) {
+ return false;
+ }
+ continue;
+ }
+ if (
+ OpCode::TYPE_ARRAY_DIM_FETCH === $next->type
+ || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
+ ) {
+ continue;
+ }
+ if (OpCode::TYPE_UNSET === $next->type) {
+ continue;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ /**
+ * True when fetch dest is the container of a later FETCH_DIM_W (`$a[i][j]` / #34745).
+ *
+ * @see php-src Zend/zend_execute.c ZEND_FETCH_DIM_W (nested dimension address)
+ */
+ private function varFetchDestUsedAsNestedDimWriteContainer(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $ops = $block->opCodes;
+ $n = \count($ops);
+ for ($i = $opIndex + 1; $i < $n; ++$i) {
+ $next = $ops[$i];
+ if (
+ OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
+ && (int) $next->arg2 === $destSlot
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Expected type for dimFetch: force TYPE_ARRAY on nested FETCH_DIM_W intermediates (#24011 / #34745)
+ * and on FETCH_DIM_W prefixes that feed unset($a[i][k]) (#36380).
+ *
+ * CFG often leaves `$a[0]` as mixed when `$a` is a by-ref formal; without TYPE_ARRAY the outer
+ * write returns a prepareIndexWrite orphan and the inner write/unset mutates a detached HT.
+ *
+ * php-src: Zend/zend_execute.c ZEND_FETCH_DIM_W (nested dimension address) + ZEND_UNSET_DIM.
+ */
+ private function dimFetchExpectedType(
+ Block $block,
+ int $opIndex,
+ int $destSlot,
+ ?\PHPTypes\Type $resultType,
+ bool $forWrite
+ ): ?\PHPTypes\Type {
+ if (
+ $forWrite
+ && (
+ $this->varFetchDestUsedAsNestedDimWriteContainer($block, $opIndex, $destSlot)
+ || $this->varFetchDestUsedAsDimWriteContainer($block, $opIndex, $destSlot)
+ )
+ ) {
+ return \PHPTypes\Type::fromDecl('array');
+ }
+
+ return $resultType;
+ }
+
+ /**
+ * True when property fetch feeds dim RW (++/--/+=) — Zend BP_VAR_RW (#31784).
+ */
+ private function varFetchDestUsedAsDimRwContainer(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $ops = $block->opCodes;
+ $n = \count($ops);
+ for ($i = $opIndex + 1; $i < $n; ++$i) {
+ $next = $ops[$i];
+ if (
+ OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
+ && (int) $next->arg2 === $destSlot
+ ) {
+ $dimSlot = (int) $next->arg1;
+ for ($j = $i + 1; $j < $n; ++$j) {
+ $consumer = $ops[$j];
+ if (OpCode::dimSlotUsedAsRwOp($consumer, $dimSlot)) {
+ return true;
+ }
+ if (
+ OpCode::TYPE_ASSIGN === $consumer->type
+ && (int) $consumer->arg2 === $dimSlot
+ && (int) $consumer->arg3 !== $dimSlot
+ ) {
+ return false;
+ }
+ if ((int) $consumer->arg1 === $dimSlot) {
+ if (
+ OpCode::TYPE_PROPERTY_FETCH === $consumer->type
+ || OpCode::TYPE_PROPERTY_FETCH_WRITE === $consumer->type
+ || OpCode::TYPE_ARRAY_DIM_FETCH === $consumer->type
+ || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $consumer->type
+ ) {
+ return false;
+ }
+ }
+ }
+
+ return false;
+ }
+ if (
+ OpCode::TYPE_PROPERTY_FETCH === $next->type
+ || OpCode::TYPE_PROPERTY_FETCH_WRITE === $next->type
+ ) {
+ if ((int) $next->arg1 === $destSlot) {
+ return false;
+ }
+ continue;
+ }
+ if (
+ OpCode::TYPE_ARRAY_DIM_FETCH === $next->type
+ || OpCode::TYPE_ARRAY_DIM_FETCH_WRITE === $next->type
+ ) {
+ continue;
+ }
+ if (OpCode::TYPE_UNSET === $next->type) {
+ continue;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private function varFetchDestUsedAsIncDec(Block $block, int $opIndex, int $destSlot): bool
+ {
+ $next = $block->opCodes[$opIndex + 1] ?? null;
+ if (null === $next) {
+ return false;
+ }
+
+ return \in_array($next->type, [
+ OpCode::TYPE_PRE_INC,
+ OpCode::TYPE_POST_INC,
+ OpCode::TYPE_PRE_DEC,
+ OpCode::TYPE_POST_DEC,
+ ], true) && $next->arg3 === $destSlot;
+ }
+}
diff --git a/lib/JIT/SpineChunkRuntimeMethodDemote.php b/lib/JIT/SpineChunkRuntimeMethodDemote.php
index f7487a1edd8..dd3a026687f 100644
--- a/lib/JIT/SpineChunkRuntimeMethodDemote.php
+++ b/lib/JIT/SpineChunkRuntimeMethodDemote.php
@@ -48,7 +48,7 @@
* OOM at 1536M without demote (~148s) — hollow + demote emits under SPINE_CHUNK (#36387).
* - Func\* / Cfg\* / Lint\* / Visitor\*: NestedJIT SEGV on Internal/PHP/Linter/
* OpSubBlockAccess/VoidCastResolver — measured 2026-09-04.
- * - Top-level JIT Concern traits ({@see CompileBlockInternal}, AssignOperand, InitJitMethodCall, InitJitStaticCall, CompileIncDecAndConcatFlatten, DateTimeConstructAndMutationMeta, DomCompileTimeTagMeta, CoerceReturnPropertyDeclaringAndByRef, PropertyIncDecCompile, CallResultCompileTimePropagate, PropertyFetchCoalesceAndCompileTimeString, CallResultOperandAssign, ByRefFormalAssignAndCallArgAdapt, ResolveJitOutgoingCall)
+* - Top-level JIT Concern traits ({@see CompileBlockInternal}, AssignOperand, InitJitMethodCall, InitJitStaticCall, CompileIncDecAndConcatFlatten, DateTimeConstructAndMutationMeta, DomCompileTimeTagMeta, CoerceReturnPropertyDeclaringAndByRef, PropertyIncDecCompile, CallResultCompileTimePropagate, PropertyFetchCoalesceAndCompileTimeString, CallResultOperandAssign, ByRefFormalAssignAndCallArgAdapt, ResolveJitOutgoingCall, LocalReleaseUnsetAndVarFetchDest)
* live in namespace PHPCompiler (not PHPCompiler\JIT\Concern). Host CFG of CompileBlockInternal
* (470 KB) OOMs at 1536M unless {@see rewriteSource()} hollows **trait** bodies (T_TRAIT) —
* measured 2026-09-04: rc=255 before; emits after T_TRAIT + demote target.
@@ -106,6 +106,7 @@ public static function isDemoteTarget(string $displayClassLc): bool
|| 'phpcompiler\\callresultoperandassign' === $lc
|| 'phpcompiler\\byrefformalassignandcallargadapt' === $lc
|| 'phpcompiler\\resolvejitoutgoingcall' === $lc
+ || 'phpcompiler\\localreleaseunsetandvarfetchdest' === $lc
|| str_starts_with($lc, 'phpcompiler\\builtin')
) {
return true;
diff --git a/prelinked/bootstrap-gen0/.m3_compiler_lib_sidecar.sha b/prelinked/bootstrap-gen0/.m3_compiler_lib_sidecar.sha
index de21c1b7476..d24e3abd8e8 100755
--- a/prelinked/bootstrap-gen0/.m3_compiler_lib_sidecar.sha
+++ b/prelinked/bootstrap-gen0/.m3_compiler_lib_sidecar.sha
@@ -1 +1 @@
-5ab39d9b4a50571c3145a4a81d4e51ef2c9d3fd4
\ No newline at end of file
+cdb2cfbbaa0239b0b45425e9ae573ab0a7b2aa54
diff --git a/script/size-budgets.json b/script/size-budgets.json
index d9b60215272..bc1ab4108bf 100644
--- a/script/size-budgets.json
+++ b/script/size-budgets.json
@@ -9,9 +9,9 @@
"note": "Concern trait splits (#36230/#36387/#36403); CompileFuncCfgReturnAndGlobalImport + peers"
},
"lib/JIT.php": {
- "budget": 12739,
+ "budget": 11704,
"target": 20000,
- "note": "Concern trait splits (#36387/#36403); ResolveJitOutgoingCall + ByRefFormalAssignAndCallArgAdapt + CallResultOperandAssign"
+ "note": "Concern trait splits (#36387/#36403); LocalReleaseUnsetAndVarFetchDest + ResolveJitOutgoingCall + peers"
},
"lib/VM.php": {
"budget": 21186,
diff --git a/test/selfhost/compiler_lib_spine_smoke/main.php b/test/selfhost/compiler_lib_spine_smoke/main.php
index f2640a63c50..6e451590a0e 100644
--- a/test/selfhost/compiler_lib_spine_smoke/main.php
+++ b/test/selfhost/compiler_lib_spine_smoke/main.php
@@ -5399,6 +5399,7 @@
require_once __DIR__.'/../../../lib/JIT/Concern/CallResultOperandAssign.php';
require_once __DIR__.'/../../../lib/JIT/Concern/ByRefFormalAssignAndCallArgAdapt.php';
require_once __DIR__.'/../../../lib/JIT/Concern/ResolveJitOutgoingCall.php';
+require_once __DIR__.'/../../../lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php';
require_once __DIR__.'/../../../lib/JIT/Concern/InitJitMethodCall.php';
require_once __DIR__.'/../../../lib/Compiler/CompileTimeNew.php';
require_once __DIR__.'/../../../lib/Compiler/DeprecatedMetadata.php';
diff --git a/test/unit/BootstrapSelfhostBundleTest.php b/test/unit/BootstrapSelfhostBundleTest.php
index f8c2c75887c..c72b3485f53 100644
--- a/test/unit/BootstrapSelfhostBundleTest.php
+++ b/test/unit/BootstrapSelfhostBundleTest.php
@@ -564,7 +564,7 @@ public function testCompilerLibSpineSmokeBundleUnitCountAndKeyUnits(): void
$contents = (string) file_get_contents($entry);
$count = bootstrap_spine_counts(self::$root)['spine'];
// Spine ratio 8017/8002 — Phase A inventory in compiler_lib_spine_smoke (#2202, #31835 typed parent assign).
- $this->assertSame(8217, $count, 'M2 spine require_once units track Phase A inventory (#8559, #9234, #11629, #18550, #36079); coverage sync defers PregJitHelperThinAot (#24115) + NetworkServicesNameLookupThinAot (#27103)');
+ $this->assertSame(8218, $count, 'M2 spine require_once units track Phase A inventory (#8559, #9234, #11629, #18550, #36079); coverage sync defers PregJitHelperThinAot (#24115) + NetworkServicesNameLookupThinAot (#27103)');
foreach (self::LIB_SPINE_SMOKE_NEW_UNITS as $unit) {
$this->assertStringContainsString(
"require_once __DIR__.'/../../../{$unit}';",
diff --git a/test/unit/SpineChunkRuntimeMethodDemoteTest.php b/test/unit/SpineChunkRuntimeMethodDemoteTest.php
index 36685957983..c210b67d814 100644
--- a/test/unit/SpineChunkRuntimeMethodDemoteTest.php
+++ b/test/unit/SpineChunkRuntimeMethodDemoteTest.php
@@ -99,6 +99,7 @@ public function testShouldDemoteHubCapacityClassesUnderSpineChunk(): void
$this->assertTrue(SpineChunkRuntimeMethodDemote::shouldDemote('PHPCompiler\\CallResultOperandAssign'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::shouldDemote('PHPCompiler\\ByRefFormalAssignAndCallArgAdapt'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::shouldDemote('PHPCompiler\\ResolveJitOutgoingCall'));
+ $this->assertTrue(SpineChunkRuntimeMethodDemote::shouldDemote('PHPCompiler\\LocalReleaseUnsetAndVarFetchDest'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\CompileBlockInternal'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\CompileClassAndTraitUses'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\InitJitStaticCall'));
@@ -112,6 +113,7 @@ public function testShouldDemoteHubCapacityClassesUnderSpineChunk(): void
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\CallResultOperandAssign'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\ByRefFormalAssignAndCallArgAdapt'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\ResolveJitOutgoingCall'));
+ $this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\LocalReleaseUnsetAndVarFetchDest'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/CompileBlockInternal.php'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/CompileClassAndTraitUses.php'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/InitJitStaticCall.php'));
@@ -125,6 +127,7 @@ public function testShouldDemoteHubCapacityClassesUnderSpineChunk(): void
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/CallResultOperandAssign.php'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/ByRefFormalAssignAndCallArgAdapt.php'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/ResolveJitOutgoingCall.php'));
+ $this->assertTrue(SpineChunkRuntimeMethodDemote::oversizeSingletonCanEmit('lib/JIT/Concern/LocalReleaseUnsetAndVarFetchDest.php'));
// Doctor.php — NestedJIT OOM without demote; hollow + demote emits (#36387).
$this->assertTrue(SpineChunkRuntimeMethodDemote::shouldDemote('PHPCompiler\\Doctor'));
$this->assertTrue(SpineChunkRuntimeMethodDemote::isDemoteTarget('PHPCompiler\\Doctor'));