diff --git a/README.md b/README.md index fc02616..8f11656 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ ## 📖 简介 -`tinywan/webman-typephp` 是面向 Webman 2.x 的 [TypePHP AOT](https://swoole.com/aot/zh) 构建插件。它会从现有 Webman 项目生成 AOT 入口和 Linux 编译配置,再交给固定版本的 Docker builder 完成编译,最后整理出可以复制到目标服务器的 `dist/` 目录。 +`tinywan/webman-typephp` 是面向 Webman 2.x 的 [TypePHP AOT](https://swoole.com/aot/zh) 构建插件。它会从现有 Webman 项目生成 AOT 入口和编译配置,可交给固定版本的 Docker builder 生成 Linux portable-dir,也可直接调用宿主机 TypePHP 工具链生成当前平台原生程序。 -宿主机只需要 PHP、Composer 和 Docker,不需要安装 C++、Clang 或 TypePHP 编译工具链。 +Linux portable-dir 模式下,宿主机只需要 PHP、Composer 和 Docker。原生模式不使用 Docker,但需要匹配 ABI 的 PHP embed SDK、PHPX、TypePHP 和 C++17 编译器。 ## 🌟 核心特性 -- ⚡ **一键构建**:自动生成 `main.php` 与 `project.linux.yml`,统一调度 Docker 编译环境。 +- ⚡ **两种构建路径**:`typephp:package` 使用 Docker 生成 Linux portable-dir;`typephp:compile` 直接调用宿主机 TypePHP、PHPX 与 C++ 编译器生成当前平台程序。 - 🧩 **全版本 Webman 兼容**:自动把 `webman-framework` 的 `helpers.php` 与 `fast-route` 的 `functions.php` 平铺为 AOT 专用版本(`.typephp/build/`),规避新版框架顶层 `if` 守卫触发的 `Unsupported statement: Stmt_If` 编译错误或静默跳过,同时保证 `base_path()`、`config()`、`FastRoute\simpleDispatcher()` 等全局函数完整编译进二进制。 - 🩹 **协程静态属性补丁**:自动把 `workerman/coroutine` 的 `Context`/`WaitGroup`/`Barrier` 中未初始化的标量静态属性补成可空并默认 `null`(`.typephp/build/`),规避 TypePHP 编译产物把未初始化标量静态读作零值导致 `??=` 守卫失效、进而触发 `Invalid callback ::destroy` 崩溃循环的问题。 - 🔧 **可变参数闭包补丁**:自动把 `Worker`/`TcpConnection`/`AsyncTcpConnection`/`Select`/webman `File` 中签名不足的错误处理与信号闭包补成可变参数形态(`.typephp/build/`),规避 TypePHP 编译产物对闭包调用强制精确参数个数(PHP 语义允许多传忽略)导致每次 accept 抛 `ArgumentCountError`、worker 崩溃循环的问题。 @@ -52,12 +52,21 @@ composer require tinywan/webman-typephp --dev php webman typephp:doctor ``` +检查不使用 Docker 的宿主机原生工具链: + +```bash +php webman typephp:doctor --target=native +``` + ### 3. 编译打包 ```bash # 默认输出到 dist/ php webman typephp:package +# SaiAdmin:自动发现核心、app、support 与已安装插件服务端业务代码 +php webman typephp:package --profile=saiadmin + # dist/ 已存在时,显式确认覆盖 php webman typephp:package --force @@ -67,6 +76,22 @@ php webman typephp:package --refresh-main 默认 builder 为 `tinywan/typephp-webman-builder:v0.1.3`。编译在 Docker 中完成,宿主机不需要 C++、Clang 或 TypePHP 编译器。 +直接调用宿主机 TypePHP 和 clang 编译当前平台程序: + +```bash +php webman typephp:doctor --target=native +php webman typephp:compile --profile=saiadmin +``` + +原生模式输出 `build/webman-server` 及 +`.typephp/build/native-build-manifest.json`。它不会启动 Docker,也不会把当前平台程序包装成 Linux +portable-dir;macOS 构建结果是 Mach-O,只能在 ABI 兼容的 macOS 环境运行。 +如果项目中已经存在旧版本插件生成的 `main.php`,升级后首次重建应增加 +`--refresh-main`;命令会先保留 `main.php.bak`。 + +SaiAdmin 的支持矩阵、开发规范、存量迁移、配置样例、验收脚本和实跑证据见 +[`docs/saiadmin-aot/`](docs/saiadmin-aot/README.md)。 + ### 4. 启动产物 将 `dist/` 复制到兼容的 Linux x86_64/glibc 服务器,在目录内启动: @@ -106,6 +131,7 @@ dist/ ├── lib/ # 随包发布的底层系统与扩展动态依赖库 (ldd 完整收集) ├── runtime/ # 运行时缓存与日志目录 (logs, views) ├── build-manifest.json # 输入、镜像与时间等构建元数据 +├── source-coverage.json # SaiAdmin profile 的逐业务文件 AOT 覆盖清单 ├── config/ # 项目运行时配置(若存在) ├── public/ # 静态资源(若存在) └── app/view/ # 视图模板(若存在) @@ -118,10 +144,13 @@ dist/ | 命令 | 说明 | | --- | --- | | `php webman typephp:package` | 使用默认 builder 构建 Linux portable-dir | +| `php webman typephp:package --profile=saiadmin` | 使用锁版本、失败关闭的 SaiAdmin 自动发现与兼容规则构建 | +| `php webman typephp:compile --profile=saiadmin` | 不使用 Docker,调用本机 TypePHP 工具链编译当前平台原生程序 | | `php webman typephp:package --force` | 覆盖已有输出,并保留旧目录备份 | | `php webman typephp:package --refresh-main` | 强制从最新官方 stub 刷新 `main.php`(旧文件自动备份) | | `php webman typephp:package --image=...` | 使用指定且经过验证的 Docker 镜像 | | `php webman typephp:doctor` | 检查 PHP、Docker 和构建前置条件 | +| `php webman typephp:doctor --target=native` | 检查 PHP embed、PHPX、TypePHP 和本机 C++ 编译器 | | `php webman typephp:init-ci` | 生成 Linux amd64 GitHub Actions 工作流 | ## ⚙️ 配置 @@ -135,6 +164,13 @@ return [ 'enabled' => true, 'image' => 'tinywan/typephp-webman-builder:v0.1.3', ], + 'native' => [ + 'tpc' => null, + 'php' => null, + 'php_home' => null, + 'phpx_home' => null, + 'cxx' => null, + ], 'build' => [ 'output_name' => 'webman-server', 'dist_dir' => 'dist', @@ -145,6 +181,27 @@ return [ `--image` 的优先级最高;未指定时使用上述 `docker.image`,配置缺失时才回退至 `tinywan/typephp-webman-builder:v0.1.3`。 +原生工具链默认按环境变量和常见 Composer/Homebrew 路径发现。自动发现不适用时,可通过 +`native.*`、`PHP_HOME`、`PHPX_HOME`、`TYPEPHP_TPC`、`CXX`,或 +`typephp:compile` 的同名命令选项显式指定。PHP 可执行文件、`php-config`、头文件、 +`libphp` 和 `libphpx` 必须来自同一 PHP 8.4/8.5 ABI;发现版本混用时命令会在编译前失败。 + +### SaiAdmin profile + +`--profile=saiadmin` 要求存在 `plugin/saiadmin` 和有效的 `composer.lock`。profile +沿用本插件 Composer 对 Webman 的版本约束,不再额外设置 Webman、Workerman 或 +SaiAdmin 版本白名单。安全边界由 Composer 可安装约束、SaiAdmin 安装源码一致性、 +兼容规则预期命中数和完整编译共同保证;源码结构漂移会失败关闭。当前仍会精确校验 +ThinkORM `v3.0.34` 和 Carbon `3.13.2`,因为相关兼容规则尚未完成跨版本验证。 + +已验证版本分层如下:SaiAdmin `6.1.1` 与 `6.1.5` 均已完成 Linux amd64 +编译、打包和隔离 MySQL 业务验收。`6.1.5` 的普通 PHP 8.4 对照中,验证码、 +登录和用户信息通过;权限异常路径仍受上游隐式 nullable deprecation 影响,具体 +证据边界见 [支持矩阵](docs/saiadmin-aot/docs/compatibility-matrix.md) 与 +[验证证据](docs/saiadmin-aot/docs/verification-evidence.md)。 + +profile 会自动发现根 `app/`、`support/`、SaiAdmin 核心和每个 `plugin/*/app/`,并编译完整 Composer 依赖树。兼容副本只写入 `.typephp/build/`,普通 PHP 源码不变。构建输出中的 `source-coverage.json` 逐项记录业务 PHP 是直接编译还是由哪个 AOT 副本替代;业务文件未分类、被排除却没有等价副本,或漂移到未知依赖版本时都会终止构建。 + ## 🎯 可信 MVP 边界 当前第一阶段只承诺已经验证的组合: diff --git a/docker/AssignOpTrait.php b/docker/AssignOpTrait.php index 7edaefa..e619052 100644 --- a/docker/AssignOpTrait.php +++ b/docker/AssignOpTrait.php @@ -287,20 +287,18 @@ protected function parseAssignToList(Expr $left, Expr $right): string continue; } if ($item instanceof ArrayItem) { - $key = $item->key ? $this->parseArrayKey($item->key) : (string) $k; + $value = new Expr\ArrayDimFetch( + new Variable($tmpVar), + $item->key ?? new Node\Scalar\Int_($k), + ); if ($item->value instanceof Expr\List_) { - $nestedTmp = $this->genTmpVarName(); - $this->addLocalVar($nestedTmp, Type::ARRAY); - $code .= $this->getIndent() . "{$nestedTmp} = {$tmpVar}.item({$key});" . PHP_EOL; $code .= $this->getIndent() - . $this->parseAssignToList($item->value, new Variable($nestedTmp)) + . $this->parseAssignToList($item->value, $value) . PHP_EOL; } else { - $var = $this->parseWritableIdentifier($item->value); - if ($this->isVarExpr($item->value) and !$this->hasVar($var)) { - $this->addLocalVar($var, Type::VAR); - } - $code .= $this->getIndent() . "{$var} = {$tmpVar}.item({$key});" . PHP_EOL; + $code .= $this->getIndent() + . $this->parseAssignFinally($item->value, $value) + . ';' . PHP_EOL; } } else { $this->unsupportedSyntax($item); @@ -885,7 +883,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string return $pythonOperator; } $propertyWriteTarget = $this->preparePropertyWriteTarget($node->var); - $this->guardLiteralDivisionByZero($node->expr, $op); + $this->guardLiteralDivisionByZero($node->var, $node->expr, $op); // A compound division/modulo on a NATIVE scalar slot with a proven // zero divisor cannot fall through to the raw C++ operator (SIGFPE @@ -894,7 +892,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string // lower the whole expression to the PHP-semantics binary operation // and leave the target untouched. if (($op === '/=' || $op === '%=') - && !$this->nativeTypes + && $this->varIntTypes && $this->isZeroLiteral($node->expr) && $this->isVarExpr($node->var) && $this->hasVar((string) $this->parseIdentifier($node->var)) @@ -903,7 +901,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string // std::int()/std::float() values are an explicit opt-in to native // C++ arithmetic; changing them to PHP semantics here would be as // wrong as the undefined raw operation. Keep the compile-time - // rejection native_types mode uses. + // rejection varint_types mode uses. if ($this->isExplicitNativeArithmeticExpr($node->var)) { $this->fatalError($node->expr, 'Cannot divide or modulo by zero'); } @@ -1201,7 +1199,7 @@ protected function parseNativePropertyAssignOp(Expr\AssignOp $node, string $op): // A direct zend_long reference would bypass that behavior completely. // Native objects cannot cross the Variant boundary and retain their // native C++ property access path. - if (!$this->nativeTypes + if ($this->varIntTypes && $def->type === Type::INT && !$this->isNativeObjectClass($this->detectClassOfExpr($node->var->var)) && in_array($op, ['+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '&=', '|=', '^='], true) diff --git a/docker/CompilerBase.php b/docker/CompilerBase.php index de45868..15f0820 100644 --- a/docker/CompilerBase.php +++ b/docker/CompilerBase.php @@ -153,6 +153,83 @@ class CompilerBase implements PropertyAccessContext use LoopVarOptimizer; use SsaPropOptimizer; + /** + * Dynamic reference slots can contain Throwable objects. Defer their + * validation to throwValue(), just like ordinary dynamic variables. + */ + protected function parseThrow(mixed $expr): string + { + if ($this->method === '__destruct') { + $this->warning($expr, "Throwing exception in {$this->getFullClassName()}::__destruct() may cause memory leak"); + } + $class = $this->detectDeclaredClassOfExpr($expr->expr); + if ($this->isNativeObjectClass($class)) { + $this->fatalError($expr, 'Native objects cannot be thrown as Zend exceptions'); + } + $type = $this->detectTypeOfExpr($expr->expr); + if ($this->isNewExpr($expr->expr)) { + $ex = $this->parseExpr($expr->expr); + return 'php::throwException(' . $ex . ')'; + } + if ($this->isVarExpr($expr->expr)) { + $ex = $this->parseIdentifier($expr->expr); + if ($type === Type::OBJECT) { + return 'php::throwException(' . $ex . ')'; + } + } else { + $ex = $this->parseExpr($expr->expr); + } + if (!in_array($type, [Type::VAR, Type::REF, Type::OBJECT], true) && $class === '') { + $this->fatalError($expr, 'Can only throw objects'); + } + return 'php::throwValue(' . $ex . ')'; + } + + /** + * Parameterized toArray() methods on ordinary PHP classes are application + * APIs, not TypePHP conversion hooks (for example JsonResource::toArray(Request)). + */ + protected function assertKeywordConversionMethodSignature( + NodeAbstract $node, + string $class, + string $method, + FunctionDef $function, + string $expectedType, + bool $nativeClass, + ): void { + $kind = $nativeClass ? 'Native conversion method' : 'Conversion method'; + if ($function->argInfoList !== []) { + if (!$nativeClass && strtolower($method) === 'toarray') { + return; + } + $this->fatalError($node, "{$kind} `{$class}::{$method}()` must not accept arguments"); + } + $hasExactReturnType = $function->returnType === $expectedType; + if ($expectedType === Type::VAR) { + $hasExactReturnType = in_array(strtolower($function->returnTypeStr), ['mixed', 'any'], true); + } + if ($function->returnsByRef || $function->returnNullable || !$hasExactReturnType) { + $expectedTypeName = match ($expectedType) { + Type::INT => 'int', + Type::FLOAT => 'float', + Type::STR => 'string', + Type::BOOL => 'bool', + Type::ARRAY => 'array', + Type::STREAM => 'Stream', + Type::BIGINT => 'BigInt', + Type::BIGFLOAT => 'BigFloat', + Type::DECIMAL => 'Decimal', + Type::OBJECT => 'object', + Type::VAR => 'mixed` or `any', + default => $expectedType, + }; + $this->fatalError( + $node, + "{$kind} `{$class}::{$method}()` must return exactly `{$expectedTypeName}`", + ); + } + } + public const string DEFAULT_PHP_VERSION = '8.5'; protected const string NATIVE_PROPERTY_VALUE_VAR = 'var'; protected const string NATIVE_PROPERTY_VALUE_DYNAMIC = 'dynamic'; @@ -490,7 +567,7 @@ protected function getBoolValue(Expr\ConstFetch $expr): string protected array $nativeClassDeclarations = []; /** @var array Request-reset initialization flags for Native static locals. */ protected array $nativeStaticInitializers = []; - protected bool $nativeTypes = false; + protected bool $varIntTypes = false; protected bool $decimalTypes = false; protected bool $bigintTypes = false; protected string $rootPath; @@ -997,7 +1074,7 @@ protected function removeCommonPrefix(string $short, string $long): string return $this->getPlatform()->removeCommonPrefix($short, $long); } - protected function getVarType(string $name): string + protected function getRawVarType(string $name): string { if ($this->hasLocalVar($name)) { return $this->context->localVars[$name]; @@ -1009,6 +1086,16 @@ protected function getVarType(string $name): string return Type::VAR; } + /** + * Return the value type visible to expressions. A native C++ reference has + * the same operators and assignment rules as its referenced value; only + * ABI/binding code should inspect getRawVarType(). + */ + protected function getVarType(string $name): string + { + return Type::getReferencedType($this->getRawVarType($name)); + } + /** * Resolve the ClassDef for an object expression (variable or $this). */ @@ -1071,7 +1158,7 @@ protected function resetClass(): void protected function resetFile(): void { $this->indentLevel = 0; - $this->nativeTypes = false; + $this->varIntTypes = false; $this->decimalTypes = false; $this->bigintTypes = false; $this->classesDefineInFile = []; @@ -1348,6 +1435,21 @@ protected function getFunctionCallCache(): string return 'typephp_get_function_call_cache(FunctionCallCacheId{' . $id . '})'; } + /** Return the function-local late-static-bound class entry. */ + protected function getCalledCeExpr(): string + { + $this->context->needsCalledCe = true; + return '_typephp_called_ce'; + } + + /** Return the function-local late-static-bound class name. */ + protected function getCalledClassExpr(): string + { + $this->context->needsCalledCe = true; + $this->context->needsCalledClass = true; + return '_typephp_called_class'; + } + protected function getClassEntryPtr(string $className): string { $id = $this->getClassId($className); @@ -2499,9 +2601,9 @@ protected function parseReturn(Node\Stmt\Return_ $v): string // runtime overflow promotes the result to float. Keep the Variant // representation through the return boundary so a declared scalar // return type observes and rejects that float exactly as PHP does. - // `use native_types` intentionally opts into native C++ arithmetic + // `use varint_types` intentionally opts into native C++ arithmetic // semantics and is therefore excluded from this check. - if (!$this->nativeTypes && $type === Type::INT && $this->exprCanOverflowInt($v->expr)) { + if ($this->varIntTypes && $type === Type::INT && $this->exprCanOverflowInt($v->expr)) { $type = Type::VAR; } $nativeExpressionClass = $this->detectClassOfExpr($v->expr); @@ -2831,9 +2933,21 @@ protected function getNativeMethod(CallLike $expr, string $class, string $method } if (!$this->hasClass($classDef->extends)) { if ($classDef->inheritedFromInternalClass) { - if (!Reflection::hasMethod($classDef->extends, $method) and !Reflection::hasMethod($classDef->extends, $method . '__call')) { + $lateStaticCall = $expr instanceof Expr\StaticCall + && $this->isNameExpr($expr->class) + && strtolower($expr->class->toString()) === 'static'; + if ($lateStaticCall && !$this->isCurrentClassFinal()) { + return false; + } + $magicMethod = $expr instanceof Expr\StaticCall ? '__callStatic' : '__call'; + if ($classDef->hasMethod($magicMethod)) { + return false; + } + if (!Reflection::hasMethod($classDef->extends, $method) + && !Reflection::hasMethod($classDef->extends, $magicMethod) + ) { $this->fatalError($expr, 'Class `' . $classDef->getNamespacedName() . '` inherits from a internal class, but the class `' . - $classDef->extends . '` does not have a `' . $method . '` method or a `__call` magic method'); + $classDef->extends . '` does not have a `' . $method . '` method or a `' . $magicMethod . '` magic method'); } else { $this->climate->cyan('Dynamically calling internal class method `' . $classDef->extends . '::' . $method . '()`'); throw new DynamicCall(); @@ -3015,7 +3129,7 @@ protected function detectTypeOfExpr($expr): string case 'Expr_UnaryPlus': $innerType = $this->detectTypeOfExpr($expr->expr); if ( - !$this->nativeTypes + $this->varIntTypes && $exprType === 'Expr_UnaryMinus' && $innerType === Type::INT && $this->constantIntValue($expr->expr) === PHP_INT_MIN @@ -3103,7 +3217,7 @@ protected function detectTypeOfExpr($expr): string if ($leftType === Type::FLOAT || $rightType === Type::FLOAT) { return Type::FLOAT; } - if (!$this->nativeTypes && $leftType === Type::INT && $rightType === Type::INT) { + if ($this->varIntTypes && $leftType === Type::INT && $rightType === Type::INT) { $op = match ($exprType) { 'Expr_BinaryOp_Plus' => '+', 'Expr_BinaryOp_Minus' => '-', @@ -3936,7 +4050,7 @@ protected function parseNew(Expr\New_ $expr): string if ($this->classDef?->nativeObject) { $this->fatalError($expr, 'Native classes do not support `new static()`'); } - $cePtr = Symbol::getCalledCe(); + $cePtr = $this->getCalledCeExpr(); } else { if ($className === 'self') { $className = $this->getFullClassName(); @@ -4134,7 +4248,7 @@ protected function resolveInstanceofClassPtr(NodeAbstract $class): string if (!$this->classDef) { $this->fatalError($class, 'Cannot use "static" outside a class'); } - return Symbol::getCalledCe(); + return $this->getCalledCeExpr(); } else { $className = $this->getNamespacedClassName($className); } @@ -5217,6 +5331,16 @@ protected function genLocalVarDecl(array $localVars): string protected function genScopeVarDecl(): string { $code = ''; + if ($this->context->needsCalledCe) { + $code .= $this->getIndent() + . 'zend_class_entry *const _typephp_called_ce = typephp_get_called_ce(this_);' + . PHP_EOL; + } + if ($this->context->needsCalledClass) { + $code .= $this->getIndent() + . 'php::Str const _typephp_called_class = typephp_get_called_class(_typephp_called_ce);' + . PHP_EOL; + } if ($this->context->hasMultiLevelBreak) { $code .= $this->getIndent() . 'int _brk_flag = 0;' . PHP_EOL; } @@ -5276,13 +5400,11 @@ protected function genScopeVarDecl(): string $code .= $this->getIndent() . $info['type'] . ' &' . $name . ' = ' . $zvalMacro . '(' . $info['getter'] . '.unwrap_ptr());' . PHP_EOL; } } - foreach ($this->context->staticPropRefs as $name => $info) { - $getter = Symbol::getStaticProperty() . '(' . $info['classPtr'] . ', ' . $info['offsetExpr'] . ')'; - if (($info['kind'] ?? 'zval') === 'var') { - $code .= $this->getIndent() . Type::VAR . ' ' . $name . ' = ' . $getter . ';' . PHP_EOL; - } else { - $code .= $this->getIndent() . 'zval *' . $name . ' = ' . $getter . '.unwrap_ptr();' . PHP_EOL; - } + foreach ($this->context->staticPropRefs as $info) { + $code .= $this->getIndent() . 'zval *' . $info['name'] . ' = nullptr;' . PHP_EOL; + $code .= $this->getIndent() . 'const auto ' . $info['accessorName'] . ' = [&]() {' + . ' return typephp_get_static_property_cached(' . $info['name'] . ', [&]() {' + . ' return ' . $info['resolver'] . '; }); };' . PHP_EOL; } return $code; } diff --git a/docker/Dockerfile b/docker/Dockerfile index ea6cb37..04fa504 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,6 +10,10 @@ COPY CompilerDiagnosticTrait.php /opt/typephp/vendor/swoole/typephp/src/Diagnost COPY gen_stub.php /opt/typephp/vendor/swoole/typephp/src/gen_stub.php COPY SwitchTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/SwitchTrait.php COPY CompilerBase.php /opt/typephp/vendor/swoole/typephp/src/CompilerBase.php +COPY patch_native_type_compatibility_trait.php /tmp/patch_native_type_compatibility_trait.php +RUN php /tmp/patch_native_type_compatibility_trait.php && rm /tmp/patch_native_type_compatibility_trait.php +COPY patch_method_call_trait.php /tmp/patch_method_call_trait.php +RUN php /tmp/patch_method_call_trait.php && rm /tmp/patch_method_call_trait.php COPY ArrayExpressionTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/ArrayExpressionTrait.php COPY FuncCallOptimizer.php /opt/typephp/vendor/swoole/typephp/src/Optimizer/FuncCallOptimizer.php COPY AssignOpTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/AssignOpTrait.php diff --git a/docker/FuncCallOptimizer.php b/docker/FuncCallOptimizer.php index 7e5cf83..d563724 100644 --- a/docker/FuncCallOptimizer.php +++ b/docker/FuncCallOptimizer.php @@ -1202,7 +1202,8 @@ protected function genCompact(string $name, Node\Expr\FuncCall $expr, array $con $this->fatalError($expr, 'The argument of compact function can only be literal string'); } $var = $arg->value->value; - if (!$this->hasVar($var) && $var !== 'this') { + $cVar = $this->escapeVarName($var); + if (!$this->hasVar($cVar) && $var !== 'this') { $this->fatalError($arg->value, "Undefined variable `{$var}` in compact()"); } if ($this->isSuperGlobal($var)) { @@ -1215,7 +1216,6 @@ protected function genCompact(string $name, Node\Expr\FuncCall $expr, array $con $this->fatalError($expr, 'Cannot use compact("this") outside of class method'); } } - $cVar = $this->escapeVarName($var); $list[] = '{' . $key . '.str(), php::Var(' . $cVar . ')}'; } diff --git a/docker/Translator.php b/docker/Translator.php index 1183dbe..05cca88 100644 --- a/docker/Translator.php +++ b/docker/Translator.php @@ -55,6 +55,7 @@ use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\NodeAbstract; +use PhpParser\NodeFinder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\NodeVisitor\CloningVisitor; @@ -85,6 +86,15 @@ class Translator extends Preprocessor protected array $argInfoHeaderFiles = []; protected array $registerSymbols = []; + protected function genArgumentDeclaration(ArgInfo $argInfo): string + { + if ($argInfo->byRef) { + return Type::REF . ' ' . $argInfo->name; + } + + return parent::genArgumentDeclaration($argInfo); + } + /** Generated per-file teardown functions for persistent AST class constants. */ protected array $releaseAstConstantFns = []; @@ -1770,11 +1780,19 @@ protected function getSourceCompileCommandOptions(string $sourceFile, ?string $l return $this->getProjectRuntimeEntryCompileCommandOptions(); } - return match ($language) { + $options = match ($language) { null => $this->getCompileCommandOptions(), 'c' => $this->getCCompileCommandOptions(), default => $this->getNativeCompileCommandOptions($language), }; + + // The generated registration unit is enormous but not a business hot path. + // Compiling it at -O0 bounds GCC memory without weakening user source optimization. + if (str_starts_with(basename($sourceFile), 'extension-')) { + return $options->with('optimize', 0); + } + + return $options; } protected function buildCompileFileCommand(string $sourceFile, string $objectFile): string @@ -1810,12 +1828,24 @@ public function compile(array $sourceFiles): array // Windows: compile the resource file (icon, version info, etc.) $this->compileResourceFile(); + $serialObjects = []; + if ($this->getPlatform()->supportsPcntlParallelCompile() && $job > 1) { + foreach ($sourceFiles as $index => $sourceFile) { + if (str_starts_with(basename($sourceFile), 'extension-')) { + $this->climate->lightBlue('Compiling generated extension unit serially to bound peak memory'); + $serialObjects = $this->compileSourceFile([$sourceFile]); + unset($sourceFiles[$index]); + } + } + $sourceFiles = array_values($sourceFiles); + } + if (!$this->getPlatform()->supportsPcntlParallelCompile() or $job <= 1) { return $this->compileSourceFile($sourceFiles); } // Unix/Linux/macOS compile in parallel using pcntl - return $this->compileWithPcntl($sourceFiles, $job); + return array_merge($serialObjects, $this->compileWithPcntl($sourceFiles, $job)); } protected function preparePhpXPrecompiledHeader(): void @@ -4791,9 +4821,13 @@ protected function genWrapperFunctionArgs( $argExpr = 'php::getCallArg(' . $k . ')'; } } - $cppType = $this->getDefaultArgumentType($argInfo); + $cppType = $argInfo->byRef + ? Type::REF + : $this->getDefaultArgumentType($argInfo); $declaredClass = $argInfo->declaredClass ?: $argInfo->class; - if ($this->isStrictScalarType($argInfo->type)) { + if ($argInfo->byRef) { + $expr = $argExpr; + } elseif ($this->isStrictScalarType($argInfo->type)) { $rawVar = 'raw_' . $var; $cppCode .= $this->getIndent() . Type::VAR . ' ' . $rawVar . ' = ' . $argExpr . ';' . PHP_EOL; $cppCode .= $this->genStrictScalarParamCheck( @@ -4957,6 +4991,33 @@ protected function genClassWrapper(ClassDef|InterfaceDef $classDef): string /** * @throws \Exception */ + /** + * PHP closure references are dynamic Zend reference slots even when their + * current value is scalar or array. Mark them before statement generation + * so an earlier assignment is not narrowed to incompatible C++ storage. + * + * @param list $stmts + */ + private function markClosureReferenceVariables(array $stmts): void + { + $finder = new NodeFinder(); + /** @var list $closures */ + $closures = $finder->findInstanceOf($stmts, Node\Expr\Closure::class); + foreach ($closures as $closure) { + foreach ($closure->uses as $useItem) { + if ( + !$useItem->byRef + || !$useItem->var instanceof Node\Expr\Variable + || !is_string($useItem->var->name) + ) { + continue; + } + $name = $this->parseIdentifier($useItem->var); + $this->context->localVars[$name] = Type::REF; + } + } + } + protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): string { $this->resetFunction(); @@ -4991,9 +5052,11 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): } } foreach ($this->functionDef->argInfoList as $argInfo) { - $argumentType = $argInfo->variadic + $argumentType = $argInfo->byRef + ? Type::REF + : ($argInfo->variadic ? Type::ARRAY - : ($this->getNativeObjectArgumentType($argInfo) ?? $argInfo->type); + : ($this->getNativeObjectArgumentType($argInfo) ?? $argInfo->type)); $this->addArgument($argInfo->name, $argumentType); if (!$argInfo->variadic and $argInfo->declaredClass) { $this->addObject($argInfo->name, $argInfo->declaredClass); @@ -5007,6 +5070,9 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): } } $this->initializeImmutableFunctionContext(); + if ($v->stmts) { + $this->markClosureReferenceVariables($v->stmts); + } if ($this->functionDef->generator) { try { @@ -5029,10 +5095,10 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): $this->context->ssaBuilder = $ssaBuilder; $this->analyzeStableObjects($ssaBuilder); // Range-proven loop counters are safe to narrow even without - // `use native_types`: the optimizer rejects counters whose PHP + // `use varint_types`: the optimizer rejects counters whose PHP // integer semantics could widen to float or otherwise escape. $optimizedLoopVars = $this->optimizeLoopVars($ssaBuilder); - if ($this->nativeTypes) { + if ($this->varIntTypes) { // Narrow local variable types based on SSA analysis. $this->optimizeVarTypes($ssaBuilder); // Narrow native property accesses. @@ -5049,7 +5115,6 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): $this->context->localVars[$varName] = $type; } } - $stmts = ''; $this->indentLevel++; try { diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7dc8f30..e698480 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -114,9 +114,13 @@ fi # Parallelize the C++ codegen/compile phase across available cores. job:1 makes # the ~371-file g++ phase take 40+ minutes (exceeding the command timeout); -# cap at 8 to bound peak memory since each PHPX-template g++ job can be large. -jobs="$(nproc 2>/dev/null || echo 2)" -[[ "$jobs" -gt 8 ]] && jobs=8 +# cap at 4 to bound peak memory since each PHPX-template g++ job can be large. +jobs="${TYPEPHP_JOBS:-$(nproc 2>/dev/null || echo 2)}" +if [[ ! "$jobs" =~ ^[1-9][0-9]*$ ]]; then + echo '[ERROR] TYPEPHP_JOBS must be a positive integer.' >&2 + exit 2 +fi +[[ "$jobs" -gt 4 ]] && jobs=4 [[ "$jobs" -lt 1 ]] && jobs=1 sed -i "s/^job:.*/job: ${jobs}/" "$project_file" @@ -279,8 +283,26 @@ for resource in config public; do done mkdir -p "$stage_dir/runtime/logs" "$stage_dir/runtime/views" if [[ -d app/view ]]; then mkdir -p "$stage_dir/app"; cp -a app/view "$stage_dir/app/"; fi -if [[ -f app/functions.php ]]; then copy_file app/functions.php "$stage_dir/app/functions.php"; fi +if [[ -f app/functions.php && ! -f "$build_dir/source-coverage.json" ]]; then + copy_file app/functions.php "$stage_dir/app/functions.php" +fi +runtime_resources_file="$build_dir/runtime-resources.list" +if [[ -f "$runtime_resources_file" ]]; then + while IFS= read -r resource || [[ -n "$resource" ]]; do + [[ -z "$resource" ]] && continue + if ! validate_relative_path "$resource"; then + echo "[ERROR] Unsafe runtime resource path in manifest: $resource" >&2 + exit 2 + fi + if [[ -e "$workspace/$resource" ]]; then + mkdir -p "$stage_dir/$(dirname -- "$resource")" + cp -a "$workspace/$resource" "$stage_dir/$resource" + fi + done < "$runtime_resources_file" + copy_file "$runtime_resources_file" "$stage_dir/runtime-resources.list" +fi [[ -f "$build_dir/build-manifest.json" ]] && copy_file "$build_dir/build-manifest.json" "$stage_dir/build-manifest.json" +[[ -f "$build_dir/source-coverage.json" ]] && copy_file "$build_dir/source-coverage.json" "$stage_dir/source-coverage.json" readonly final_dir="$real_workspace/$output_dir" if [[ -e "$final_dir" ]]; then @@ -288,6 +310,7 @@ if [[ -e "$final_dir" ]]; then echo "[ERROR] Output '$output_dir' exists; use --force to replace it." >&2 exit 1 fi + mkdir -p "$real_workspace/.typephp" mv "$final_dir" "$real_workspace/.typephp/previous-$build_id" fi # stage_dir is on native storage while final_dir is on the 9P bind mount, so this diff --git a/docker/gen_stub.php b/docker/gen_stub.php index c4962c1..bdb31e2 100644 --- a/docker/gen_stub.php +++ b/docker/gen_stub.php @@ -58,6 +58,11 @@ PHP_85_VERSION_ID, ]; +function &refval(mixed &$value): mixed +{ + return $value; +} + // file_put_contents() but with a success message printed after saving function reportFilePutContents(string $filename, string $content): void { getTranslator()->writeFile($filename, $content); diff --git a/docker/patch_method_call_trait.php b/docker/patch_method_call_trait.php new file mode 100644 index 0000000..3411e4f --- /dev/null +++ b/docker/patch_method_call_trait.php @@ -0,0 +1,94 @@ +args !== []) { + $this->fatalError($expr, "The {$methodName} method does not accept parameters"); + } + if ($methodName === 'toObject') { + return $this->genToObjectCall($expr, $object); + } + if ($methodName === 'toRef') { + return $this->genToRefCall($expr); + } + $receiverClass = $class; + if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { + $receiverClass = $this->detectClassOfExpr($expr->var); + } + // A declared conversion method is called directly. Otherwise + // php::toArray() applies the PHP-compatible object-property + // fallback (and invokes a real toArray() method when present). + $useDeclaredToArray = $methodName === 'toArray' + && $receiverClass !== '' + && $this->objectTypeDeclaresMethod($receiverClass, $methodName); + if (!$useDeclaredToArray) { + return $this->genToConvertCall($object, $methodName, $receiverType); + } +PHP; + +$after = <<<'PHP' + $receiverClass = $class; + if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { + $receiverClass = $this->detectClassOfExpr($expr->var); + } + // A declared toArray(...) API is an ordinary PHP method, not + // the argument-free TypePHP conversion keyword. + $useDeclaredToArray = $methodName === 'toArray' + && $receiverClass !== '' + && $this->objectTypeDeclaresMethod($receiverClass, $methodName); + $useDynamicToArray = $methodName === 'toArray' + && ($receiverClass === '' + || $receiverType === Type::VAR + || $this->objectTypeDeclaresMethod($receiverClass, '__call')); + $useObjectToArray = $useDeclaredToArray || $useDynamicToArray; + if (!isset(self::KEYWORD_METHOD_WITH_ARGUMENTS[$methodName]) + && $expr->args !== [] + && !$useObjectToArray + ) { + $this->fatalError($expr, "The {$methodName} method does not accept parameters"); + } + if ($methodName === 'toObject') { + return $this->genToObjectCall($expr, $object); + } + if ($methodName === 'toRef') { + return $this->genToRefCall($expr); + } + if (!$useObjectToArray) { + return $this->genToConvertCall($object, $methodName, $receiverType); + } +PHP; + +if (substr_count($source, $before) !== 1) { + throw new RuntimeException('Unexpected MethodCallTrait keyword-dispatch source'); +} + +$patched = str_replace($before, $after, $source); + +$lateStaticBefore = <<<'PHP' + ): ?string { + if ($expr->args !== [] || !$this->classDef || !$this->methodDef) { +PHP; +$lateStaticAfter = <<<'PHP' + ): ?string { + // SaiAdmin overlay: preserve dynamic late-static dispatch. + return null; + + if ($expr->args !== [] || !$this->classDef || !$this->methodDef) { +PHP; +if (str_contains($patched, 'private function parseExactLateStaticCall(')) { + if (substr_count($patched, $lateStaticBefore) !== 1) { + throw new RuntimeException('Unexpected MethodCallTrait late-static optimization source'); + } + $patched = str_replace($lateStaticBefore, $lateStaticAfter, $patched); +} + +if (file_put_contents($path, $patched) === false) { + throw new RuntimeException("Unable to write {$path}"); +} diff --git a/docker/patch_native_type_compatibility_trait.php b/docker/patch_native_type_compatibility_trait.php new file mode 100644 index 0000000..7b90e91 --- /dev/null +++ b/docker/patch_native_type_compatibility_trait.php @@ -0,0 +1,44 @@ +type) + ) { + return $var; + } +PHP; +$typedProxy = <<<'PHP' + $reference = $this->addTmpVar(Type::REF); + $wrapper = $this->genTmpVarName(); + $this->context->beforeStmtLines[] = $reference . ' = ' . $this->convertToRef($arg->value) . ';'; + $this->context->beforeStmtLines[] = 'php::RefWrap<' . $expectedType . '> ' + . $wrapper . '(' . $reference . ');'; + $this->context->afterStmtLines[] = $wrapper . '.commit();'; + return $wrapper . '.typed()'; +PHP; +foreach ([$typedDirect, $typedProxy] as $typedRefBoundary) { + if (substr_count($source, $typedRefBoundary) !== 1) { + throw new RuntimeException('Unexpected typed reference ABI boundary in NativeTypeCompatibilityTrait.php'); + } + $source = str_replace($typedRefBoundary, '', $source); +} + +$patched = str_replace($before, $after, $source); +if (file_put_contents($path, $patched) === false) { + throw new RuntimeException('Unable to patch NativeTypeCompatibilityTrait.php'); +} diff --git a/docs/saiadmin-aot/README.md b/docs/saiadmin-aot/README.md new file mode 100644 index 0000000..ef1f8db --- /dev/null +++ b/docs/saiadmin-aot/README.md @@ -0,0 +1,30 @@ +# Webman TypePHP AOT 配套 + +本目录是 `tinywan/webman-typephp` 的使用规范、迁移指南、配置样例和验收脚本,不包含另一套编译器或打包插件。 + +推荐流程: + +1. 安装并锁定 `tinywan/webman-typephp`、builder、PHP、Webman 和 Composer 依赖。 +2. 按 [开发规范](docs/development-standard.md) 约束新代码。 +3. 对照 [支持矩阵](docs/compatibility-matrix.md),存量项目按 [迁移指南](docs/migration-guide.md) 只处理真实编译错误。 +4. Linux portable-dir 使用 `php webman typephp:doctor` 和 + `php webman typephp:package --profile=saiadmin`;宿主机原生编译使用 + `php webman typephp:doctor --target=native` 和 + `php webman typephp:compile --profile=saiadmin`。 +5. 用 `scripts/verify-package.sh` 检查产物契约。 +6. 仅在隔离环境中,用 `scripts/accept-linux.sh` 完成验证码、登录、用户信息和权限拒绝验收。 + +本 PR 的 Linux amd64 完整编译、产物检查、启动和业务验收记录见 +[验证证据](docs/verification-evidence.md)。 + +边界: + +- 业务 PHP 必须进入 AOT 编译,不得借 `ignore` 静默回退解释执行。 +- 配置、模板、静态数据和明确登记的第三方动态资源可以随包。 +- AOT 专用兼容改写只生成到 `.typephp/build/`;普通 PHP源码和执行路径保持不变。 +- 不因 AOT 适配修改 Webman;SaiAdmin 只修复已复现、无法由生成副本解决的问题。 +- 构建成功不等于运行成功,静态检查不等于业务验收。 +- `typephp:compile` 生成当前宿主平台程序,不等同于 Linux portable-dir;macOS 产物不能作为 + Linux 发布物。 + +本目录不含任何业务仓库源码、数据、凭据、内网地址或构建产物。 diff --git a/docs/saiadmin-aot/config/typephp-aot.example.php b/docs/saiadmin-aot/config/typephp-aot.example.php new file mode 100644 index 0000000..2ac08aa --- /dev/null +++ b/docs/saiadmin-aot/config/typephp-aot.example.php @@ -0,0 +1,29 @@ + true, + 'docker' => [ + // 发布使用时可进一步固定为 registry tag@sha256:digest。 + 'image' => 'tinywan/typephp-webman-builder:v0.1.3', + ], + 'build' => [ + 'output_name' => 'webman-server', + 'dist_dir' => 'dist', + 'clean_build' => true, + ], + 'runtime_resources' => [ + 'config', + 'public', + 'app/view', + // 只增加模板、静态数据或经登记的第三方动态资源。 + ], + 'ignore' => [ + 'config', + 'public', + 'runtime', + 'app/view', + // 不得增加自有业务 PHP 目录。 + ], +]; diff --git a/docs/saiadmin-aot/docs/compatibility-matrix.md b/docs/saiadmin-aot/docs/compatibility-matrix.md new file mode 100644 index 0000000..b9608d6 --- /dev/null +++ b/docs/saiadmin-aot/docs/compatibility-matrix.md @@ -0,0 +1,63 @@ +# SaiAdmin AOT 支持矩阵 + +发布型 portable-dir 首版只验收 Linux amd64;宿主机原生模式另已验证 macOS arm64。 +profile 不对 Webman、Workerman 或 SaiAdmin 增加版本白名单;依赖是否可安装由 Composer +约束决定。表中的版本是已验证记录,不代表未列出版本自动兼容;源码结构、安装副本或 +锁定依赖漂移仍会失败关闭。 + +| 组件 | 支持/锁定版本 | +| --- | --- | +| PHP | 8.4.25 | +| TypePHP Linux builder | `tinywan/typephp-linux-x64:v0.8.0@sha256:f18cac640edf52126acc1ad781f220f9fe547f7c8db925dcc38d4854f9436f90` | +| SaiAdmin | profile 无额外版本门禁;已验证 `6.1.1`、`6.1.5` | +| ThinkORM | v3.0.34 | +| Carbon | 3.13.2 | +| Webman framework | 沿用插件 Composer 约束 `^1.5.4 \|\| ^2.0 \|\| dev-master`;已验证 `v2.2.4` 和记录中的 `dev-master` | +| Workerman | profile 无额外版本门禁;已验证 `v5.2.2` 和记录中的 `dev-master` | + +## 非 Docker 原生模式 + +| 宿主平台 | PHP embed / TypePHP | SaiAdmin | 编译 | 启动与 HTTP | 结论 | +| --- | --- | --- | --- | --- | --- | +| macOS arm64 | PHP ZTS 8.5.10 / TypePHP 0.9.0 | 6.1.5 | 2,056 / 2,056,通过 | 验证码和未登录权限拒绝通过 | 当前宿主原生验证通过 | + +原生模式要求 PHP 可执行文件、embed 头文件与 `libphp`、PHPX 来自同一 ABI。它生成 +当前宿主平台程序,不是跨平台编译:macOS 产物是 Mach-O,不能作为 Linux 发布物。 + +## 已验证版本 + +| SaiAdmin | Linux amd64 编译/打包 | 隔离数据库业务验收 | 结论 | +| --- | --- | --- | --- | +| 6.1.1 | 通过 | 验证码、登录、用户信息、权限拒绝、普通 PHP 对照均通过 | 完整验证 | +| 6.1.5 | 通过 | MySQL 下验证码、登录、用户信息、权限拒绝通过 | AOT 业务验证完成;普通 PHP 8.4 权限异常路径仍受上游隐式 nullable deprecation 影响 | + +SaiAdmin 6.1.5 的最小变更依赖组合新增 CakePHP Chronos `3.5.1`, +CakePHP Core/Database/Datasource/Event/Utility `5.4.2`、League Container +`5.2.0`、Phinx `0.16.12`、Symfony Config `8.1.5` 和 Symfony Filesystem +`8.1.6`,并将 PHPMailer 升至 `7.1.1`、IP2Region 升至 `3.0.15`。升级时应使用 Composer +`--minimal-changes`,避免无关依赖整体漂移。 + +## 已验证的兼容规则 + +| 归属 | 问题类别 | AOT 处理 | +| --- | --- | --- | +| Webman / Workerman | 顶层守卫与初始化、固定参数回调、引用捕获、switch 落空、运行期清理 | 生成锁定结构的副本,并在入口补回等价初始化 | +| Workerman Coroutine | Fiber 上下文与连接池对空 `ArrayObject`/`WeakMap` 的维度写入被 TypePHP 错译为读取 | 在 AOT 副本中使用等价的 `offsetSet()`,并保留缺失、重复及结构漂移失败 | +| ThinkORM | 参数与局部变量类型漂移、动态解析调用参数数目、结果集跨类型、运行期连接状态清理、Collection 回调少声明键参数 | 生成类型稳定且调用参数显式的副本,并声明集合回调的值与键参数 | +| Carbon | 变量变量、魔术单位调用、继承常量、DatePeriod 声明顺序和类型漂移 | 生成显式方法、常量和稳定局部变量副本 | +| SaiAdmin | 验证码颜色/字体、编译类 protected 默认属性反射缺失、零参控制器无法接收 Webman 请求参数、缓存标签变量跨类型、异常原因隐式可空 | 使用随包字体;从当前源码静态提取登录与安装匿名动作;生成显式 `Request` 参数、类型稳定缓存分支和显式 nullable 异常副本 | +| SaiAdmin 6.1.5 第三方依赖 | CakePHP/Phinx/League Container 的安装与迁移工具依赖 TypePHP v0.8 无法有界表示的动态写法 | 作为明确登记的第三方动态运行资源随包;SaiAdmin 核心、登录、权限、模型及自有业务不得进入该清单 | +| IP2Region / Nelexa | v3 整数槽跨类型、PSR Stream 签名不一致 | 生成返回分支和接口签名明确的 AOT 副本 | + +规则匹配已记录的源码结构。`plugin/saiadmin` 的安装副本还必须与 +`vendor/saithink/saiadmin/src/plugin/saiadmin` 内容一致。未知源码结构、零命中、重复命中、 +安装副本漂移或无法证明等价的写法必须终止构建;不得改成运行时解释业务代码。 + +## 未承诺范围 + +- Windows 可执行文件和 DLL。 +- 将 macOS 原生产物当作 Linux portable-dir 发布。 +- 未来 SaiAdmin 或依赖版本的自动兼容。 +- 未经完整编译和业务验收的第三方插件。 +- PostgreSQL 业务运行尚未验收;当前完整数据库业务证据使用 MySQL 8.4。 +- 依赖动态 `include`、`eval`、闭包 rebinding 或不稳定魔术调用且无法静态等价转换的业务功能。 diff --git a/docs/saiadmin-aot/docs/development-standard.md b/docs/saiadmin-aot/docs/development-standard.md new file mode 100644 index 0000000..279aaef --- /dev/null +++ b/docs/saiadmin-aot/docs/development-standard.md @@ -0,0 +1,40 @@ +# AOT 开发规范 + +## 依赖与构建 + +- `composer.lock`、builder 镜像标签及 digest、PHP 小版本、Webman/Workerman 提交和扩展集合必须作为一次验收组合记录。 +- 使用项目内 `tinywan/webman-typephp` 命令生成入口、编译配置和 portable-dir,不复制其编译与打包逻辑。 +- Linux amd64 构建显式使用 `--platform linux/amd64`;发布前在目标 glibc 基线或更旧基线上验收。 + +## 代码边界 + +- 业务控制器、Logic、Service、Model、中间件、命令和进程代码必须编译。 +- `ignore` 只允许配置、模板、静态数据、安装器/测试夹具以及明确登记的第三方动态资源。 +- 被 `ignore` 的 PHP 声明必须由插件生成等价 `.typephp/build/*.php` 并参与编译,或被列为非业务运行时动态代码并说明原因、所有者和验收方式。 +- 不用“功能未调用”掩盖编译失败。未支持能力应在 AOT 路径显式失败,并保留普通 PHP 行为。 +- PHP 文件顶层只允许声明;禁止在顶层执行函数调用、条件分支或初始化语句。 +- 禁止变量变量和无法静态确定目标的魔术静态调用;业务代码禁止动态 `include`、`require` 和 `eval`。 +- 一个局部变量不得在同一作用域跨不兼容类型重复赋值;需要不同类型时拆成具有明确含义的变量。 +- `switch` 的每个非空分支必须显式 `break`、`return` 或 `throw`,禁止依赖 fall-through。 +- 调用参数必须符合声明;允许接收额外参数的回调应显式声明可变参数。 +- 动态调用不得依赖参数的隐式引用语义;引用参数必须在声明和调用处明确并能被 TypePHP 静态分析。 +- 禁止业务代码使用 `Closure::bind()`、`Closure::bindTo()` 或 `Closure::call()` 改写闭包作用域。 +- 上述规则若能安全且等价转换,可以只在 `.typephp/build/` 生成 AOT 副本;不能证明等价时必须报告文件、行号、规则和迁移建议,并终止出包。 + +## 修改原则 + +- 先保留失败日志和最小复现,再增加针对性 fixture。 +- 优先修改插件生成的 AOT 副本;不直接修改 vendor。 +- 每个替换必须锁定输入字面量或结构,版本漂移时失败或由测试暴露。 +- 同一阻碍两轮没有新证据即停止扩张,记录编译器限制或上游缺口。 + +## 验收分层 + +1. 插件测试和 PHP 语法检查。 +2. Linux amd64 全量编译成功。 +3. portable-dir 文件、ELF 架构、动态库、配置和运行资源检查。 +4. 隔离环境启动成功。 +5. 登录成功及至少一条权限拒绝路径成功。 +6. 相同改动的普通 PHP 回归。 + +任何上一层通过都不能替代下一层。 diff --git a/docs/saiadmin-aot/docs/migration-guide.md b/docs/saiadmin-aot/docs/migration-guide.md new file mode 100644 index 0000000..2e23e86 --- /dev/null +++ b/docs/saiadmin-aot/docs/migration-guide.md @@ -0,0 +1,72 @@ +# 存量项目迁移指南 + +## 1. 建立基线 + +记录当前提交和工作树状态,PHP、Composer、Docker、Webman、Workerman、TypePHP、builder 与扩展版本,以及普通 PHP 回归和原始编译 Fatal。不要运行安装器或数据库初始化。 + +profile 不额外限制 Webman、Workerman 或 SaiAdmin 版本,不要为了通过 profile 检查 +切换项目依赖。先保留项目现有 Composer 锁定组合;若兼容规则因源码结构变化失败, +再以实际编译错误扩展规则并补充验证记录。 + +旧版配置中排除 `app/model`、`support` 等业务源码目录的规则会由 SaiAdmin profile +自动移除;这些 PHP 即使暂未被引用也必须进入 AOT,不应通过删除文件绕过覆盖检查。 + +## 2. 划分输入 + +- 必须编译:所有自有业务 PHP。 +- 随包资源:配置、模板、静态文件和语言/数据文件。 +- 明确登记的第三方动态代码:仅在编译器当前不能表示、且运行时加载方案已经验收时使用。 + +逐项检查生成的 `project.linux.yml`。业务文件被 `ignore` 时,必须能对应到参与编译的 `.typephp/build` 等价副本;否则迁移失败。 + +## 3. 逐个处理真实错误 + +每轮只处理日志中的首个阻碍:确认失败文件和锁定版本,判断责任层,在现有插件增加最小 AOT 生成改写及 fixture,然后重新全量编译。只有出现新失败位置才算取得新证据;禁止排除整块业务目录来“先出包”。 + +升级了插件的 `main.php.stub` 后,存量项目必须显式使用 `--refresh-main` 重建入口并审查备份,不能只更新依赖后沿用旧入口。 + +升级 SaiAdmin 时使用 Composer `--minimal-changes`,并确认 +`plugin/saiadmin` 已与 `vendor/saithink/saiadmin/src/plugin/saiadmin` 同步。 +profile 会拒绝版本号已升级但安装目录仍保留旧源码的项目。 + +TypePHP 会拒绝同一局部变量跨不兼容类型赋值,也可能拒绝业务代码直接读取模型的 +protected 属性。前者应拆成不同变量或分支内直接返回;后者应改用公开 accessor、 +`getAttr()` 或显式 DTO。未知自有业务写法必须修复后重新编译,不能登记为动态资源。 + +若第三方代码依赖 TypePHP 当前不能表示的运行时反射或动态行为,必须同时: + +1. 在 `runtime_resources` 中以最小文件或包目录登记; +2. 在 `ignore` 中使用相同路径; +3. 通过 Webman `autoload.files` 在使用前加载; +4. 证明自有业务 PHP 没有进入该清单。 + +## 4. 产物和运行验收 + +编译完成后: + +```bash +AOT_DIST=/absolute/path/to/dist ./scripts/verify-package.sh +``` + +准备隔离数据库、测试账号和未授权接口后: + +```bash +AOT_ACCEPT_ISOLATED=YES \ +AOT_DIST=/absolute/path/to/dist \ +AOT_BASE_URL=http://127.0.0.1:8787 \ +AOT_CAPTCHA_PATH=/core/captcha \ +AOT_LOGIN_PATH=/core/login \ +AOT_LOGIN_BODY_FILE=/absolute/path/to/login.json \ +AOT_TOKEN_PATH=data.access_token \ +AOT_USER_INFO_PATH=/core/system/user \ +AOT_DENIED_PATH=/core/post/index \ +AOT_DENIED_EXPECT_STATUS=200 \ +AOT_DENIED_EXPECT_CODE=400 \ +./scripts/accept-linux.sh +``` + +`AOT_TOKEN_PATH` 是登录 JSON 中 Bearer token 的点分路径。脚本会验证验证码响应含 UUID 和图片、登录 token、登录后用户信息,以及权限拒绝的 HTTP 状态和业务码。登录请求文件应由隔离环境夹具写入有效且尚未消费的验证码;脚本不会创建或迁移数据库,凭据只从本地文件读取且不会输出。宿主特有路径和响应断言不得进入公开仓库。 + +## 5. 回归与发布 + +重新运行普通 PHP 基线。提交、推送、镜像发布和部署均是独立授权动作;发布必须重建完整 portable-dir 后重新验收。 diff --git a/docs/saiadmin-aot/docs/verification-evidence.md b/docs/saiadmin-aot/docs/verification-evidence.md new file mode 100644 index 0000000..990af77 --- /dev/null +++ b/docs/saiadmin-aot/docs/verification-evidence.md @@ -0,0 +1,301 @@ +# SaiAdmin AOT 验证证据 + +验收编号:`AOT-SAIADMIN-LINUX-01` + +本记录对应 `feat/saiadmin-aot-profile` 的首版候选。SaiAdmin 6.1.1 的完整业务 +验收于 2026-09-14 在 Mac 上的隔离 Linux amd64 容器环境完成;SaiAdmin 6.1.5 +的构建验收于 2026-09-18 完成。原始日志包含本地路径和一次性测试账号,因此不提交 +原始文件;下文保留版本、数量、哈希、返回码和可复现命令。 + +## SaiAdmin 6.1.1 完整验收 + +## 验证结论 + +- Linux amd64 AOT 完整编译、链接和 portable-dir 打包成功。 +- 所有已发现的自有业务 PHP 和已安装插件业务 PHP 均进入 AOT 覆盖清单。 +- AOT 产物成功启动,并通过验证码、登录、登录后用户信息、权限拒绝和中立插件路由。 +- 相同业务路径在普通 PHP 下通过,AOT 适配没有改变普通运行方式。 +- 一次性测试账号、数据库卷、运行容器和隔离网络已在验收后销毁。 + +## 固定组合 + +| 项目 | 验证值 | +| --- | --- | +| 目标 | Linux amd64 / glibc portable-dir | +| builder PHP | 8.4.25 | +| TypePHP | `swoole/typephp v0.8.0` | +| builder 基线 | `tinywan/typephp-linux-x64:v0.8.0@sha256:f18cac640edf52126acc1ad781f220f9fe547f7c8db925dcc38d4854f9436f90` | +| 候选镜像 ID | `sha256:bf2dc10f8470bac08b958bf2f457969d43eb6b6a77d123c66dc7b9864f363922` | +| SaiAdmin | 6.1.1 | +| ThinkORM | v3.0.34 | +| Carbon | 3.13.2 | +| Webman framework | `fa352016aac4c9e21c8781cc127afd25ee144795` | +| Workerman | `69bfc7765fff55bc3792560c715ae3ca8eadccb5` | + +## 环境与自动化测试 + +`php webman typephp:doctor` 的关键结果: + +```text +PHP 8.4.25: OK +Docker CLI 29.1.3 and daemon: OK +Host Clang: optional +``` + +插件测试和静态检查: + +```text +Pest: 128 passed, 1,369 assertions +Existing fixture warning: 1 +PHP 8.4 syntax: passed +Shell syntax: passed +git diff --check: passed +``` + +## 完整编译 + +执行命令: + +```bash +php webman typephp:doctor +php webman typephp:package --profile=saiadmin +``` + +最终构建记录: + +```text +Source roots scanned: 200 +PHP inputs generated and prechecked: 2,279 +Native objects compiled and linked: 2,283 / 2,283 +Build exit code: 0 +portable-dir size: 230 MB +Runtime resources recorded: 74 +``` + +中立插件 `plugin/aot_probe/app/controller/ProbeController.php` 由 profile 自动发现并 +直接编译,没有在 profile 中手工登记。 + +## 产物完整性 + +保留产物于 2026-09-15 再次运行 `scripts/verify-package.sh`,结果: + +```text +ELF 64-bit LSB pie executable, x86-64 +Portable-dir contract OK +profile=saiadmin +compiled=131 +generated=11 +covered files=142 +runtime resources=74 +business PHP leaked into portable-dir=0 +unresolved ldd dependency=0 +``` + +产物身份: + +| 文件 | SHA-256 | +| --- | --- | +| `webman-server.bin` | `a248c3fdd7fe7e95dfacf6d0557e320bfb19f7a5d9053ac5605329b696aee2ff` | +| `build-manifest.json` | `379a8d19fcd1d76af463efe567cd83d51ee9414a4b0f173096a5c2ac9315e862` | +| `source-coverage.json` | `5fd8bd86bed3987cc43fd97dd0c7f8047664b235a0b2b3bb571ea9ed785d2d77` | + +## 启动与业务路径 + +portable-dir 在隔离 Linux amd64 容器中启动,Workerman 记录: + +```text +Workerman[main.php] start in DEBUG mode +40 workers [OK] +``` + +业务验收结果: + +| 路径 | HTTP | JSON 业务码 | 验证内容 | +| --- | ---: | ---: | --- | +| 验证码 | 200 | 200 | 返回非空 UUID 和 `data:image/` 图片 | +| 登录 | 200 | 200 | 返回非空 Bearer access token | +| 登录后用户信息 | 200 | 200 | 返回当前隔离用户数据 | +| 权限拒绝 | 200 | 400 | 无权限用户被拒绝访问受保护资源 | +| 中立插件 `/aot-probe` | 200 | 200 | 返回 `data.aot=true` | + +`scripts/accept-linux.sh` 的完成结果: + +```text +Portable-dir contract OK +Isolated captcha, login, user-info, and permission-denial acceptance passed. +``` + +普通 PHP 使用同一依赖组合、隔离数据库和业务请求执行对照: + +```text +captcha: HTTP 200 / JSON 200 +login: HTTP 200 / JSON 200 +user-info: HTTP 200 / JSON 200 +permission denial: HTTP 200 / JSON 400 +``` + +## 复现 + +维护者可以在一次性、处于支持范围的 SaiAdmin 测试项目中执行: + +```bash +php webman typephp:doctor +php webman typephp:package --profile=saiadmin + +AOT_DIST=/absolute/path/to/dist \ + ./vendor/tinywan/webman-typephp/docs/saiadmin-aot/scripts/verify-package.sh +``` + +业务验收需要由调用方提供隔离环境的 URL、接口路径和临时登录请求文件。必填变量及 +安全开关记录在 `scripts/accept-linux.sh` 中。脚本拒绝在未显式设置 +`AOT_ACCEPT_ISOLATED=YES` 时启动。 + +## SaiAdmin 6.1.5 构建验收 + +2026-09-18 使用官方 SaiAdmin `6.1.5` 源码和 Composer `--minimal-changes` +依赖组合执行完整 Linux amd64 构建。构建前验证安装目录与 Composer 包源码一致; +自有项目代码和非 SaiAdmin 插件不属于本证据输入。 + +```text +TypePHP precheck and C++ generation: 2,275 / 2,275 +Build exit code: 0 +Portable-dir contract: OK +ELF: 64-bit LSB pie executable, x86-64 +portable-dir size: 224 MB +runtime resources: 74 +Pest: 133 passed, 1,410 assertions +Existing fixture warning: 1 +SaiAdmin business PHP: 120 +directly compiled: 109 +generated AOT copies: 11 +unclassified SaiAdmin business PHP: 0 +business PHP leaked into portable-dir: 0 +``` + +第三方安装与迁移工具 `vendor/cakephp`、`vendor/league/container` 和 +`vendor/robmorgan/phinx` 明确登记为动态运行资源;SaiAdmin 核心、登录、权限、 +模型与缓存业务代码均未借此绕过 AOT。 + +本次产物身份: + +| 文件 | SHA-256 | +| --- | --- | +| `webman-server.bin` | `6f6fe803153c9bf84b8be3a4a1f9c4fe1493703df77a401ba5cdcf774baf5c7e` | +| `build-manifest.json` | `6aa936c82bdb5549ef2302bf5feb24d34d7ece0191a6e1da143251ce12785836` | +| `source-coverage.json` | `d7cf4d22f4388d830496fbcdb5bb6114ee7d7de3a998ca601e75dd8d3afeb5b5` | + +本次构建镜像身份为 +`webman-typephp-saiadmin@sha256:5f4065b17fafc6eb86f6478f10a060211b5e9c396e8f64fa93f164b727195c5b`。 +它是本地验证候选,不替代发布时应固定的公开 builder digest。 + +以上是 2026-09-18 的构建证据;后续数据库业务验收见下一节。 + +## SaiAdmin 6.1.5 Linux amd64 MySQL 业务验收 + +2026-09-19 使用 SaiAdmin `6.1.5`、Webman `v2.2.4`、Workerman `v5.2.2`、 +ThinkORM `v3.0.34` 和 Carbon `3.13.2` 重建 Linux amd64 portable-dir。builder +为本地 PR 候选,镜像身份 +`sha256:c67c0d2635646a4c7122b7c6872d89a6e0e2495fa9211bca1a6769e87d601729`。 + +```text +TypePHP source generation: 2,231 / 2,231 +C++ compilation: 2,235 / 2,235 +Build and link exit code: 0 +Portable-dir contract: OK +ELF: 64-bit LSB pie executable, x86-64 +first-party coverage: 151 +directly compiled: 134 +generated AOT copies: 17 +unclassified first-party PHP: 0 +business PHP leaked into portable-dir: 0 +Pest: 166 passed, 1,599 assertions +Existing fixture warning: 1 +``` + +本次产物身份: + +| 文件 | SHA-256 | +| --- | --- | +| `webman-server.bin` | `3305279e7c58791af36965ca6156fffcbb325dd0fd2eb0c32b1e31711c9a38b6` | +| `build-manifest.json` | `483873bebb95694e0538e36b0df58c247abf52ce4412aca7632b9c59ba8dde9c` | +| `source-coverage.json` | `8d9a6676322aff1bbecaa9c7525d7dc3ebac84c487d2f2690221d2253a54c76c` | + +运行环境使用无宿主 HTTP 端口映射的一次性容器网络和 MySQL 8.4。数据库由项目自带 +Phinx 迁移与 `PureSeeder` 初始化,并创建一个不绑定角色的临时账号。AOT 结果: + +| 路径 | HTTP | JSON 业务码 | 结果 | +| --- | ---: | ---: | --- | +| `GET /core/captcha` | 200 | 200 | UUID、图片和会话均生成 | +| `POST /core/login`(管理员) | 200 | 200 | 返回 Bearer token | +| `GET /core/system/user` | 200 | 200 | 返回管理员、角色和按钮 | +| `POST /core/login`(无角色账号) | 200 | 200 | 返回 Bearer token | +| `GET /core/user/index`(无角色账号) | 200 | 400 | 返回“权限不足” | + +普通 PHP 使用同一隔离 MySQL 对照:验证码、管理员登录和用户信息均返回业务码 200。 +权限拒绝路径在进入既有 `SystemException` 时,被 PHP 8.4 的隐式 nullable deprecation +升级为 500。该异常类的 AOT 副本使用显式 nullable,AOT 权限拒绝已通过;普通 PHP +源码未被 profile 修改。此项是 SaiAdmin 6.1.5 普通 PHP 8.4 基线缺口,不应误记为 +AOT 回归通过。验收结束后必须销毁临时账号、数据库和容器。 + +## macOS arm64 非 Docker 原生验收 + +2026-09-19 使用 SaiAdmin 6.1.5 的锁定项目组合执行宿主机原生编译。此路径没有调用 +Docker,也没有生成或冒充 Linux portable-dir。 + +固定组合: + +| 项目 | 验证值 | +| --- | --- | +| 宿主目标 | macOS arm64 / Mach-O | +| PHP embed | PHP ZTS 8.5.10 | +| TypePHP | 0.9.0 | +| C++ 编译器 | Apple Clang (`/usr/bin/clang++`) | +| SaiAdmin | 6.1.5 | +| Webman framework | v2.2.4 | +| Workerman | v5.2.2 | +| ThinkORM | v3.0.34 | +| Carbon | 3.13.2 | + +执行命令: + +```bash +php webman typephp:doctor --target=native +php webman typephp:compile --profile=saiadmin +``` + +doctor 对 PHP embed、TypePHP、PHPX 和 C++ 编译器全部返回 `[OK]`。完整编译记录: + +```text +Native toolchain ready (Docker will not be used). +PHP 8.5 | TypePHP 0.9.0 | clang++ +Successfully compiled 2056 files +Build successful: build/webman-server +``` + +产物为 82,796,672 字节的 `Mach-O 64-bit executable arm64`,SHA-256: + +```text +d44177577a346bd76f2a5d96048634fb9c2c94b5ac97f668560f0584028899c0 +``` + +动态依赖包括 `libphpx.dylib`、PHP ZTS `libphp.dylib`、GMP、MPFR、libc++ 和 +系统库;相同信息已写入 `.typephp/build/native-build-manifest.json`。 + +为避免对外监听,运行验收使用一次性配置副本,将 HTTP 地址限制为 +`127.0.0.1:8787`、worker 数限制为 1。AOT 可执行文件启动结果为 `[OK]`,随后: + +| 路径 | HTTP | JSON 业务码 | 结果 | +| --- | ---: | ---: | --- | +| `GET /core/captcha` | 200 | 200 | `data.result=1`,UUID 与图片非空 | +| `GET /core/system/user`(无 token) | 200 | 401 | 正确拒绝未登录请求 | + +普通 PHP 8.4.18 在同一份一次性运行配置下执行相同两条路径,HTTP 与业务码完全一致。 +本次没有使用登录账号或连接隔离数据库,因此不宣称登录和登录后用户信息已经在 macOS +原生模式验收。 + +## 证据边界 + +- 本文件是脱敏后的本地隔离验收记录,不是公开托管的二进制或完整原始日志。 +- 哈希只能标识本次保留产物,不能替代维护者在自己的锁定环境中重建。 +- GitHub 当前没有为该分支返回 CI checks;合并前建议维护者重跑插件测试和完整构建。 +- Windows exe/DLL 未验证,不属于本次支持范围。 diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/README.md b/docs/saiadmin-aot/examples/plugin/aot_probe/README.md new file mode 100644 index 0000000..69e7267 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/README.md @@ -0,0 +1,9 @@ +# 中立插件自动发现样例 + +将 `aot_probe` 目录复制到 SaiAdmin 项目的 `plugin/` 后,再执行: + +```bash +php webman typephp:package --profile=saiadmin +``` + +覆盖清单必须新增 `plugin/aot_probe/app/controller/ProbeController.php`,产物中不得保留该业务 PHP。该样例只用于验证插件自动发现,不需要在 TypePHP 配置中手工登记。 diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php b/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php new file mode 100644 index 0000000..0f86358 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php @@ -0,0 +1,16 @@ + 200, 'data' => ['aot' => true]]); + } +} diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php b/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php new file mode 100644 index 0000000..0e98eb3 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php @@ -0,0 +1,8 @@ +&2 + exit 64 +fi + +dist_dir=${AOT_DIST:-} +base_url=${AOT_BASE_URL:-} +captcha_path=${AOT_CAPTCHA_PATH:-} +login_path=${AOT_LOGIN_PATH:-} +login_body_file=${AOT_LOGIN_BODY_FILE:-} +login_status=${AOT_LOGIN_EXPECT_STATUS:-200} +token_path=${AOT_TOKEN_PATH:-data.access_token} +user_info_path=${AOT_USER_INFO_PATH:-} +user_info_status=${AOT_USER_INFO_EXPECT_STATUS:-200} +denied_path=${AOT_DENIED_PATH:-} +denied_status=${AOT_DENIED_EXPECT_STATUS:-200} +denied_code=${AOT_DENIED_EXPECT_CODE:-400} + +for required_value in "$dist_dir" "$base_url" "$captcha_path" "$login_path" "$login_body_file" "$user_info_path" "$denied_path"; do + [[ -n "$required_value" ]] || { + echo "Missing required AOT acceptance environment variable." >&2 + exit 64 + } +done + +if [[ "$dist_dir" != /* || "$login_body_file" != /* || ! -f "$login_body_file" ]]; then + echo "AOT_DIST and AOT_LOGIN_BODY_FILE must be valid absolute paths." >&2 + exit 64 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +AOT_DIST="$dist_dir" "$script_dir/verify-package.sh" + +response_dir=$(mktemp -d) +cleanup() { + "$dist_dir/start.sh" stop >/dev/null 2>&1 || true + rm -rf -- "$response_dir" +} +trap cleanup EXIT INT TERM + +(cd "$dist_dir" && ./start.sh start) + +ready=0 +for _ in {1..30}; do + if curl --fail --silent --show-error "$base_url" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done +[[ $ready -eq 1 ]] || { + echo "Service did not become ready within 30 seconds." >&2 + exit 70 +} + +actual_captcha_status=$(curl --silent --show-error --output "$response_dir/captcha.json" \ + --write-out '%{http_code}' "$base_url$captcha_path") +[[ "$actual_captcha_status" == 200 ]] || { + echo "Captcha status mismatch: expected 200, got $actual_captcha_status." >&2 + exit 71 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + $uuid = $value["data"]["uuid"] ?? null; + $image = $value["data"]["image"] ?? null; + if (($value["code"] ?? null) !== 200 || !is_string($uuid) || $uuid === "" + || !is_string($image) || !str_starts_with($image, "data:image/")) { + exit(2); + } +' "$response_dir/captcha.json" || { + echo "Captcha response contract failed." >&2 + exit 71 +} + +actual_login_status=$(curl --silent --show-error --output "$response_dir/login.json" \ + --write-out '%{http_code}' --header 'Content-Type: application/json' \ + --data-binary "@$login_body_file" "$base_url$login_path") +[[ "$actual_login_status" == "$login_status" ]] || { + echo "Login status mismatch: expected $login_status, got $actual_login_status." >&2 + exit 71 +} + +token=$(php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + foreach (explode(".", $argv[2]) as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) { + exit(2); + } + $value = $value[$key]; + } + if (!is_string($value) || $value === "") { + exit(3); + } + echo $value; +' "$response_dir/login.json" "$token_path") || { + echo "Login token not found at JSON path: $token_path." >&2 + exit 71 +} + +actual_user_info_status=$(curl --silent --show-error --output "$response_dir/user-info.json" \ + --write-out '%{http_code}' --header "Authorization: Bearer $token" "$base_url$user_info_path") +[[ "$actual_user_info_status" == "$user_info_status" ]] || { + echo "User-info status mismatch: expected $user_info_status, got $actual_user_info_status." >&2 + exit 72 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + if (($value["code"] ?? null) !== 200 || !is_array($value["data"] ?? null)) { + exit(2); + } +' "$response_dir/user-info.json" || { + echo "User-info response contract failed." >&2 + exit 72 +} + +actual_denied_status=$(curl --silent --show-error --output "$response_dir/denied.json" \ + --write-out '%{http_code}' --header "Authorization: Bearer $token" "$base_url$denied_path") +[[ "$actual_denied_status" == "$denied_status" ]] || { + echo "Permission denial mismatch: expected $denied_status, got $actual_denied_status." >&2 + exit 72 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + if (($value["code"] ?? null) !== (int) $argv[2]) { + exit(2); + } +' "$response_dir/denied.json" "$denied_code" || { + echo "Permission denial JSON code mismatch: expected $denied_code." >&2 + exit 72 +} + +echo "Isolated captcha, login, user-info, and permission-denial acceptance passed." diff --git a/docs/saiadmin-aot/scripts/verify-package.sh b/docs/saiadmin-aot/scripts/verify-package.sh new file mode 100755 index 0000000..731c46c --- /dev/null +++ b/docs/saiadmin-aot/scripts/verify-package.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +dist_dir=${AOT_DIST:-} +binary_name=${AOT_BINARY_NAME:-webman-server.bin} + +if [[ -z "$dist_dir" || "$dist_dir" != /* ]]; then + echo "AOT_DIST must be an absolute portable-dir path." >&2 + exit 64 +fi + +required=("$binary_name" webman-server start.sh libphp.so libphpx.so php.ini build-manifest.json) +for relative_path in "${required[@]}"; do + [[ -e "$dist_dir/$relative_path" ]] || { + echo "Missing required artifact: $relative_path" >&2 + exit 65 + } +done + +if [[ ! -d "$dist_dir/ext" || ! -d "$dist_dir/lib" || ! -d "$dist_dir/runtime" ]]; then + echo "Missing ext/, lib/, or runtime/ directory." >&2 + exit 65 +fi + +file "$dist_dir/$binary_name" | grep -Eq 'ELF 64-bit.*x86-64' || { + echo "Native binary is not Linux amd64 ELF." >&2 + exit 66 +} + +php -r ' + $manifest = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + foreach (["output_name", "builder_image", "built_at"] as $key) { + if (!isset($manifest[$key]) || $manifest[$key] === "") { + throw new RuntimeException("Missing manifest key: ".$key); + } + } + if (!isset($manifest["runtime_resources"]) || !is_array($manifest["runtime_resources"])) { + throw new RuntimeException("Missing manifest runtime_resources."); + } + $dist = $argv[2]; + if (($manifest["profile"] ?? null) === "saiadmin") { + $coverageFile = $dist."/source-coverage.json"; + if (!is_file($coverageFile)) { + throw new RuntimeException("Missing SaiAdmin source coverage manifest."); + } + $coverage = json_decode(file_get_contents($coverageFile), true, 512, JSON_THROW_ON_ERROR); + if (($coverage["profile"] ?? null) !== "saiadmin" || !is_array($coverage["files"] ?? null)) { + throw new RuntimeException("Invalid SaiAdmin source coverage manifest."); + } + $counts = $coverage["counts"] ?? []; + if (($counts["compiled"] ?? -1) + ($counts["generated"] ?? -1) !== count($coverage["files"])) { + throw new RuntimeException("SaiAdmin source coverage counts do not match its file list."); + } + if (($manifest["inputs"]["source_coverage_hash"] ?? "") !== sha1_file($coverageFile)) { + throw new RuntimeException("SaiAdmin source coverage hash mismatch."); + } + foreach ($coverage["files"] as $file) { + if (!is_array($file) || !in_array($file["mode"] ?? null, ["compiled", "generated"], true)) { + throw new RuntimeException("Invalid SaiAdmin source coverage entry."); + } + $path = $file["path"] ?? ""; + if (!is_string($path) || $path === "" || str_starts_with($path, "/") || str_contains($path, "..")) { + throw new RuntimeException("Unsafe SaiAdmin business source path."); + } + if (file_exists($dist."/".$path)) { + throw new RuntimeException("Business PHP leaked into portable-dir: ".$path); + } + } + } + foreach ($manifest["runtime_resources"] as $resource) { + if (!is_string($resource) || $resource === "" || str_starts_with($resource, "/") || str_contains($resource, "..")) { + throw new RuntimeException("Unsafe runtime resource path."); + } + if (!file_exists($dist."/".$resource)) { + throw new RuntimeException("Missing runtime resource: ".$resource); + } + } +' "$dist_dir/build-manifest.json" "$dist_dir" + +echo "Portable-dir contract OK: $dist_dir" diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php index 70de777..3c424f4 100644 --- a/src/Commands/DoctorCommand.php +++ b/src/Commands/DoctorCommand.php @@ -11,9 +11,12 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +use Tinywan\Typephp\Compiler\NativeToolchain; +// @mago-ignore lint:cyclomatic-complexity -- Docker and native diagnostics are independent explicit checks. class DoctorCommand extends Command { protected static $defaultName = 'typephp:doctor'; @@ -21,13 +24,27 @@ class DoctorCommand extends Command protected function configure(): void { - $this->setName('typephp:doctor')->setDescription('Check local system environment for TypePHP compilation'); + $this + ->setName('typephp:doctor') + ->setDescription('Check local system environment for TypePHP compilation') + ->addOption( + 'target', + null, + InputOption::VALUE_REQUIRED, + 'Toolchain to check: docker, native, or all', + 'docker', + ); } protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('=== TypePHP Environment Diagnostic Tool ==='); $allPassed = true; + $target = (string) $input->getOption('target'); + if (!in_array($target, ['docker', 'native', 'all'], true)) { + $output->writeln("[FAIL] Unknown doctor target: {$target}"); + return Command::FAILURE; + } // 1. PHP Version $phpVer = PHP_VERSION; @@ -39,29 +56,54 @@ protected function execute(InputInterface $input, OutputInterface $output): int $allPassed = false; } - // 2. Docker Check (必须可用) - $dockerProcess = new Process(['docker', '--version']); - $dockerProcess->run(); - if ($dockerProcess->isSuccessful()) { - $output->writeln( - '• Docker: ' . trim($dockerProcess->getOutput()) . ' [OK - Required for Phase 1]', - ); - } else { - $output->writeln( - '• Docker: Not found or not running [FAIL - Docker is required for portable-dir build]', - ); - $allPassed = false; + if ($target === 'docker' || $target === 'all') { + $dockerProcess = new Process(['docker', '--version']); + $dockerProcess->run(); + if ($dockerProcess->isSuccessful()) { + $output->writeln( + '• Docker: ' . trim($dockerProcess->getOutput()) . ' [OK - Required for portable-dir]', + ); + } else { + $output->writeln( + '• Docker: Not found or not running [FAIL - Required for portable-dir build]', + ); + $allPassed = false; + } } - // 3. Clang (宿主机非必需提示) - $clangProcess = new Process(['clang', '--version']); - $clangProcess->run(); - if ($clangProcess->isSuccessful()) { - $output->writeln( - '• Host Clang Compiler: Installed [Optional - Containerized build bypasses host compiler]', - ); - } else { - $output->writeln('• Host Clang Compiler: Not installed [OK - Handled inside Docker builder]'); + if ($target === 'native' || $target === 'all') { + $pluginConfig = require dirname(__DIR__) . '/config/plugin/tinywan/typephp/app.php'; + $nativeConfig = is_array($pluginConfig) && is_array($pluginConfig['native'] ?? null) + ? $pluginConfig['native'] + : []; + if (function_exists('config')) { + $configured = config('plugin.tinywan.typephp.app.native', []); + if (is_array($configured)) { + $nativeConfig = array_replace($nativeConfig, $configured); + } + } + try { + $toolchain = new NativeToolchain()->resolve($nativeConfig); + $output->writeln("• Native PHP embed: {$toolchain['php_version']} [OK]"); + $output->writeln("• TypePHP: {$toolchain['tpc_version']} [OK]"); + $output->writeln('• PHPX runtime: ' . $toolchain['phpx_home'] . ' [OK]'); + $output->writeln('• C++ compiler: ' . $toolchain['cxx'] . ' [OK]'); + } catch (\RuntimeException $exception) { + $output->writeln('• Native toolchain: [FAIL] ' . $exception->getMessage() . ''); + $allPassed = false; + } + } elseif ($target === 'docker') { + $clangProcess = new Process(['clang', '--version']); + $clangProcess->run(); + if ($clangProcess->isSuccessful()) { + $output->writeln( + '• Host Clang Compiler: Installed [Optional - Docker build bypasses host compiler]', + ); + } else { + $output->writeln( + '• Host Clang Compiler: Not installed [OK - Handled inside Docker builder]', + ); + } } return $allPassed ? Command::SUCCESS : Command::FAILURE; diff --git a/src/Commands/NativeCompileCommand.php b/src/Commands/NativeCompileCommand.php new file mode 100644 index 0000000..89b0c82 --- /dev/null +++ b/src/Commands/NativeCompileCommand.php @@ -0,0 +1,269 @@ +setName('typephp:compile') + ->setDescription('Compile Webman for the current host without Docker') + ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'Compatibility profile (supported: saiadmin)') + ->addOption('output-name', null, InputOption::VALUE_REQUIRED, 'Native executable name', 'webman-server') + ->addOption('tpc', null, InputOption::VALUE_REQUIRED, 'Path to tpc or tpc.php') + ->addOption('php', null, InputOption::VALUE_REQUIRED, 'PHP executable matching the embed SDK') + ->addOption('php-home', null, InputOption::VALUE_REQUIRED, 'PHP embed SDK prefix') + ->addOption('phpx-home', null, InputOption::VALUE_REQUIRED, 'Built PHPX prefix or checkout') + ->addOption('cxx', null, InputOption::VALUE_REQUIRED, 'C++ compiler executable') + ->addOption( + 'build-dir', + null, + InputOption::VALUE_REQUIRED, + 'Project-relative compiler cache', + '.typephp/native-cache', + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force a clean TypePHP rebuild') + ->addOption('refresh-main', null, InputOption::VALUE_NONE, 'Refresh main.php from the packaged stub'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $outputName = (string) $input->getOption('output-name'); + $buildDir = trim((string) $input->getOption('build-dir'), '/\\'); + if (!preg_match('/^[A-Za-z0-9._-]+$/', $outputName)) { + $output->writeln("[ERROR] Invalid output-name: '{$outputName}'."); + return Command::FAILURE; + } + if (!$this->isSafeRelativePath($buildDir)) { + $output->writeln("[ERROR] Invalid build-dir: '{$buildDir}'."); + return Command::FAILURE; + } + + $pluginConfig = $this->pluginConfig(); + $jobs = $pluginConfig['build']['jobs'] ?? 4; + if (!is_int($jobs) || $jobs < 1 || $jobs > 4) { + $output->writeln('[ERROR] build.jobs must be an integer between 1 and 4.'); + return Command::FAILURE; + } + $nativeConfig = is_array($pluginConfig['native'] ?? null) ? $pluginConfig['native'] : []; + foreach (['tpc', 'php', 'php_home', 'phpx_home', 'cxx'] as $key) { + $option = str_replace('_', '-', $key); + $value = $input->getOption($option); + if (is_string($value) && $value !== '') { + $nativeConfig[$key] = $value; + } + } + + try { + $toolchain = new NativeToolchain()->resolve($nativeConfig); + } catch (\RuntimeException $exception) { + $output->writeln('[ERROR] Native toolchain check failed: ' . $exception->getMessage() . ''); + $output->writeln('Run: php webman typephp:doctor --target=native'); + return Command::FAILURE; + } + + $basePath = (string) (function_exists('base_path') ? base_path() : getcwd()); + $profile = $input->getOption('profile'); + $profileName = is_string($profile) && $profile !== '' ? $profile : null; + $generator = new ProjectGenerator($basePath); + $skipGuardedSources = []; + $nativeIgnores = []; + if (version_compare($toolchain['php_version'], '8.5', '>=')) { + foreach (array_keys(ProjectGenerator::GUARDED_SOURCES) as $source) { + if (str_starts_with($source, 'vendor/symfony/polyfill-php85/')) { + $skipGuardedSources[] = $source; + } + } + $nativeIgnores[] = 'vendor/symfony/polyfill-php85'; + } + if ($toolchain['intl_loaded'] === '1') { + $skipGuardedSources = [ + ...$skipGuardedSources, + 'vendor/symfony/polyfill-intl-grapheme/bootstrap80.php', + 'vendor/symfony/polyfill-intl-idn/bootstrap80.php', + 'vendor/symfony/polyfill-intl-normalizer/bootstrap80.php', + ]; + $nativeIgnores = [ + ...$nativeIgnores, + 'vendor/symfony/polyfill-intl-grapheme', + 'vendor/symfony/polyfill-intl-idn', + 'vendor/symfony/polyfill-intl-normalizer', + ]; + } + + try { + $configuredIgnores = is_array($pluginConfig['ignore'] ?? null) ? $pluginConfig['ignore'] : []; + $generator->generateMain(null, (bool) $input->getOption('refresh-main')); + $projectFile = $generator->generateProjectYml(array_replace_recursive($pluginConfig, [ + 'profile' => $profileName, + 'ignore' => array_values(array_unique([...$configuredIgnores, ...$nativeIgnores])), + 'build' => [ + 'output_name' => $outputName, + 'jobs' => $jobs, + 'skip_guarded_sources' => array_values(array_unique($skipGuardedSources)), + ], + ]), 'project.native.yml'); + } catch (\RuntimeException $exception) { + $output->writeln('[ERROR] ' . $exception->getMessage() . ''); + return Command::FAILURE; + } + + $absoluteBuildDir = $basePath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $buildDir); + $outputDirectory = $basePath . DIRECTORY_SEPARATOR . 'build'; + if ( + !is_dir($absoluteBuildDir) && !mkdir($absoluteBuildDir, 0777, true) && !is_dir($absoluteBuildDir) + || !is_dir($outputDirectory) && !mkdir($outputDirectory, 0777, true) && !is_dir($outputDirectory) + ) { + $output->writeln('[ERROR] Unable to create native build directories.'); + return Command::FAILURE; + } + + try { + $compilerEntry = $profileName === 'saiadmin' + ? new NativeCompilerOverlay()->prepare($toolchain, $basePath . '/.typephp/native-compiler') + : $toolchain['tpc']; + } catch (\RuntimeException $exception) { + $output->writeln('[ERROR] Native compiler overlay failed: ' . $exception->getMessage() . ''); + return Command::FAILURE; + } + $compilerToolchain = $toolchain; + $compilerToolchain['tpc'] = $compilerEntry; + $command = new NativeToolchain()->command($compilerToolchain); + $command = [ + ...$command, + $projectFile, + '--php-version', + $toolchain['php_version'], + '--compiler', + $toolchain['cxx'], + '--build-dir', + $absoluteBuildDir, + '--no-progress', + ]; + if ((bool) $input->getOption('force')) { + $command[] = '--force'; + } + + $output->writeln('[TypePHP] Native toolchain ready (Docker will not be used).'); + $output->writeln( + "PHP {$toolchain['php_version']} | TypePHP {$toolchain['tpc_version']} | " + . basename($toolchain['cxx']) + . '', + ); + $process = new Process( + $command, + $basePath, + [ + 'PHP_HOME' => $toolchain['php_home'], + 'PHPX_HOME' => $toolchain['phpx_home'], + ], + null, + 1800, + ); + $process->run(static function (string $type, string $buffer) use ($output): void { + $output->write($buffer); + }); + if (!$process->isSuccessful()) { + $exitCode = $process->getExitCode(); + $output->writeln( + '[ERROR] Native TypePHP build failed with exit code ' + . ($exitCode === null ? 'unknown' : (string) $exitCode) + . '', + ); + return Command::FAILURE; + } + + $compiled = $this->findCompiledExecutable($outputDirectory, $outputName); + if ($compiled === null) { + $output->writeln("[ERROR] Compiled executable build/{$outputName} was not found."); + return Command::FAILURE; + } + $this->writeManifest($basePath, $compiled, $profileName, $toolchain); + $relative = ltrim(str_replace($basePath, '', $compiled), DIRECTORY_SEPARATOR); + $output->writeln("Successfully built native executable: {$relative}"); + $output->writeln("Run command: ./{$relative} start"); + return Command::SUCCESS; + } + + /** + * @return array + */ + private function pluginConfig(): array + { + $defaults = require dirname(__DIR__) . '/config/plugin/tinywan/typephp/app.php'; + if (!is_array($defaults) || !function_exists('config')) { + return is_array($defaults) ? $defaults : []; + } + $configured = config('plugin.tinywan.typephp.app', []); + if (!is_array($configured)) { + return $defaults; + } + $merged = array_replace_recursive($defaults, $configured); + foreach (['ignore', 'runtime_resources'] as $key) { + if (is_array($defaults[$key] ?? null) && is_array($configured[$key] ?? null)) { + $merged[$key] = array_values(array_unique(array_merge($defaults[$key], $configured[$key]))); + } + } + return $merged; + } + + private function isSafeRelativePath(string $path): bool + { + return ( + $path !== '' + && !str_starts_with($path, '/') + && !preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) + && !in_array('..', preg_split('#[\\\\/]#', $path) ?: [], true) + ); + } + + private function findCompiledExecutable(string $outputDirectory, string $outputName): ?string + { + foreach ([$outputName, str_replace('-', '_', $outputName)] as $name) { + $candidate = $outputDirectory . DIRECTORY_SEPARATOR . $name; + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + return null; + } + + /** + * @param array $toolchain + */ + private function writeManifest(string $basePath, string $compiled, ?string $profile, array $toolchain): void + { + $manifest = [ + 'target' => strtolower(PHP_OS_FAMILY) . '-' . php_uname('m') . '-native', + 'mode' => 'native', + 'profile' => $profile, + 'php_version' => $toolchain['php_version'], + 'typephp_version' => $toolchain['tpc_version'], + 'cxx' => basename($toolchain['cxx']), + 'output' => ltrim(str_replace($basePath, '', $compiled), DIRECTORY_SEPARATOR), + 'output_sha256' => (string) hash_file('sha256', $compiled), + 'built_at' => gmdate('Y-m-d\TH:i:s\Z'), + ]; + file_put_contents( + $basePath . '/.typephp/build/native-build-manifest.json', + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + ); + } +} diff --git a/src/Commands/PackageCommand.php b/src/Commands/PackageCommand.php index fe18465..3260151 100644 --- a/src/Commands/PackageCommand.php +++ b/src/Commands/PackageCommand.php @@ -30,6 +30,13 @@ protected function configure(): void ->setName('typephp:package') ->setDescription('Build Webman project into a Linux x86_64 portable-dir using TypePHP Docker builder') ->addOption('image', null, InputOption::VALUE_REQUIRED, 'Docker builder image reference', null) + ->addOption( + 'profile', + null, + InputOption::VALUE_REQUIRED, + 'Optional compatibility profile (supported: saiadmin)', + null, + ) ->addOption( 'output-dir', null, @@ -54,11 +61,16 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $pluginConfig = []; + $defaultPluginConfig = require dirname(__DIR__) . '/config/plugin/tinywan/typephp/app.php'; + if (!is_array($defaultPluginConfig)) { + $output->writeln('[ERROR] The packaged TypePHP configuration is invalid.'); + return Command::FAILURE; + } + $pluginConfig = $defaultPluginConfig; if (function_exists('config')) { $configuredPluginConfig = config('plugin.tinywan.typephp.app', []); if (is_array($configuredPluginConfig)) { - $pluginConfig = $configuredPluginConfig; + $pluginConfig = $this->mergePluginConfig($defaultPluginConfig, $configuredPluginConfig); } } @@ -67,6 +79,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $outputDir = trim((string) $input->getOption('output-dir'), '/\\'); $outputName = (string) $input->getOption('output-name'); $force = (bool) $input->getOption('force'); + $profile = $input->getOption('profile'); + $jobs = $pluginConfig['build']['jobs'] ?? 4; + + if (!is_int($jobs) || $jobs < 1 || $jobs > 4) { + $output->writeln('[ERROR] build.jobs must be an integer between 1 and 4.'); + return Command::FAILURE; + } // 1. 严格参数校验,防止任何路径穿越或格式错误 if (!preg_match('/^[A-Za-z0-9._-]+$/', $outputName)) { @@ -107,7 +126,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - // 3. 检查 Docker 环境 + // 3. 执行与 doctor 相同的强制环境门禁;不允许用源码改写掩盖宿主环境问题。 + if (version_compare(PHP_VERSION, '8.4', '<') || version_compare(PHP_VERSION, '8.6', '>=')) { + $output->writeln( + '[ERROR] Environment check failed: PHP >= 8.4 and < 8.6 is required. Run typephp:doctor.', + ); + return Command::FAILURE; + } + $dockerCheck = new Process(['docker', '--version']); $dockerCheck->run(); if (!$dockerCheck->isSuccessful()) { @@ -141,6 +167,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // 动态合并配置并生成 project.linux.yml $extraConfig = [ + 'profile' => is_string($profile) && $profile !== '' ? $profile : null, 'build' => [ 'output_name' => $outputName, ], @@ -158,7 +185,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // 汇总各类 AOT 生成源的输入摘要:标签用于终端提示,同名