diff --git a/docs/YIELD_GENERATOR.md b/docs/YIELD_GENERATOR.md index 50cfced3..ca5e860c 100644 --- a/docs/YIELD_GENERATOR.md +++ b/docs/YIELD_GENERATOR.md @@ -4,7 +4,7 @@ TypePHP 的 generator 基于 PHP Fiber 运行。generator 函数或方法会返 ## 不支持 -- 不支持声明返回类型为 `Generator`;请使用 `Iterator`、`Traversable`、`iterable`、`mixed`,或省略返回类型。 +- 不支持声明返回类型为 `Generator`;请使用 `Iterator`、`Traversable`、`iterable`、`object`、`mixed`,或省略返回类型。 - 不支持按引用返回的 generator,例如 `function &gen() { yield 1; }`。 - 不支持 generator 参数按引用传递。 - 不支持 generator 可变参数。 @@ -15,7 +15,6 @@ TypePHP 的 generator 基于 PHP Fiber 运行。generator 函数或方法会返 ## 受限行为 - `yield from` 可以转发数组和 `Traversable` 的 key/value;委托对象是 generator 时可以读取其 return value。 -- `yield from` 的 `send()`/`throw()` 委托透传仍属于受限场景,复杂协程式双向通信应避免依赖。 - TypePHP Native `foreach` 可以遍历动态 PHP 返回的 Zend 原生 generator;反向由 ZendVM `foreach` 驱动 TypePHP Native generator 暂不支持。 - generator 的执行依赖 Fiber;如果当前 PHP 运行环境禁用或缺失 Fiber,则无法运行。 - generator body 在 Fiber 内执行,析构、异常传播、force-close 与 Zend 原生 generator 可能存在边界差异。 diff --git a/src/CompilerBase.php b/src/CompilerBase.php index ef3394e2..211d2ebd 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -7205,7 +7205,10 @@ class CompilerBase implements PropertyAccessContext $code .= $this->parseStmts($finally->stmts); $code .= PHP_EOL; } - $code .= 'if (' . $exVar . ') {' . PHP_EOL . $this->getIndent() . 'php::throwException(php::Object(' . $exVar . '));' . PHP_EOL . $this->getIndent() . '}'; + $rethrow = $this->inGeneratorBody + ? 'typephp_fiber_rethrow(' . $exVar . ');' + : 'php::throwException(php::Object(' . $exVar . '));'; + $code .= 'if (' . $exVar . ') {' . PHP_EOL . $this->getIndent() . $rethrow . PHP_EOL . $this->getIndent() . '}'; return $code; } diff --git a/src/Generator/FiberGenerator.php b/src/Generator/FiberGenerator.php index 681f1566..52e47e85 100644 --- a/src/Generator/FiberGenerator.php +++ b/src/Generator/FiberGenerator.php @@ -13,6 +13,9 @@ use PhpParser\Node\Expr\Yield_; use PhpParser\Node\Expr\YieldFrom; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; +use PhpParser\Node\IntersectionType; +use PhpParser\Node\NullableType; +use PhpParser\Node\UnionType; use TypePhp\Context\FunctionContext; use TypePhp\Entity\FunctionDef; @@ -64,8 +67,8 @@ trait FiberGenerator $this->fatalError($param, 'Generators with by-reference or variadic parameters are not supported yet'); } } - if ($functionDef->returnClass === 'Generator') { - $this->fatalError($v, 'Generator return type is not supported by TypePHP Fiber generators yet; use Iterator, Traversable, iterable, mixed, or omit the return type'); + if (!$this->generatorReturnTypeAcceptsFiber($v->returnType)) { + $this->fatalError($v, 'Generator return type must accept TypePHP\\FiberGenerator; use Iterator, Traversable, iterable, object, mixed, or omit the return type'); } $functionDef->generator = true; $functionDef->returnType = self::TYPE_VAR; @@ -75,6 +78,42 @@ trait FiberGenerator $functionDef->returnTypeNode = null; } + protected function generatorReturnTypeAcceptsFiber(?Node $type): bool + { + if ($type === null) { + return true; + } + if ($type instanceof NullableType) { + return $this->generatorReturnTypeAcceptsFiber($type->type); + } + if ($type instanceof UnionType) { + foreach ($type->types as $member) { + if ($this->generatorReturnTypeAcceptsFiber($member)) { + return true; + } + } + return false; + } + if ($type instanceof IntersectionType) { + foreach ($type->types as $member) { + if (!$this->generatorReturnTypeAcceptsFiber($member)) { + return false; + } + } + return true; + } + + $typeName = strtolower($this->parseIdentifier($type)); + if (in_array($typeName, ['mixed', 'object', 'iterable'], true)) { + return true; + } + + $class = ''; + $this->parseTypeDecl($type, self::DECL_TYPE_OF_RETURN, $class); + $class = strtolower(ltrim($class, '\\')); + return in_array($class, ['iterator', 'traversable', 'typephp\\fibergenerator'], true); + } + protected function parseYieldExpr(Yield_ $expr): string { if (!$this->inGeneratorBody) { @@ -142,6 +181,17 @@ trait FiberGenerator $code = $functionDeclCode . ' {' . PHP_EOL; $this->indentLevel++; + foreach ($functionDef->argInfoList as $i => $argInfo) { + if (!empty($argInfo->typeCheck)) { + $code .= $this->genUnionParamCheck($argInfo, $i); + } + } + foreach ($functionDef->argInfoList as $argInfo) { + if ($argInfo->property) { + $code .= $this->getIndent() . $this->genPropertyPromotion($argInfo); + } + } + $closureVar = $this->genTmpVarName(); $code .= $this->getIndent() . 'php::ClosureFn ' . $closureVar . ' = [](' . 'INTERNAL_FUNCTION_PARAMETERS, ' @@ -165,11 +215,21 @@ trait FiberGenerator } $body = ''; + $this->indentLevel++; + if ($this->methodDef && $this->methodDef->hasDynamicCall) { + $body .= $this->genScopeSwitchCode(); + } if ($v->stmts) { - $body = $this->parseStmts($v->stmts); + $body .= $this->parseStmts($v->stmts); } $body .= $this->getIndent() . 'return ' . self::VALUE_NULL . ';' . PHP_EOL; - $code .= $this->genScopeVarDecl() . $body; + $this->indentLevel--; + $code .= $this->genScopeVarDecl(); + $code .= $this->getIndent() . 'try {' . PHP_EOL; + $code .= $body; + $code .= $this->getIndent() . '} catch (zend_object *) {' . PHP_EOL; + $code .= $this->getIndent() . ' return ' . self::VALUE_NULL . ';' . PHP_EOL; + $code .= $this->getIndent() . '}' . PHP_EOL; $this->indentLevel = $outerIndent; $this->inGeneratorBody = $outerInGeneratorBody; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 3c85f738..64fcc4de 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -447,7 +447,10 @@ class Preprocessor extends CompilerBase $this->prepareGeneratorFunction($v, $functionDef); } - if ($v->returnType instanceof NullableType || $v->returnType instanceof UnionType || $v->returnType instanceof IntersectionType) { + if (!$functionDef->generator + && ($v->returnType instanceof NullableType + || $v->returnType instanceof UnionType + || $v->returnType instanceof IntersectionType)) { $typeInfo = $this->buildTypeCheckFromNode($v->returnType); if (!empty($typeInfo['check'])) { $functionDef->returnTypeCheck = $typeInfo['check']; diff --git a/tests/aot/generator/closed-throw.phpt b/tests/aot/generator/closed-throw.phpt new file mode 100644 index 00000000..e4485fc9 --- /dev/null +++ b/tests/aot/generator/closed-throw.phpt @@ -0,0 +1,24 @@ +--TEST-- +throw on a closed generator rethrows without leaking the exception +--FILE-- +valid(); + try { + $generator->throw(new RuntimeException('closed')); + } catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } +} +?> +--EXPECT-- +RuntimeException: closed diff --git a/tests/aot/generator/constructor-property-promotion.phpt b/tests/aot/generator/constructor-property-promotion.phpt new file mode 100644 index 00000000..45ec375c --- /dev/null +++ b/tests/aot/generator/constructor-property-promotion.phpt @@ -0,0 +1,22 @@ +--TEST-- +constructor property promotion runs even when the constructor contains yield +--FILE-- +value); +} +?> +--EXPECT-- +int(42) diff --git a/tests/aot/generator/destructor-finally.phpt b/tests/aot/generator/destructor-finally.phpt new file mode 100644 index 00000000..938fbdb4 --- /dev/null +++ b/tests/aot/generator/destructor-finally.phpt @@ -0,0 +1,25 @@ +--TEST-- +suspended generator destruction closes its Fiber without leaking +--FILE-- +current()); + unset($generator); + gc_collect_cycles(); +} +?> +--EXPECT-- +int(1) +finally diff --git a/tests/aot/generator/send-value-lifetime.phpt b/tests/aot/generator/send-value-lifetime.phpt new file mode 100644 index 00000000..1d2e70d1 --- /dev/null +++ b/tests/aot/generator/send-value-lifetime.phpt @@ -0,0 +1,36 @@ +--TEST-- +generator send preserves refcounted values across a Fiber suspension +--FILE-- +value); +} + +function main(): void +{ + $generator = receive_values(); + var_dump($generator->current()); + var_dump($generator->send(str_repeat('x', 32))); + var_dump($generator->send(['key' => str_repeat('y', 16)])); + $object = new stdClass(); + $object->value = 42; + var_dump($generator->send($object)); +} +?> +--EXPECT-- +string(6) "string" +string(32) "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +string(5) "array" +array(1) { + ["key"]=> + string(16) "yyyyyyyyyyyyyyyy" +} +string(6) "object" +int(42) +NULL diff --git a/tests/aot/generator/state-machine.phpt b/tests/aot/generator/state-machine.phpt new file mode 100644 index 00000000..95959a15 --- /dev/null +++ b/tests/aot/generator/state-machine.phpt @@ -0,0 +1,71 @@ +--TEST-- +generator lifecycle methods follow Zend Generator semantics +--FILE-- + 1; + yield 'second' => 2; + } catch (Exception $e) { + yield 'caught' => $e->getMessage(); + } + return 9; +} + +function empty_generator(): iterable +{ + if (false) { + yield 1; + } + return 7; +} + +function main(): void +{ + $next = lifecycle_generator(); + $next->next(); + var_dump($next->key(), $next->current()); + try { + $next->rewind(); + } catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } + while ($next->valid()) { + $next->next(); + } + + $throw = lifecycle_generator(); + var_dump($throw->throw(new Exception('injected'))); + var_dump($throw->key()); + + while ($throw->valid()) { + $throw->next(); + } + $throw->next(); + var_dump($throw->send('ignored')); + var_dump($throw->getReturn()); + + $empty = empty_generator(); + var_dump($empty->send('ignored')); + $empty->next(); + var_dump($empty->getReturn()); + + try { + $empty->throw(new RuntimeException('closed')); + } catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } +} +?> +--EXPECT-- +string(6) "second" +int(2) +Exception: Cannot rewind a generator that was already run +string(8) "injected" +string(6) "caught" +NULL +int(9) +NULL +int(7) +RuntimeException: closed diff --git a/tests/aot/generator/uncaught-exception.phpt b/tests/aot/generator/uncaught-exception.phpt new file mode 100644 index 00000000..897cee54 --- /dev/null +++ b/tests/aot/generator/uncaught-exception.phpt @@ -0,0 +1,32 @@ +--TEST-- +uncaught generator exceptions cross the Fiber boundary safely +--FILE-- +current()); + try { + $generator->next(); + } catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } + var_dump($generator->valid()); + try { + $generator->getReturn(); + } catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } +} +?> +--EXPECT-- +int(1) +RuntimeException: generator failed +bool(false) +Exception: Cannot get return value of a generator that hasn't returned diff --git a/tests/aot/generator/union-signatures.phpt b/tests/aot/generator/union-signatures.phpt new file mode 100644 index 00000000..a5313333 --- /dev/null +++ b/tests/aot/generator/union-signatures.phpt @@ -0,0 +1,28 @@ +--TEST-- +generator union signatures validate parameters without checking the yielded return value +--FILE-- +current()); + $generator->next(); + var_dump($generator->getReturn()); + + try { + union_generator([]); + } catch (Throwable $e) { + echo get_class($e), "\n"; + } +} +?> +--EXPECT-- +string(5) "valid" +int(42) +TypeError diff --git a/tests/aot/generator/yield-automatic-keys.phpt b/tests/aot/generator/yield-automatic-keys.phpt new file mode 100644 index 00000000..e2a15dc6 --- /dev/null +++ b/tests/aot/generator/yield-automatic-keys.phpt @@ -0,0 +1,28 @@ +--TEST-- +generator automatic integer keys track the greatest integer key +--FILE-- + 1; + yield 2; + yield 5 => 3; + yield 4; + yield -2 => 5; + yield 6; +} + +function main(): void +{ + foreach (mixed_keys() as $key => $value) { + var_dump($key); + } +} +?> +--EXPECT-- +string(4) "name" +int(0) +int(5) +int(6) +int(-2) +int(7) diff --git a/tests/aot/generator/yield-from-iterator-aggregate-cycle.phpt b/tests/aot/generator/yield-from-iterator-aggregate-cycle.phpt new file mode 100644 index 00000000..cb4a2538 --- /dev/null +++ b/tests/aot/generator/yield-from-iterator-aggregate-cycle.phpt @@ -0,0 +1,28 @@ +--TEST-- +yield from rejects an IteratorAggregate cycle +--FILE-- +current(); + } catch (Throwable $e) { + echo get_class($e), "\n"; + } +} +?> +--EXPECT-- +Exception diff --git a/tests/aot/generator/yield-from-throw.phpt b/tests/aot/generator/yield-from-throw.phpt new file mode 100644 index 00000000..7b1db207 --- /dev/null +++ b/tests/aot/generator/yield-from-throw.phpt @@ -0,0 +1,33 @@ +--TEST-- +yield from delegates throw and preserves the child return value +--FILE-- +getMessage(); + } + return 7; +} + +function throwing_parent(): iterable +{ + $result = yield from throwing_child(); + yield 'return:' . $result; +} + +function main(): void +{ + $generator = throwing_parent(); + var_dump($generator->current()); + var_dump($generator->throw(new RuntimeException('injected'))); + $generator->next(); + var_dump($generator->current()); +} +?> +--EXPECT-- +string(5) "ready" +string(14) "child:injected" +string(8) "return:7"