diff --git a/.gitignore b/.gitignore index 5ea96f6b..e7284854 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ *.lib *.exp *.class +*.wasm /swoole_compiler /tpc tests/**/*.diff diff --git a/docs/README.md b/docs/README.md index 6092099d..63b77532 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ - [后端中立 IR](BACKEND_NEUTRAL_IR.md) - [TypePHP WASM 技术方案与实施计划](TYPEPHP_WASM_IMPLEMENTATION_PLAN.md) +- [构建 TypePHP WASI 程序](TYPEPHP_WASI_BUILD.md) - [核心重构计划](REFACTORING_PLAN.md) - [构建速度研究](AOT_BUILD_SPEED_RESEARCH.md) - [优化优先级](aot-optimization-priority.md) diff --git a/docs/TYPEPHP_WASI_BUILD.md b/docs/TYPEPHP_WASI_BUILD.md new file mode 100644 index 00000000..57e3891b --- /dev/null +++ b/docs/TYPEPHP_WASI_BUILD.md @@ -0,0 +1,93 @@ +# 构建 TypePHP WASI 程序 + +TypePHP 已有一个可运行的 WASI Preview 1 原型。它将 TypePHP 生成的 C++、PHPX 核心、精简的 PHP 8.5 NTS、GMP、MPFR 和 mpdecimal 静态链接为单个 `.wasm` command 模块。 + +## 环境要求 + +- WASI SDK 33 或更高版本(LLVM/Clang/LLD 22 或更高) +- PHP 8.4 或更高版本,用于运行 TypePHP 编译器 +- Autoconf、Automake、Libtool、Bison、re2c 和常规 C/C++ 构建工具 +- Wasmtime 47 或更高版本,用于运行和测试产物 + +WASI SDK 的 `bin` 目录和 Wasmtime 必须加入系统 `PATH`。编译器不会探测或使用 `/opt` 等约定安装目录,也不接受专用的工具目录配置。可以继续通过环境变量覆盖缓存位置: + +```bash +export PATH="::$PATH" +export PHP_WASM_BUILD_DIR=/tmp/php-8.5.9-wasm-build +export TYPEPHP_WASM_DEPS_PREFIX=/tmp/typephp-wasm-numeric/prefix +export TYPEPHP_WASM_RUNTIME_PREFIX=/tmp/typephp-wasm-runtime +``` + +执行 `--wasm` 时会先检查 `clang`、`clang++`、`llvm-ar`、`llvm-ranlib`、`llvm-nm`、`wasm-ld` 和 `wasmtime` 是否都能从 `PATH` 找到,检查最低版本,并确认 `clang++` 的默认目标是 `wasm32-wasi`。检测失败时不会进入代码生成或编译阶段。 + +## 一条命令构建 + +源文件必须提供 `main(): void`: + +```php +` 覆盖。每个 `.o` 与对应 `.cc` 放在同一目录并在下次构建时直接覆盖,不建立 WASM 专用的深层对象目录。编译器用于传递本次源码列表的临时清单会在链接结束后自动清理。 + +PHP、PHPX、TypePHP runtime、GMP、MPFR 和 mpdecimal 会预编译并缓存为 WASI 静态库;正常的应用构建只编译 TypePHP 为当前程序生成的 C++,然后链接这些 `.a`。源码开发环境首次执行时会自动建立缺失的运行时缓存,发行包可直接携带预编译静态库。 + +每个 C/C++ 翻译单元统一使用标准 Wasm C++ exceptions 和 WASI SJLJ;链接阶段将 ABI 警告视为错误,旧的 32 位 `zend_long` 缓存也会自动失效。 + +运行: + +```bash +wasmtime hello.wasm +``` + +## 高精度类型 + +WASI 产物包含 TypePHP 的三种语言级高精度类型: + +- `BigInt`:GMP 6.3.0 +- `BigFloat`:MPFR 4.2.2 +- `Decimal`:mpdecimal 4.0.1 + +完整示例位于 [high-precision.php](../projects/php-8.5.9/wasm/examples/high-precision.php)。构建并运行: + +```bash +php bin/tpc.php --wasm projects/php-8.5.9/wasm/examples/high-precision.php +wasmtime high-precision.wasm +``` + +预期输出: + +```text +1111111101111111110111111111010 +1000000000000000000000000000001 +12348.14159265358979324 +``` + +wasm32 使用 32 位指针,但 PHP 的 `zend_long` 保持 64 位,以维持 TypePHP 与 64 位 PHP 的整数语义。GMP 和 mpdecimal 使用 32 位 limb;这不改变任意精度语义,但大数吞吐量低于具有汇编优化的原生 64 位构建。 + +## 当前平台边界 + +- 仅支持 NTS、单线程。 +- Fiber 和 TypePHP Generator 被禁用;编译器在发现 `yield` 时直接报致命错误。 +- PHPX Facade API 在 `__wasi__` 下整体禁用。PHPX 核心类型和 `phpx_std` 仍可使用。 +- 不支持动态扩展、网络 socket、进程控制和依赖操作系统服务的扩展。 +- 保留 PHP stream 框架、本地文件能力以及由 WASI host 提供的时间和随机数能力。 +- 当前产物是 WASI command module,适用于 Wasmtime 等 WASI 运行时,不能不经宿主适配直接放入浏览器运行。 + +PHPX Facade 只是为 PHP 可选扩展生成的便捷包装,并非 TypePHP ABI 的组成部分。WASI 下整体关闭它,可以避免把不存在的 curl、socket、Swoole、PDO 等 API 暴露为“可编译但链接失败”的接口。 + +## 内部构建层次 + +编译器内部会分别建立 PHP 8.5 NTS、GMP/MPFR/mpdecimal,以及 PHPX core/TypePHP runtime 的静态库缓存。内部构建脚本不是用户接口,不需要也不应由用户手动执行。发行包可直接附带目标平台对应的预编译 `.a` 文件。 + +用户始终通过 `php bin/tpc.php --wasm program.php` 构建最终程序,避免 PHP、PHPX 和生成代码使用不一致的 `zend_long`、异常或 SJLJ ABI。 diff --git a/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md b/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md index 0bf1a65d..a2e4d3ce 100644 --- a/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md +++ b/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md @@ -1,14 +1,14 @@ # TypePHP WASM 技术方案与实施计划 -> 状态:技术预研结论,供后续实现使用 +> 状态:WASI 命令行原型已实现,浏览器阶段待实现 > 调研日期:2026-08-07 -> 首期目标:浏览器中的 Emscripten/WASM,NTS,单线程 +> 当前目标:WASI Preview 1,NTS,单线程;后续适配浏览器 ## 1. 文档目的 本文记录 TypePHP 支持 WebAssembly 的技术决策、功能边界、运行时架构、主要风险、验证方法和分阶段实施计划。 -本文不是当前功能说明。TypePHP 尚未承诺已经支持 WASM,后续实现应以本文作为设计起点,并根据原型验证结果更新决策。 +2026-08-07 的实现验证已经证明:精简 PHP 8.5、PHPX 核心、TypePHP 生成代码、GMP、MPFR 和 mpdecimal 可以通过 WASI SDK 静态链接为单个模块,并在 Wasmtime 中运行。可复现构建方法见 [构建 TypePHP WASI 程序](TYPEPHP_WASI_BUILD.md)。本文余下内容同时保留浏览器阶段的设计目标。 ## 2. 核心结论 @@ -18,28 +18,28 @@ PHP 源码 -> TypePHP 编译器 -> TypePHP 生成的 C++ - -> Emscripten 编译和静态链接 + -> WASI SDK 编译和静态链接 + PHP NTS + PHPX + TypePHP runtime + GMP / MPFR / mpdecimal - + 最小 TypePHP WASM SAPI - -> typephp.wasm + 薄 JavaScript loader + + PHP embed/WASI 运行时 + -> typephp.wasm(WASI command module) ``` 具体决策如下: 1. 第一版复用当前 C++/Zend 后端,不直接生成 WAT/WASM,也不重新实现 PHP 运行时。 -2. 使用 Emscripten,而不是把 WASI 作为首期目标。 +2. 先使用 WASI SDK 建立最小、可测试的命令行目标;浏览器阶段再选择 Emscripten 或 WASI adapter。 3. PHP、PHPX、TypePHP 生成代码和高精度库全部静态链接到一个 `.wasm` 模块。 -4. 浏览器侧保留一个很薄的 JavaScript loader,用于实例化模块和提供必要的宿主能力。 +4. 当前由 WASI host 提供 stdout、clocks、随机数和受控文件系统;浏览器阶段也保持 WASI 接口,由浏览器中的 WASI host/adapter 实现这些能力。 5. 仅支持 PHP NTS,不支持线程。 6. 禁用 Fiber 和 TypePHP Generator。 7. 必须支持 C++ 异常以及 Zend bailout 所需的 `setjmp/longjmp`。 8. 保留 PHP stream 框架和本地 stream,禁用网络 transport 和依赖操作系统进程能力的功能。 9. WordPress Playground 和其他 PHP-WASM 项目只作为补丁与移植经验来源,不作为 TypePHP 的依赖或代码基础。 -本文描述的是最短可落地路径。长期的后端中立方案参见 [BACKEND_NEUTRAL_IR.md](BACKEND_NEUTRAL_IR.md)。在首期原型成功前,不应为了 WASM 重写 TypePHP 前端和语义层。 +本文描述的是最短可落地路径。长期的后端中立方案参见 [BACKEND_NEUTRAL_IR.md](BACKEND_NEUTRAL_IR.md)。WASI 原型证明无需为了 WASM 重写 TypePHP 前端和语义层。 ## 3. 为什么不采用 WordPress Playground @@ -67,20 +67,20 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type ## 4. 目标与非目标 -### 4.1 首期目标 +### 4.1 当前 WASI 目标 -- 在主流浏览器中加载 TypePHP 编译产物。 +- 在 Wasmtime 等 WASI runtime 中加载 TypePHP 编译产物。 - 执行静态编译的 TypePHP 应用入口。 - 保持 TypePHP 当前基于 Zend 和 PHPX 的主要语言语义。 - 正确处理 PHP request 生命周期、C++ 异常和 Zend bailout。 - 支持 GMP、MPFR 和 mpdecimal 高精度类型。 -- 支持内存文件系统和必要的本地 PHP stream。 +- 支持 WASI 文件系统和必要的本地 PHP stream。 - 对不支持的功能给出确定、可测试的错误,而不是链接失败或运行时崩溃。 -- 构建过程可复现,php-src 和 Emscripten 版本固定。 +- 构建过程可复现,php-src、WASI SDK 和数值库版本固定。 ### 4.2 首期非目标 -- WASI 独立运行时。 +- 无宿主适配的浏览器直接运行。 - pthread、Web Worker 并行 PHP 或共享内存。 - Fiber 和 TypePHP Generator。 - 动态扩展加载。 @@ -95,31 +95,26 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type ## 5. 目标平台选择 -### 5.1 首期使用 Emscripten +### 5.1 当前使用 WASI SDK -首期目标是浏览器,且 PHP 依赖 `setjmp/longjmp`,PHPX 和 TypePHP 依赖 C++ 异常。Emscripten 已经提供: +当前先建立命令行可验证基线。WASI SDK 已经验证可以同时提供: - C/C++ 到 WebAssembly 的完整工具链; -- C++ exception handling; -- `setjmp/longjmp` 支持; -- 浏览器文件系统; -- JavaScript import/export 和模块加载; -- libc、时间、随机数和常见 POSIX 接口的兼容层。 +- 标准 Wasm C++ exception handling; +- Zend bailout 所需的 SJLJ; +- capability-based 文件系统; +- libc、时间和随机数接口。 -这些能力可以显著减少 PHP 移植工作。 +PHP、PHPX 和所有 TypePHP C++ 翻译单元必须使用一致的 Wasm EH/SJLJ 参数。链接器必须将函数签名不一致视为致命错误。 -### 5.2 暂不以 WASI 为目标 +### 5.2 浏览器阶段 -WASI 更适合非浏览器运行时和能力安全模型,但首期采用 WASI 会同时引入以下变量: +WASI command module 不能不经适配直接在浏览器运行。后续浏览器阶段需要在以下两种路线中选择: -- PHP 和 Zend bailout 的兼容性; -- C++ 异常实现; -- 浏览器侧 WASI shim; -- 文件系统和异步 I/O; -- GMP、MPFR、mpdecimal 的 WASI 构建; -- 不同 WASI runtime 的实现差异。 +- 使用 Emscripten 重建同一套静态运行时; +- 为已验证的 WASI 模块提供浏览器 WASI adapter/component host。 -在 Emscripten 原型稳定前,不同时维护第二套平台。后续如需支持 Wasmtime、Wasmer 或边缘运行时,应建立独立的 WASI 可行性项目,而不是把兼容层混入首期实现。 +选择应以异常语义、文件系统、模块体积和 JavaScript 宿主接口的实测结果为依据。浏览器适配不得改变 TypePHP、PHPX 或高精度类型的语言语义。 ## 6. 产物和运行模型 @@ -133,12 +128,12 @@ dist/ └── typephp-wasm.mjs ``` -所有 C/C++ 代码进入 `typephp.wasm`。`typephp-wasm.mjs` 只负责: +所有 C/C++ 代码进入 `typephp.wasm`。浏览器自身不会自动提供 WASI imports;`typephp-wasm.mjs` 作为 WASI host/adapter 的装载入口,只负责: - 获取和实例化 `.wasm`; - 提供 stdout/stderr; - 初始化内存文件系统; -- 注入时间、随机数等宿主能力; +- 实现或接入 WASI clocks、随机数等宿主能力; - 调用导出的 TypePHP 生命周期接口; - 把状态码和错误信息转换为 JavaScript 结果。 @@ -232,8 +227,8 @@ PHP 标准库大量依赖 stream。完全关闭 stream 会破坏文件读写、` | 文件系统 | MEMFS;可选只读预加载文件 | | 当前目录和路径 | 虚拟根目录,禁止泄漏宿主路径 | | 环境变量 | loader 注入白名单 | -| 时间 | Emscripten/JavaScript host clock | -| 随机数 | 浏览器安全随机源,不使用弱伪随机替代 | +| 时间 | WASI clocks;浏览器宿主使用浏览器时钟实现该接口 | +| 随机数 | WASI random;浏览器宿主使用安全随机源实现,不使用弱伪随机替代 | | DNS、socket | 不支持 | | 进程、shell | 不支持 | | 信号 | 不支持 | diff --git a/examples/hello.php b/examples/hello.php index 1abfd2fa..e9504351 100644 --- a/examples/hello.php +++ b/examples/hello.php @@ -7,4 +7,7 @@ function main(): void var_dump(php_uname()); global $argv; var_dump($argv); + + $date = date('Y-m-d H:i:s', time()); + var_dump($date); } diff --git a/phpunit/code/wasi-backtick.php b/phpunit/code/wasi-backtick.php new file mode 100644 index 00000000..3158b215 --- /dev/null +++ b/phpunit/code/wasi-backtick.php @@ -0,0 +1,7 @@ + yield 1; + var_dump($generator); +} diff --git a/phpunit/code/wasi-generator-closure.php b/phpunit/code/wasi-generator-closure.php new file mode 100644 index 00000000..cbab8509 --- /dev/null +++ b/phpunit/code/wasi-generator-closure.php @@ -0,0 +1,9 @@ +directory = sys_get_temp_dir() . '/typephp-wasi-tools-' . bin2hex(random_bytes(6)); + mkdir($this->directory, 0777, true); + $this->originalPath = getenv('PATH'); + } + + protected function tearDown(): void + { + putenv('PATH=' . ($this->originalPath !== false ? $this->originalPath : '')); + foreach (glob($this->directory . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->directory); + } + + public function testDetectsSupportedToolsOnlyFromPath(): void + { + $this->installFakeTools(22, 47, 'wasm32-unknown-wasip1'); + putenv('PATH=' . $this->directory); + + $tools = (new WasiToolchain())->detect(); + + $this->assertSame($this->directory . '/clang++', $tools['clang++']); + $this->assertSame($this->directory . '/wasmtime', $tools['wasmtime']); + $this->assertSame('wasm32-unknown-wasip1', $tools['target']); + $this->assertSame('22.0.0', $tools['clang-version']); + $this->assertSame('47.0.0', $tools['wasmtime-version']); + } + + public function testRejectsMissingTool(): void + { + putenv('PATH=' . $this->directory); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('`clang` was not found in PATH'); + (new WasiToolchain())->detect(); + } + + public function testRejectsOldLlvm(): void + { + $this->installFakeTools(21, 47, 'wasm32-unknown-wasip1'); + putenv('PATH=' . $this->directory); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('`clang` 21 is too old'); + (new WasiToolchain())->detect(); + } + + public function testRejectsNonWasiClangTarget(): void + { + $this->installFakeTools(22, 47, 'x86_64-unknown-linux-gnu'); + putenv('PATH=' . $this->directory); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not configured for wasm32-wasi'); + (new WasiToolchain())->detect(); + } + + private function installFakeTools(int $llvmMajor, int $wasmtimeMajor, string $target): void + { + foreach (['clang', 'llvm-ar', 'llvm-ranlib', 'llvm-nm', 'wasm-ld'] as $tool) { + $this->writeExecutable($tool, "#!/bin/sh\necho 'LLVM version {$llvmMajor}.0.0'\n"); + } + $this->writeExecutable( + 'clang++', + "#!/bin/sh\nif [ \"\$1\" = '--print-target-triple' ]; then echo '{$target}'; else echo 'clang version {$llvmMajor}.0.0'; fi\n", + ); + $this->writeExecutable('wasmtime', "#!/bin/sh\necho 'wasmtime {$wasmtimeMajor}.0.0'\n"); + } + + private function writeExecutable(string $name, string $contents): void + { + $path = $this->directory . '/' . $name; + file_put_contents($path, $contents); + chmod($path, 0755); + } +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index b94c9781..1cdf0567 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -99,6 +99,19 @@ class CompilerBaseApiTest extends TestCase return $projectFile; } + public function testWasiTargetDetection(): void + { + foreach (['wasm32-wasi', 'wasm32-wasip1', 'wasm32-wasip1-threads'] as $target) { + $this->setPropertyValue('targetPlatform', $target); + $this->assertTrue($this->compiler->isWasiTarget(), $target); + } + + foreach (['', 'wasm32-unknown-unknown', 'aarch64-linux-gnu'] as $target) { + $this->setPropertyValue('targetPlatform', $target); + $this->assertFalse($this->compiler->isWasiTarget(), $target); + } + } + private function createFakeClangFormat(string $binDir, string $logFile): void { mkdir($binDir, 0777, true); diff --git a/phpunit/src/Generator/FiberGeneratorTest.php b/phpunit/src/Generator/FiberGeneratorTest.php index c06a4f52..b85efda5 100644 --- a/phpunit/src/Generator/FiberGeneratorTest.php +++ b/phpunit/src/Generator/FiberGeneratorTest.php @@ -8,6 +8,19 @@ use TypePhp\Exception\TestError; class FiberGeneratorTest extends TestCase { + public function testWasiTargetRejectsGeneratorDuringPreparation(): void + { + $compiler = CompilerTest::create(ROOT_PATH); + $reflection = new \ReflectionClass($compiler); + $reflection->getProperty('targetPlatform')->setValue($compiler, 'wasm32-wasi'); + $file = __DIR__ . '/../../code/generator-conversion-error.php'; + $compiler->addFiles([$file]); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Fiber and Generator are not supported by the WASI target'); + $compiler->prepareFile($file); + } + public function testCompilerStateIsRestoredAfterGeneratorConversionError(): void { $compiler = CompilerTest::create(ROOT_PATH); diff --git a/phpunit/src/Platform/PlatformTest.php b/phpunit/src/Platform/PlatformTest.php index ab1f2d84..031402ee 100644 --- a/phpunit/src/Platform/PlatformTest.php +++ b/phpunit/src/Platform/PlatformTest.php @@ -6,9 +6,22 @@ use PHPUnit\Framework\TestCase; use TypePhp\Platform\Windows; use TypePhp\Platform\Linux; use TypePhp\Platform\Macos; +use TypePhp\Platform\Wasi; class PlatformTest extends TestCase { + public function testWasiTargetProperties(): void + { + $platform = new Wasi('wasm32-unknown-wasip1'); + + $this->assertSame('WASI SDK (wasm32-unknown-wasip1)', $platform->getName()); + $this->assertSame('.o', $platform->getObjectExtension()); + $this->assertSame('.wasm', $platform->getExecutableExtension()); + $this->assertSame('.a', $platform->getSharedLibraryExtension()); + $this->assertSame('LL', $platform->getIntegerLiteralSuffix()); + $this->assertSame([], $platform->getBuildLibraryWarnings('', '', 'bin')); + } + /** * 测试 Windows 平台基本功能 */ diff --git a/phpunit/src/WasiUnsupportedSyntaxTest.php b/phpunit/src/WasiUnsupportedSyntaxTest.php new file mode 100644 index 00000000..b6142c9a --- /dev/null +++ b/phpunit/src/WasiUnsupportedSyntaxTest.php @@ -0,0 +1,42 @@ +getProperty('targetPlatform')->setValue($compiler, 'wasm32-wasi'); + $source = __DIR__ . '/../code/' . $file; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + + $this->expectException(TestError::class); + $this->expectExceptionMessage($message); + $compiler->convertFile($source); + } + + public static function unsupportedConversionSyntaxProvider(): array + { + return [ + 'backtick shell execution' => [ + 'wasi-backtick.php', + 'Backtick shell execution is not supported by the WASI target', + ], + 'generator closure' => [ + 'wasi-generator-closure.php', + 'Fiber and Generator are not supported by the WASI target', + ], + 'generator arrow function' => [ + 'wasi-generator-arrow.php', + 'Fiber and Generator are not supported by the WASI target', + ], + ]; + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 7ca200d2..1d74964f 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -14,6 +14,7 @@ use TypePhp\Exception\Unsupported; use TypePhp\Installer\LibPhpInstaller; use TypePhp\Installer\LibPhpxInstaller; use TypePhp\Platform\Linux; +use TypePhp\Platform\Wasi; use TypePhp\Platform\Windows; trait SourcePipelineTrait @@ -98,7 +99,7 @@ trait SourcePipelineTrait // 仅在 PHP 脚本入口(bin/tpc.php)前置检测 phpx 库:缺少库立即 fatal, // 避免继续向下执行到文件处理/编译阶段才报错。已编译的 tpc 可执行文件 // 在进入 main() 前就由动态链接器加载 libphpx,无需(也无法)在此检测。 - if (defined('TYPEPHP_PHP_SCRIPT_ENTRY')) { + if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) { $this->validatePhpxLibrary(); } diff --git a/src/Build/WasiToolchain.php b/src/Build/WasiToolchain.php new file mode 100644 index 00000000..e2367e8f --- /dev/null +++ b/src/Build/WasiToolchain.php @@ -0,0 +1,96 @@ + */ + public function detect(): array + { + $tools = []; + foreach (['clang', 'clang++', 'llvm-ar', 'llvm-ranlib', 'llvm-nm', 'wasm-ld', 'wasmtime'] as $name) { + $tools[$name] = $this->findExecutable($name); + } + + $versions = []; + foreach (['clang', 'clang++', 'llvm-ar', 'llvm-ranlib', 'llvm-nm', 'wasm-ld'] as $name) { + $versions[$name] = $this->requireVersion($name, $tools[$name], self::MIN_LLVM_MAJOR); + } + $versions['wasmtime'] = $this->requireVersion('wasmtime', $tools['wasmtime'], self::MIN_WASMTIME_MAJOR); + + [$exitCode, $target, $error] = $this->run([$tools['clang++'], '--print-target-triple']); + $target = trim($target); + if ($exitCode !== 0 || preg_match('/^wasm32-(?:unknown-)?wasi(?:p1)?$/', $target) !== 1) { + $detail = trim($error) !== '' ? ': ' . trim($error) : ''; + throw new RuntimeException( + "clang++ from PATH is not configured for wasm32-wasi (reported target: " + . ($target !== '' ? $target : 'unknown') . "){$detail}", + ); + } + + $tools['target'] = $target; + $tools['clang-version'] = $versions['clang++']; + $tools['wasmtime-version'] = $versions['wasmtime']; + return $tools; + } + + private function findExecutable(string $name): string + { + $path = getenv('PATH'); + foreach (explode(PATH_SEPARATOR, is_string($path) ? $path : '') as $directory) { + if ($directory === '') { + continue; + } + $candidate = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; + if (is_file($candidate) && is_executable($candidate)) { + // Preserve the PATH entry instead of resolving symlinks. LLVM + // multicall binaries select their driver mode and adjacent + // .cfg file from argv[0] (notably clang++ and wasm-ld). + return $candidate; + } + } + + throw new RuntimeException("Required WASI tool `{$name}` was not found in PATH"); + } + + private function requireVersion(string $name, string $executable, int $minimumMajor): string + { + [$exitCode, $output, $error] = $this->run([$executable, '--version']); + $versionText = trim($output . "\n" . $error); + if ($exitCode !== 0 || preg_match('/(?:version|wasmtime|LLD)\s+((\d+)(?:\.\d+)+)/i', $versionText, $match) !== 1) { + throw new RuntimeException("Unable to determine the version of WASI tool `{$name}` from PATH"); + } + $major = (int) $match[2]; + if ($major < $minimumMajor) { + throw new RuntimeException( + "WASI tool `{$name}` {$major} is too old; version {$minimumMajor} or newer is required", + ); + } + return $match[1]; + } + + /** @return array{int, string, string} */ + private function run(array $command): array + { + $process = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + if (!is_resource($process)) { + return [127, '', 'failed to start process']; + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $stdout, $stderr]; + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 2aa34da0..7fff7db8 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -581,6 +581,12 @@ class CompilerBase implements PropertyAccessContext return $this->getPlatform() instanceof Macos; } + public function isWasiTarget(): bool + { + $target = strtolower($this->targetPlatform); + return str_starts_with($target, 'wasm32-wasi') || str_starts_with($target, 'wasm32-wasip1'); + } + public function isBuildModeBin(): bool { return $this->buildMode === self::BUILD_MODE_BIN; @@ -3804,6 +3810,9 @@ class CompilerBase implements PropertyAccessContext protected function parseShellExec(Expr\ShellExec $expr): string { + if ($this->isWasiTarget()) { + $this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target'); + } $list = []; foreach ($expr->parts as $part) { $list[] = $this->identifierToStr($part); diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index c2311824..512d4f1b 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -247,6 +247,9 @@ trait ClosureGenerator protected function validateGeneratorClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params): void { + if ($this->isWasiTarget()) { + $this->fatalError($expr, 'Fiber and Generator are not supported by the WASI target'); + } if ($expr->byRef) { $this->fatalError($expr, 'Generator closures returning by reference are not supported yet'); } diff --git a/src/Generator/FiberGenerator.php b/src/Generator/FiberGenerator.php index 1fbeba39..657e725b 100644 --- a/src/Generator/FiberGenerator.php +++ b/src/Generator/FiberGenerator.php @@ -61,6 +61,9 @@ trait FiberGenerator protected function prepareGeneratorFunction(Function_|ClassMethod $v, FunctionDef $functionDef): void { + if ($this->isWasiTarget()) { + $this->fatalError($v, 'Fiber and Generator are not supported by the WASI target'); + } if ($v->byRef) { $this->fatalError($v, 'Generators returning by reference are not supported yet'); } diff --git a/src/Platform/Wasi.php b/src/Platform/Wasi.php new file mode 100644 index 00000000..12913555 --- /dev/null +++ b/src/Platform/Wasi.php @@ -0,0 +1,50 @@ +target})"; + } + + public function isCurrent(): bool + { + return false; + } + + public function getSharedLibraryExtension(): string + { + return '.a'; + } + + public function getExecutableExtension(): string + { + return '.wasm'; + } + + public function getDefaultCompiler(): string + { + $compiler = getenv('TYPEPHP_WASI_CXX'); + return is_string($compiler) && $compiler !== '' ? $compiler : 'clang++'; + } + + public function getBuildLibraryWarnings( + string $phpDir, + string $phpxDir, + string $buildMode, + bool $checkPhpxRuntime = true, + ): array { + return []; + } + + public function getIntegerLiteralSuffix(): string + { + return 'LL'; + } +} diff --git a/src/Translator.php b/src/Translator.php index f922e759..f7e12f1b 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -37,6 +37,7 @@ use TypePhp\Generator\LibraryImportStubGenerator; use TypePhp\Generator\Symbol; use TypePhp\Metadata\Constants; use TypePhp\Platform\PlatformFactory; +use TypePhp\Platform\Wasi; use TypePhp\Platform\Windows; use TypePhp\Resolver\Reflection; use TypePhp\Resolver\ClassConstantValueTrait; @@ -164,8 +165,20 @@ class Translator extends Preprocessor protected function detectPlatform(): void { try { - $this->platform = PlatformFactory::create(); - $this->cppCompiler = CompilerFactory::detectCompilerName($this->platform); + $targetPlatform = $this->climate->arguments->defined('target-platform') + ? (string) $this->climate->arguments->get('target-platform') + : ''; + if (str_starts_with($targetPlatform, 'wasm32-wasi') || str_starts_with($targetPlatform, 'wasm32-wasip1')) { + $detectedTarget = getenv('TYPEPHP_WASI_TARGET'); + $this->platform = new Wasi( + is_string($detectedTarget) && $detectedTarget !== '' ? $detectedTarget : $targetPlatform, + ); + } else { + $this->platform = PlatformFactory::create(); + } + $this->cppCompiler = $this->platform instanceof Wasi + ? $this->platform->getDefaultCompiler() + : CompilerFactory::detectCompilerName($this->platform); if ($this->platform instanceof Windows) { $libInfo = $this->platform->detectPhpLibs($this->getPhpDir()); @@ -181,8 +194,15 @@ class Translator extends Preprocessor } $this->compilerBackend = CompilerFactory::createByName($this->cppCompiler, $this->platform); + $backendName = $this->compilerBackend->getName(); + if ($this->platform instanceof Wasi) { + $clangVersion = getenv('TYPEPHP_WASI_CLANG_VERSION'); + $backendName = 'LLVM Clang' + . (is_string($clangVersion) && $clangVersion !== '' ? " {$clangVersion}" : ''); + } + $label = $this->platform instanceof Wasi ? 'Initialized target/toolchain' : 'Initialized platform/backend'; $this->climate->info( - "Initialized platform/backend: {$this->platform->getName()} + {$this->compilerBackend->getName()} ({$this->compilerBackend->getCompilerCommand()})" + "{$label}: {$this->platform->getName()} + {$backendName} ({$this->compilerBackend->getCompilerCommand()})" ); } catch (\Throwable $e) { $this->error($e->getMessage()); @@ -244,6 +264,7 @@ class Translator extends Preprocessor $climate->tab()->out('--cxx-std C++ standard version (c++17, c++20, etc., default: c++17)'); $climate->tab()->out('--march Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)'); $climate->tab()->out('--target-platform Cross-compilation target triple (e.g. aarch64-linux-gnu)'); + $climate->tab()->out('--wasm Build a self-contained WASI command module from one PHP file'); $climate->tab()->out('--lto Enable Link Time Optimization (-flto)'); $climate->tab()->out('--no-literal-strings Disable literal strings optimization'); $climate->tab()->out('--php-version PHP language version to accept (8.2-8.5, default: 8.5)'); @@ -754,7 +775,7 @@ class Translator extends Preprocessor $code .= 'extern "C" void save_ps_args(int, char **) {}' . PHP_EOL; } - if ($this->isBuildModeBin()) { + if ($this->isBuildModeBin() && !$this->isWasiTarget()) { $cliHeaders = [ '#include "php_cli_process_title.h"', '#include "php_cli_process_title_arginfo.h"', @@ -856,7 +877,7 @@ CODE; $code .= "// clang-format off\n"; $code .= "static const zend_function_entry ext_functions[] = {\n"; - if ($this->isBuildModeBin()) { + if ($this->isBuildModeBin() && !$this->isWasiTarget()) { $code .= $this->getIndent() . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n"; $code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n"; } @@ -885,7 +906,9 @@ CODE; $code .= 'PHP_MINIT_FUNCTION(' . $this->getModuleName() . ') {' . PHP_EOL; $code .= 'zend_try {' . PHP_EOL; $code .= '// class/interface class entries' . PHP_EOL; - $code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; + if (!$this->isWasiTarget()) { + $code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; + } $code .= 'if (typephp_install_reflection_attribute_handlers() != SUCCESS) {' . PHP_EOL; $code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL; $code .= '}' . PHP_EOL; @@ -1289,7 +1312,9 @@ CODE; { $job = $this->maxJob; - $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_fiber_generator.cc'; + if (!$this->isWasiTarget()) { + $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_fiber_generator.cc'; + } $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_helper.cc'; // embed 需要 main 函数,以及 cli 的内置函数定义 @@ -1297,7 +1322,7 @@ CODE; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc'; } - if ($this->isBuildModeBin()) { + if ($this->isBuildModeBin() && !$this->isWasiTarget()) { $sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c'; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c'; } diff --git a/src/compiler.php b/src/compiler.php index 255c5bd1..87fb0a4a 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -1,5 +1,6 @@ = 2 && str_ends_with($argv[1], '.prof')) { profileAnalyze($argc, $argv); @@ -31,6 +37,16 @@ function main(int $argc, array $argv): void if ($translator->isDryRun()) { $buildDir = $translator->getBuildDir(); $count = count($sourceFiles); + $sourceListFile = getenv('TYPEPHP_GENERATED_SOURCE_LIST'); + if (is_string($sourceListFile) && $sourceListFile !== '') { + $sourceListDir = dirname($sourceListFile); + if (!is_dir($sourceListDir) && !mkdir($sourceListDir, 0777, true) && !is_dir($sourceListDir)) { + throw new RuntimeException("Unable to create generated source manifest directory: {$sourceListDir}"); + } + if (file_put_contents($sourceListFile, implode(PHP_EOL, $sourceFiles) . PHP_EOL) === false) { + throw new RuntimeException("Unable to write generated source manifest: {$sourceListFile}"); + } + } $translator->output("Dry run completed: {$count} C++ source file(s) generated in {$buildDir}", 'lightBlue'); return; } @@ -45,6 +61,108 @@ function main(int $argc, array $argv): void } } +/** + * Build a self-contained WASI command module through the compiler's public CLI. + * The lower-level build scripts are implementation details and are not part of + * the user-facing workflow. + */ +function compileWasmProgram(array $argv): void +{ + $input = null; + $buildDir = null; + $arguments = array_slice($argv, 1); + for ($i = 0, $count = count($arguments); $i < $count; $i++) { + $argument = $arguments[$i]; + if ($argument === '--wasm') { + continue; + } + if ($argument === '--build-dir') { + if (!isset($arguments[$i + 1]) || $arguments[$i + 1] === '') { + fwrite(STDERR, "Option --build-dir requires a directory\n"); + exit(1); + } + $buildDir = $arguments[++$i]; + continue; + } + if (str_starts_with($argument, '--build-dir=')) { + $buildDir = substr($argument, strlen('--build-dir=')); + if ($buildDir === '') { + fwrite(STDERR, "Option --build-dir requires a directory\n"); + exit(1); + } + continue; + } + if (str_starts_with($argument, '-')) { + fwrite(STDERR, "Unsupported option in --wasm mode: {$argument}\n"); + exit(1); + } + if ($input !== null) { + fwrite(STDERR, "The --wasm mode accepts exactly one PHP input file\n"); + exit(1); + } + $input = $argument; + } + + if ($input === null) { + fwrite(STDERR, "Usage: php bin/tpc.php --wasm [--build-dir ]\n"); + exit(1); + } + + $workingDirectory = getcwd(); + $buildDir ??= ROOT_PATH . DIRECTORY_SEPARATOR . 'build'; + if (!str_starts_with($buildDir, DIRECTORY_SEPARATOR) + && preg_match('/^[A-Za-z]:[\\\\\/]/', $buildDir) !== 1) { + $buildDir = $workingDirectory . DIRECTORY_SEPARATOR . $buildDir; + } + + $builder = dirname(__DIR__) . '/projects/php-8.5.9/wasm/build-typephp-program.sh'; + if (!is_executable($builder)) { + fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n"); + exit(1); + } + + try { + $tools = (new WasiToolchain())->detect(); + } catch (RuntimeException $exception) { + fwrite(STDERR, "WASI toolchain check failed: {$exception->getMessage()}\n"); + fwrite(STDERR, "Add WASI SDK and Wasmtime bin directories to PATH, then try again.\n"); + exit(1); + } + + $environment = getenv(); + if (!is_array($environment)) { + $environment = []; + } + $environment['TYPEPHP_WASI_CC'] = $tools['clang']; + $environment['TYPEPHP_WASI_CXX'] = $tools['clang++']; + $environment['TYPEPHP_WASI_AR'] = $tools['llvm-ar']; + $environment['TYPEPHP_WASI_RANLIB'] = $tools['llvm-ranlib']; + $environment['TYPEPHP_WASI_NM'] = $tools['llvm-nm']; + $environment['TYPEPHP_WASI_LD'] = $tools['wasm-ld']; + $environment['TYPEPHP_WASMTIME'] = $tools['wasmtime']; + $environment['TYPEPHP_WASI_TARGET'] = $tools['target']; + $environment['TYPEPHP_WASI_CLANG_VERSION'] = $tools['clang-version']; + $environment['TYPEPHP_WASMTIME_VERSION'] = $tools['wasmtime-version']; + $environment['TYPEPHP_WASM_PROGRAM_BUILD_DIR'] = $buildDir; + + $process = proc_open( + [$builder, $input], + [STDIN, STDOUT, STDERR], + $pipes, + getcwd(), + $environment, + ); + if (!is_resource($process)) { + fwrite(STDERR, "Failed to start the TypePHP WASI builder\n"); + exit(1); + } + + $exitCode = proc_close($process); + if ($exitCode !== 0) { + exit($exitCode); + } +} + function profileAnalyze(int $argc, array $argv): void { $profFile = $argv[1];