diff --git a/docs/README.md b/docs/README.md index 77f91603..b151a6d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ - [TypePHP WASM 技术方案与实施计划](TYPEPHP_WASM_IMPLEMENTATION_PLAN.md) - [构建 TypePHP WASI 程序](WASI_BUILD.md) - [核心重构计划](REFACTORING_PLAN.md) +- [作用域管理设计](SCOPE_MANAGEMENT.md):`CallableScope`、`UserCodeScopeGuard` 与 `FakeScopeGuard` 的职责和使用边界。 - [构建速度研究](AOT_BUILD_SPEED_RESEARCH.md) - [优化优先级](aot-optimization-priority.md) - [高精度类型原地运算优化方案](BIG_NUMBER_INPLACE_OPTIMIZATION_PLAN.md) diff --git a/docs/SCOPE_MANAGEMENT.md b/docs/SCOPE_MANAGEMENT.md new file mode 100644 index 00000000..9e8d006e --- /dev/null +++ b/docs/SCOPE_MANAGEMENT.md @@ -0,0 +1,391 @@ +# TypePHP 作用域管理设计 + +本文是 TypePHP 与 PHPX 的内部实现文档,说明当前三种作用域管理器的职责、实现方式、生命周期、性能特征和适用场景。这里的“作用域”并不是同一个 Zend 概念:callable 解析、执行帧类作用域和 `EG(fake_scope)` 分别服务于不同子系统,不能相互替代。 + +## 1. 设计目标 + +TypePHP 生成的 C++ 方法并不是普通 Zend user function。动态调用回到 ZendVM 时,Zend 仍然需要以下信息才能复现 PHP 的可见性规则: + +- 声明方法的词法作用域,用于判断 private/protected 成员是否可访问; +- 当前 late static binding 的 called scope; +- 当前实例 `$this`,用于解析非静态方法 callable; +- 某些 Zend 属性、对象和异常 API 所读取的 `EG(fake_scope)`。 + +Scope 设计遵循以下原则: + +1. 优先显式传递作用域,不修改 Zend 的全局或真实执行帧状态。 +2. 一个 AOT 方法调用期间只创建一次可复用的 callable context,循环中的多次调用共享它。 +3. 只有编译器无法确定 callback 位置时,才临时修改最近的 user-code frame。 +4. 修改 Zend executor 状态时必须使用 RAII,并保证异常路径恢复。 +5. 不为纯 Native Call 或 public、绝对定位的 callback 支付额外包装成本。 + +## 2. 总览 + +| 管理器 | 管理的状态 | 主要用途 | 是否修改 Zend 当前状态 | +| --- | --- | --- | --- | +| `php::CallableScope` | synthetic `zend_execute_data`,包含 lexical scope、called scope 和 `$this` | 动态方法调用、first-class callable、内置函数 callback | 否 | +| `php::UserCodeScopeGuard` | 最近 user-code frame 的 `zend_function::common.scope` | callback 隐藏在参数展开中的兜底路径 | 是,析构时恢复 | +| `php::FakeScopeGuard` | `EG(fake_scope)` | Zend 属性、对象、异常等读取 fake scope 的 API | 是,析构或显式 `restore()` 时恢复 | + +选择规则可以简化为: + +- 能拿到明确 callable 值:使用 `CallableScope`。 +- callback 藏在 `...$args` 中,编译期无法知道最终参数位置:使用 `UserCodeScopeGuard`。 +- 调用的 Zend API 明确读取 `EG(fake_scope)`:使用 `FakeScopeGuard`。 +- 纯 native 调用或不依赖调用者可见性的操作:不创建任何 Scope 管理器。 + +## 3. `php::CallableScope` + +### 3.1 职责 + +`CallableScope` 是当前普通 callable 解析的主路径。它将调用者上下文显式交给 `zend_is_callable_at_frame()`,用于: + +- 解析 private/protected 方法; +- 解析 `self`、`parent`、`static` callback; +- 保留 late static binding 的 called scope; +- 为非静态方法提供真实 `$this`; +- 在不修改 `EG(current_execute_data)` 和真实执行帧的前提下调用动态方法。 + +它不负责属性访问,也不会设置 `EG(fake_scope)`。 + +### 3.2 内部结构 + +类定义在 PHPX 的 `include/phpx.h` 中,持有: + +```cpp +zend_function *caller_function_; +zend_class_entry *called_scope_; +zend_object *this_object_; +mutable zend_execute_data frame_{}; +``` + +构造时通过 `zend_vm_init_call_frame()` 初始化一个 synthetic frame: + +- `caller_function_->common.scope` 是 lexical scope,即声明当前方法的类; +- `called_scope_` 是运行时 called scope; +- 实例调用设置 `ZEND_CALL_HAS_THIS` 并携带真实 `zend_object *`; +- 静态调用不携带对象,只传 called scope; +- 若 called scope 为空,则回退到 lexical scope。 + +解析时调用: + +```cpp +zend_is_callable_at_frame(callable, object, &frame_, 0, cache, error); +``` + +synthetic frame 不会安装到 `EG(current_execute_data)`,因此不会污染当前 Zend 调用栈,也不需要在退出时恢复全局状态。 + +### 3.3 生命周期与所有权 + +`CallableScope` 不拥有 `zend_function`、`zend_class_entry` 或 `zend_object`,只在当前 AOT 方法栈帧内借用这些指针: + +- TypePHP 编译方法使用 persistent `zend_function`,其生命周期覆盖请求调用; +- Closure 的 `zend_function *` 在 Closure 对象存活期间有效; +- `$this` 在当前方法执行期间有效; +- `CallableScope` 不可复制、不可移动,防止 synthetic frame 被意外转移或跨生命周期保存。 + +不得把 `CallableScope` 缓存到请求之外,也不得让它比所属方法或 Closure 活得更久。 + +### 3.4 编译器生成方式 + +编译器通过 `FunctionContext::$callableScopeVar` 延迟申请 Scope 变量。第一次需要显式 callable scope 时,`getCallableScopeExpr()` 分配临时变量;随后 `genScopeVarDecl()` 将初始化代码提升到函数入口: + +```cpp +php::CallableScope tmp_var_1 = php_get_callable_scope( + php_get_persistent_method(...), + this_ +); +``` + +`php_get_callable_scope()` 根据 `this_` 同时构建 called scope 和真实实例信息。一个方法内所有 scoped call 都引用同一个 `tmp_var_1`,因此循环中的重复调用不会重复创建 synthetic frame。 + +如果方法从未使用 scoped dynamic call、first-class callable 或 scoped callback,编译器不会生成该变量。 + +### 3.5 使用入口 + +#### `php::callScoped()` + +用于动态函数或对象方法调用。内部 `call_function_impl()` 使用 `CallableScope::resolve()` 获取 `zend_fcall_info_cache`,然后执行 `zend_call_function()`。 + +典型场景是编译器无法将对象方法解析为 Native Call,但仍需保留当前类的 private/protected 访问权。 + +#### `php::makeScopedCallable()` + +用于 first-class callable 语法。此语法的结果必须是一个真正的 `Closure`,所以即使目标方法是 public,也不能只返回原始 callback 数组或字符串。 + +```php +$callback = self::privateMethod(...); +$callback = $this->publicMethod(...); +``` + +普通方法通过 `zend_create_fake_closure()` 创建 Closure。若 Zend 返回 `ZEND_ACC_CALL_VIA_TRAMPOLINE`,则使用转发 Closure 保留 magic `__call()` / `__callStatic()` 的动态语义。 + +#### `php::prepareScopedCallback()` + +用于向 `array_map()`、`usort()` 等 PHP 内置函数传递 callback。这里的目标只是让内置函数正确调用 callback,不要求参数本身变成 Closure。 + +因此它会优先复用以下 callback 的原始值: + +- public 方法; +- 使用绝对类名定位; +- 不依赖 trampoline。 + +只有 private/protected 方法、`self` / `parent` / `static` 相对 callback 或 trampoline 才创建 Closure。这避免了循环中每次调用内置函数都无条件分配 fake Closure。 + +#### `php::makeScopedCallableMap()` + +用于 callback map。它逐项调用 `prepareScopedCallback()`,只替换真正依赖作用域的元素。 + +PHPX `Array` 使用 Zend copy-on-write:若所有 callback 都可直接复用,底层 HashTable 不会复制;首次需要替换时只分离一次,之后在同一结果数组中更新。因此复杂度仍为 O(N) 检查,但没有无条件 O(N) 的 Closure 分配。 + +### 3.6 为什么仍要运行时识别 `self` / `parent` / `static` + +直接语法中的 `self::class` 可以在编译期展开为具体类名,但 PHP callback 也允许动态值: + +```php +$class = 'self'; +$callback = [$class, 'method']; +``` + +此时只有运行时才能知道数组中的类名是否为相对类名。因此 `isRelativeCallableClass()` 不能完全移到编译期。对于已知的绝对 public callback,该检查会很快返回 false,并复用原值。 + +## 4. `php::UserCodeScopeGuard` + +### 4.1 职责与适用范围 + +`UserCodeScopeGuard` 只处理一种编译器无法静态改写 callback 的情况:callback 位于参数展开中。 + +```php +$args = [[$this, 'privateMethod'], 1]; +call_user_func(...$args); +``` + +内置函数 callback 可能位于固定位置、倒数位置、命名参数中,甚至一个函数有多个 callback。执行 `...$args` 展开前,编译器并不知道最终的 positional/named 参数布局,无法只对对应值调用 `prepareScopedCallback()`。 + +普通 callback 参数不得使用此 guard;只要 callback 的 AST 参数位置已知,就应使用 `CallableScope` 路径。 + +### 4.2 实现方式 + +构造函数从 `EG(current_execute_data)` 开始向上查找最近的 user-code frame,并跳过 internal frame: + +```cpp +while (frame && (!frame->func || !ZEND_USER_CODE(frame->func->type))) { + frame = frame->prev_execute_data; +} +``` + +找到后保存: + +```cpp +function_ = frame->func; +previous_scope_ = function_->common.scope; +function_->common.scope = requested_scope; +``` + +析构函数恢复 `previous_scope_`。类不可复制、不可移动,保证一次构造对应一次恢复。如果没有可用的 user-code frame,会抛出: + +```text +A user-code frame is required for scoped callback argument unpacking +``` + +该 guard 操作的是从当前请求执行链找到的 user-code frame,不是 TypePHP 注册在 MINIT 的 persistent internal method。`EG(current_execute_data)` 本身属于当前 executor 上下文。其影响窗口被限制在当前 AOT 方法调用的 RAII 生命周期内。 + +### 4.3 编译器生成方式 + +编译器维护语义明确的标记: + +```php +MethodDef::$needsUnpackedCallbackScope +``` + +当一个已知会同步调用 callback 的 PHP 内置函数存在参数展开,且 callback 无法从显式参数中完整匹配时,`markUnpackedCallbackScopeFallback()` 设置该标记。方法、Closure 或 Fiber 入口只生成一个: + +```cpp +php::UserCodeScopeGuard tmp_var_2{php_get_called_ce(this_)}; +``` + +它不是按 call site 或循环迭代创建的。没有 unpack callback 的方法不会产生此成本。 + +### 4.4 为什么当前保留该兜底 + +若完全移除它,编译器必须在参数展开完成后增加一套结构化参数绑定和改写流程,正确处理: + +- positional 与 named 参数合并; +- callback 的正向和倒数位置; +- 一个函数的多个 callback; +- callback map; +- unpack 中重复、缺失或覆盖参数时的 PHP 错误语义。 + +这不是一个局部替换,而是对 `parseCallArgs()` 和参数容器生成流程的中等规模重构。在完成统一的运行时参数后处理机制前,保留范围严格受控的 `UserCodeScopeGuard` 更简单可靠。 + +## 5. `php::FakeScopeGuard` + +### 5.1 职责 + +`FakeScopeGuard` 是 `EG(fake_scope)` 的 RAII 包装。部分 Zend API 不接受显式调用 frame,而是直接读取 `EG(fake_scope)` 来判断类成员可见性或执行类作用域相关操作。只有这些 API 才应使用它。 + +当前典型场景包括: + +- 动态属性读取、写入和属性 hook; +- Zend object handler 调用; +- 类作用域下的默认值或对象初始化; +- 异常对象相关的 Zend 操作; +- 其他明确读取 `EG(fake_scope)` 的 Zend 内部接口。 + +TypePHP 的属性访问生成器会通过 `FakeScopeGuard::current()` 将当前 fake scope 传给 PHPX 属性 helper。 + +### 5.2 实现方式 + +构造时保存旧值并设置新值,析构时恢复: + +```cpp +explicit FakeScopeGuard(Scope scope) noexcept : previous_(current()) { + EG(fake_scope) = scope; +} + +~FakeScopeGuard() noexcept { + restore(); +} +``` + +`Scope` 通过 `decltype(EG(fake_scope))` 推导,以同时兼容 PHP 8.4 的可变指针和 PHP 8.5 的 pointer-to-const。`restore()` 是幂等操作,可以安全地提前调用一次。 + +### 5.3 Zend bailout 注意事项 + +C++ 异常展开会执行析构函数,但 Zend bailout 使用 `longjmp`,不会执行 C++ 析构函数。如果 guard 的生命周期跨越 bailout 边界,必须在对应的 `zend_catch` 路径中显式调用: + +```cpp +fake_scope_guard.restore(); +``` + +然后再继续 bailout 或转换异常。仅依赖析构函数处理 bailout 是错误的。 + +### 5.4 不适用场景 + +`FakeScopeGuard` 不能替代 `CallableScope`: + +- 它没有 synthetic frame; +- 它不能携带 `$this`; +- 它不能完整表达 lexical scope 与 called scope; +- `zend_is_callable_at_frame()` 的解析语义不应通过全局 fake scope 间接模拟。 + +同样,不能为了“可能需要访问 private”而在整个 AOT 方法入口无条件设置 `EG(fake_scope)`。这会扩大全局状态的影响范围,并让无关的 native 密集调用承担成本。 + +## 6. 三种 Scope 的调用流程 + +### 6.1 已知动态方法调用 + +```text +AOT method entry + -> lazily generated CallableScope + -> php::callScoped() + -> CallableScope::resolve() + -> zend_is_callable_at_frame(synthetic frame) + -> zend_call_function() +``` + +整个过程不修改真实 Zend frame。 + +### 6.2 已知内置函数 callback + +```text +compiler marks callback argument + -> prepareScopedCallback(value, CallableScope) + -> public absolute callback: reuse value + -> scoped/trampoline callback: create Closure + -> call PHP internal function +``` + +first-class callable 使用同一解析基础,但必须调用 `makeScopedCallable()` 并返回 Closure。 + +### 6.3 参数展开中的 callback + +```text +AOT method entry + -> UserCodeScopeGuard changes nearest user-code frame scope + -> internal function receives expanded arguments + -> Zend resolves hidden callback using that frame scope + -> method exit / C++ exception unwind + -> guard restores original scope +``` + +### 6.4 属性或对象 handler + +```text +save EG(fake_scope) + -> install FakeScopeGuard + -> call Zend property/object API + -> restore on normal/C++ exception exit + -> explicitly restore in zend_catch if bailout is possible +``` + +## 7. 禁止混用与维护约束 + +1. 不要用 `FakeScopeGuard` 解析 callable。 +2. 不要为普通已知 callback 修改真实 user-code frame;使用 `prepareScopedCallback()`。 +3. 不要让 `UserCodeScopeGuard` 重新变成所有动态调用的通用入口。 +4. 不要在循环中的 call site 重建 `CallableScope`;应由 `FunctionContext` 提升到方法入口并复用。 +5. 不要缓存 `CallableScope` 借用的函数、对象或 synthetic frame 到请求之外。 +6. 不要把 first-class callable 改为返回原始 callback;其 PHP 结果类型必须是 Closure。 +7. 新增会同步调用 callback 的 PHP 内置函数时,需要更新 callback 参数描述表,注明位置、参数名以及是否为 callback map。 +8. 保存 callback 但不立即调用的函数不能仅因接收 callable 就标记 scope fallback,例如 `spl_autoload_register()`。 +9. 新增跨 Zend bailout 的 `FakeScopeGuard` 用法时,代码审查必须检查 `zend_catch` 是否显式恢复。 + +## 8. 性能模型 + +| 路径 | 主要成本 | 优化策略 | +| --- | --- | --- | +| `CallableScope` | 初始化一个 synthetic frame | 每个 AOT 方法最多一次,循环复用 | +| `callScoped()` | `zend_is_callable_at_frame()` 动态解析 | 仅动态调用使用;可解析的 Native Call 不进入此路径 | +| `prepareScopedCallback()` | 一次 callable 解析 | public 绝对 callback 不创建 Closure | +| `makeScopedCallable()` | callable 解析及 Closure 分配 | 仅 first-class callable 使用 | +| `makeScopedCallableMap()` | O(N) 检查 | COW;只包装需要作用域的元素 | +| `UserCodeScopeGuard` | 方法入口一次指针查找、写入和退出恢复 | 只为未解析的 unpack callback 生成 | +| `FakeScopeGuard` | 两次 executor-global 指针赋值 | 仅包围确实读取 fake scope 的 Zend API | + +这套设计刻意让常见的纯 Native Call、无 callback 方法和 public callback 保持最短路径。不要为了统一表面形式而把低频 fallback 下沉到所有调用中。 + +## 9. 测试要求 + +Scope 修改至少应覆盖以下层次: + +- PHPX 单测:`FakeScopeGuard` 保存、嵌套、恢复和提前 `restore()`; +- 编译器结构测试:一个方法只生成一个 `php_get_callable_scope()`,多处调用复用同一变量; +- PHPT:private/protected callback、非静态 `self::method(...)`、public callback; +- PHPT:callback map 中 public 与 scoped callback 混合; +- PHPT:`...$args` 中 private callback 可调用,异常退出后 scope 已恢复; +- PHPT:Closure、Fiber、普通方法中的作用域生成路径; +- 回归测试:纯 Native Call 不应生成额外 Scope guard。 + +当前相关测试包括: + +- `phpunit/src/ScopedCallContextTest.php` +- `phpunit/code/scoped-call-context-reuse.php` +- `tests/compiler/place-holder/non-static-self.phpt` +- `tests/compiler/callable/scoped-internal-callbacks.phpt` +- `tests/compiler/callable/unpacked-callback-scope-restored.phpt` +- PHPX `tests/src/scope_guard.cpp` + +涉及动态调用抛出异常的 PHPT 可能触发已知 ZendVM 内存泄漏报告;只有确认泄漏来自 Zend 动态调用异常路径时,测试才可局部设置 `USE_ZEND_ALLOC=0`,不能全局关闭内存检查。 + +## 10. 代码位置索引 + +| 内容 | 位置 | +| --- | --- | +| `CallableScope` 及 public helper 声明 | `vendor/swoole/phpx/include/phpx.h` | +| callable 解析与包装 | `vendor/swoole/phpx/src/core/base.cc`、`vendor/swoole/phpx/src/core/closure.cc` | +| `FakeScopeGuard` | `vendor/swoole/phpx/include/phpx_fake_scope_guard.h` | +| `UserCodeScopeGuard` | `vendor/swoole/phpx/src/misc/typephp_helper.h`、`typephp_main.cc` | +| `php_get_callable_scope()` | `vendor/swoole/phpx/src/misc/typephp_helper.h` | +| callback 标记和 Scope 变量生成 | `src/CompilerBase.php` | +| callback 参数包装 | `src/Generator/CallArgumentGenerator.php` | +| Closure/Fiber fallback guard | `src/Generator/ClosureGenerator.php`、`FiberGenerator.php` | +| 方法 fallback guard | `src/Translator.php` | +| Scope 状态 | `src/Context/FunctionContext.php`、`src/Entity/MethodDef.php` | +| 属性访问中的 fake scope | `src/Parser/PropertyAccessTrait.php` | + +## 11. 后续演进原则 + +只有当编译器具备统一的“参数展开后绑定”中间表示,并能完整处理 positional、named、negative-position 和 callback map 时,才考虑删除 `UserCodeScopeGuard`。删除它的目标应是让所有 callback 都走显式 `CallableScope`,而不是重新扩大 `EG(fake_scope)` 或真实 frame 修改的范围。 + +未来新增 Scope 抽象前,应先确认 Zend API 依赖的是 synthetic call frame、真实 user-code frame,还是 `EG(fake_scope)`。名称和类型应直接表达所管理的 Zend 状态,避免再次出现一个含义过宽的通用 `Scope` 类。 diff --git a/phpunit/code/scoped-call-context-reuse.php b/phpunit/code/scoped-call-context-reuse.php new file mode 100644 index 00000000..56175a35 --- /dev/null +++ b/phpunit/code/scoped-call-context-reuse.php @@ -0,0 +1,20 @@ +assertFalse($method->hasDynamicCall); + $this->assertFalse($method->needsUnpackedCallbackScope); } - public function testHasDynamicCallCanBeSet(): void + public function testUnpackedCallbackScopeCanBeSet(): void { $method = new MethodDef(Modifiers::PUBLIC, 'test'); - $method->hasDynamicCall = true; - $this->assertTrue($method->hasDynamicCall); + $method->needsUnpackedCallbackScope = true; + $this->assertTrue($method->needsUnpackedCallbackScope); } public function testStaticMethod(): void diff --git a/phpunit/src/Entity/MethodDefTest.php b/phpunit/src/Entity/MethodDefTest.php index 724706bc..5e67f945 100644 --- a/phpunit/src/Entity/MethodDefTest.php +++ b/phpunit/src/Entity/MethodDefTest.php @@ -16,7 +16,7 @@ class MethodDefTest extends TestCase $this->assertEquals('handle', $method->name); $this->assertSame(Modifiers::PUBLIC, $method->flags); $this->assertNull($method->functionDef); - $this->assertFalse($method->hasDynamicCall); + $this->assertFalse($method->needsUnpackedCallbackScope); } public function testGetReturnType(): void diff --git a/phpunit/src/ScopedCallContextTest.php b/phpunit/src/ScopedCallContextTest.php new file mode 100644 index 00000000..3dc935e5 --- /dev/null +++ b/phpunit/src/ScopedCallContextTest.php @@ -0,0 +1,24 @@ +addFiles([$testFile]); + $compiler->prepareFile($testFile); + $cppFile = $compiler->convertFile($testFile); + $cpp = file_get_contents($cppFile); + + $this->assertSame(1, substr_count($cpp, 'php_get_callable_scope(')); + $this->assertMatchesRegularExpression( + '/php::CallableScope (tmp_var_\d+) = php_get_callable_scope\(/', + $cpp, + ); + preg_match('/php::CallableScope (tmp_var_\d+) =/', $cpp, $matches); + $this->assertGreaterThanOrEqual(4, substr_count($cpp, $matches[1])); + } +} diff --git a/phpunit/src/TraitsTest.php b/phpunit/src/TraitsTest.php index c4081985..e6bff789 100644 --- a/phpunit/src/TraitsTest.php +++ b/phpunit/src/TraitsTest.php @@ -215,27 +215,28 @@ class TraitsTest extends TestCase } // ======================================================================== - // ClosureGenerator::genScopeSwitchCode + // ClosureGenerator::genUnpackedCallbackScopeGuard // ======================================================================== - public function testGenScopeSwitchCode(): void + public function testGenUnpackedCallbackScopeGuard(): void { $this->invoke('resetFunction'); - $result = $this->invoke('genScopeSwitchCode'); - $this->assertStringContainsString('php_switch_scope', $result); - $this->assertStringContainsString('ON_SCOPE_EXIT', $result); - $this->assertStringContainsString('php_restore_scope', $result); + $result = $this->invoke('genUnpackedCallbackScopeGuard'); + $this->assertStringContainsString('php::UserCodeScopeGuard', $result); + $this->assertStringContainsString('php_get_called_ce(this_)', $result); + $this->assertStringNotContainsString('ON_SCOPE_EXIT', $result); } - public function testGenScopeSwitchCodeTemplate(): void + public function testGenUnpackedCallbackScopeGuardUsesOneRaiiObject(): void { $this->invoke('resetFunction'); - $result = $this->invoke('genScopeSwitchCode'); - // Should have the pattern: auto tmp_var_X = php_switch_scope(this_); - $this->assertStringContainsString('auto', $result); - $this->assertStringContainsString('= php_switch_scope(this_)', $result); + $result = $this->invoke('genUnpackedCallbackScopeGuard'); + $this->assertMatchesRegularExpression( + '/php::UserCodeScopeGuard tmp_var_\d+\{php_get_called_ce\(this_\)\};/', + $result, + ); } // ======================================================================== diff --git a/src/CompilerBase.php b/src/CompilerBase.php index c976465f..a7b11879 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1316,9 +1316,10 @@ class CompilerBase implements PropertyAccessContext if (!$this->classDef || !$this->methodDef) { return 'php::CallableScope(nullptr, nullptr, nullptr)'; } - return 'php_get_callable_scope(' - . $this->getMethodPtr($this->getFullClassName(), $this->methodDef->name) - . ', this_)'; + if ($this->context->callableScopeVar === null) { + $this->context->callableScopeVar = $this->genTmpVarName(); + } + return $this->context->callableScopeVar; } protected function getCeWrapper(string $className): string @@ -3186,10 +3187,10 @@ class CompilerBase implements PropertyAccessContext } /** Retain the legacy frame scope when an unpacked value may contain a callback. */ - protected function markUnpackedScopedCallbackCall(): void + protected function markUnpackedCallbackScopeFallback(): void { if ($this->methodDef) { - $this->methodDef->hasDynamicCall = true; + $this->methodDef->needsUnpackedCallbackScope = true; } } @@ -3291,7 +3292,7 @@ class CompilerBase implements PropertyAccessContext // position is not known here. Keep the legacy frame scope only // for this remaining case; ordinary callback arguments no longer // mutate the executing Zend frame. - $this->markUnpackedScopedCallbackCall(); + $this->markUnpackedCallbackScopeFallback(); } } @@ -4407,6 +4408,12 @@ class CompilerBase implements PropertyAccessContext if ($this->context->hasMultiLevelContinue) { $code .= $this->getIndent() . 'int _cnt_flag = 0;' . PHP_EOL; } + if ($this->context->callableScopeVar !== null) { + $code .= $this->getIndent() . 'php::CallableScope ' + . $this->context->callableScopeVar . ' = php_get_callable_scope(' + . $this->getMethodPtr($this->getFullClassName(), $this->methodDef->name) + . ', this_);' . PHP_EOL; + } $code .= $this->genLocalVarDecl($this->context->localVars); // Native static calls pass a lightweight Object containing the called // class entry. A wrapper can be shared by all calls to the same class, diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index 3b979f67..865d5a71 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -53,6 +53,8 @@ class FunctionContext * @var array */ public array $ceWrappers = []; + /** Reusable php::CallableScope local, created only when this function performs scoped calls. */ + public ?string $callableScopeVar = null; public int $tmpVarIndex = 0; public array $arguments = []; /** True while parsing a breakable loop or switch. */ @@ -96,8 +98,10 @@ class FunctionContext $this->unsafeObjectProps = []; $this->staticPropRefs = []; $this->ceWrappers = []; + $this->callableScopeVar = null; $this->tmpVarIndex = 0; $this->scopeLayouts = []; + $this->callableScopeVar = null; $this->scopeLevel = 0; $this->inLoop = false; $this->inContinuableLoop = false; diff --git a/src/Entity/MethodDef.php b/src/Entity/MethodDef.php index 93eca879..1af38ee2 100644 --- a/src/Entity/MethodDef.php +++ b/src/Entity/MethodDef.php @@ -13,7 +13,8 @@ class MethodDef public int $flags; public string $name; public ?FunctionDef $functionDef = null; - public bool $hasDynamicCall = false; + /** An unpacked argument may supply a callback whose lexical scope is only known at runtime. */ + public bool $needsUnpackedCallbackScope = false; /** * The original `ClassMethod` AST node this definition was parsed from. diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 70e9029f..d805f88e 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -553,7 +553,7 @@ trait CallArgumentGenerator return $value; } - $helper = $mode === 'map' ? 'makeScopedCallableMap' : 'makeScopedCallable'; + $helper = $mode === 'map' ? 'makeScopedCallableMap' : 'prepareScopedCallback'; return 'php::' . $helper . '(' . $value . ', ' . $this->getCallableScopeExpr() . ')'; } diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index 728adc68..9cd5a0c3 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -91,12 +91,10 @@ trait ClosureGenerator return $stmts[array_key_last($stmts)] instanceof Node\Stmt\Return_; } - protected function genScopeSwitchCode(): string + protected function genUnpackedCallbackScopeGuard(): string { $tmpScope = $this->genTmpVarName(); - $code = "auto {$tmpScope} = php_switch_scope(this_);" . PHP_EOL; - $code .= "ON_SCOPE_EXIT({ php_restore_scope({$tmpScope}); });" . PHP_EOL; - return $code; + return "php::UserCodeScopeGuard {$tmpScope}{php_get_called_ce(this_)};" . PHP_EOL; } protected function genClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params, array $uses = []): string @@ -321,8 +319,8 @@ trait ClosureGenerator $this->indentLevel++; $body = ''; - if ($this->methodDef && $this->methodDef->hasDynamicCall) { - $body .= $this->genScopeSwitchCode(); + if ($this->methodDef && $this->methodDef->needsUnpackedCallbackScope) { + $body .= $this->genUnpackedCallbackScopeGuard(); } if ($expr instanceof Expr\ArrowFunction) { [$value, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($expr->expr); diff --git a/src/Generator/FiberGenerator.php b/src/Generator/FiberGenerator.php index 657e725b..d1054b5c 100644 --- a/src/Generator/FiberGenerator.php +++ b/src/Generator/FiberGenerator.php @@ -283,8 +283,8 @@ trait FiberGenerator $body = ''; $this->indentLevel++; - if ($this->methodDef && $this->methodDef->hasDynamicCall) { - $body .= $this->genScopeSwitchCode(); + if ($this->methodDef && $this->methodDef->needsUnpackedCallbackScope) { + $body .= $this->genUnpackedCallbackScopeGuard(); } if ($v->stmts) { $body .= $this->parseStmts($v->stmts); diff --git a/src/Translator.php b/src/Translator.php index dba70bf4..321d5dbb 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3751,11 +3751,11 @@ CODE; } $code .= $this->genDebugInfo(null, $debugName, $v->getStartLine()); - // AOT methods containing a Zend dynamic object call must expose their - // class scope to callable visibility checks. Pure native methods avoid - // this frame traversal and scope mutation entirely. - if ($this->methodDef and $this->methodDef->hasDynamicCall) { - $code .= $this->genScopeSwitchCode(); + // Argument unpacking can hide a callback position from the compiler. + // Only that fallback exposes the called class through the user frame; + // ordinary callback resolution uses the explicit CallableScope above. + if ($this->methodDef and $this->methodDef->needsUnpackedCallbackScope) { + $code .= $this->genUnpackedCallbackScopeGuard(); } $code .= $stmts; diff --git a/tests/compiler/callable/unpacked-callback-scope-restored.phpt b/tests/compiler/callable/unpacked-callback-scope-restored.phpt new file mode 100644 index 00000000..a038be77 --- /dev/null +++ b/tests/compiler/callable/unpacked-callback-scope-restored.phpt @@ -0,0 +1,42 @@ +--TEST-- +Unpacked callback user-code scope is restored after an exception +--ENV-- +USE_ZEND_ALLOC=0 +--FILE-- +callback(); + try { + $object->run(); + } catch (RuntimeException $exception) { + echo $exception->getMessage(), "\n"; + } + var_dump(is_callable($callback)); +} + +?> +--EXPECT-- +expected +bool(false) diff --git a/tests/compiler/place-holder/non-static-self.phpt b/tests/compiler/place-holder/non-static-self.phpt new file mode 100644 index 00000000..c3ff6602 --- /dev/null +++ b/tests/compiler/place-holder/non-static-self.phpt @@ -0,0 +1,27 @@ +--TEST-- +First-class self callable retains the current object +--FILE-- +callback(); + var_dump($callback()); +} + +?> +--EXPECT-- +string(2) "ok"