From ceeb1da3f9b603e150eee24ceddc94ca75ab8e53 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 24 Sep 2026 10:06:24 +0800 Subject: [PATCH] feat(compiler): add PHP builder and SAPI support with proxy configuration - Implement configureProxyEnvironment function to export CLI proxy for child processes - Add --sapi option supporting embed, cli, fpm targets with comma-separated lists - Introduce --php-builder option for building PHP from source with extensions and ZTS - Update documentation for new SAPI and PHP builder configuration options - Add PeclExtensionSource class for managing PECL extension downloads - Modify build configuration to support PHP builder and multiple SAPI targets - Update compiler base with new build mode validation and SAPI handling - Enhance native build configuration with PHP builder specific library paths - Add comprehensive test coverage for new SAPI and PHP builder features --- README-CN.md | 17 +- README.md | 20 +- completions/tpc.bash | 2 +- docs/en/COMPILER_CLI.md | 26 +- docs/en/LIBPHP_INSTALLER.md | 88 ++++--- docs/zh-cn/COMPILER_CLI.md | 23 +- docs/zh-cn/LIBPHP_INSTALLER.md | 82 +++---- .../compiler_api/extension_clean_maps.php | 5 + .../src/Build/PhpBuilderConfigurationTest.php | 59 +++++ .../src/Build/SapiBuildConfigurationTest.php | 10 +- phpunit/src/CompilerBaseApiTest.php | 129 +++++++++- .../Installer/PhpBuildConfigurationTest.php | 14 +- src/Backend/GccLikeBackend.php | 3 + src/Build/NativeBuildConfigurationTrait.php | 16 +- src/Build/NativeCommandOptionsTrait.php | 14 ++ src/Build/PeclExtensionSource.php | 112 +++++++++ src/Build/PhpBuilderConfiguration.php | 143 +++++++++++ src/Build/PhpBuilderSource.php | 94 +++++++ src/Build/SapiApplicationLinker.php | 7 + src/Build/SapiBuildConfiguration.php | 10 +- src/Build/SapiPhpBuilder.php | 97 ++++++-- src/Build/SourcePipelineTrait.php | 80 ++++-- src/CompilerBase.php | 27 +- src/Installer/PhpBuildConfiguration.php | 11 +- src/Metadata/Constants.php | 11 +- src/Translator.php | 231 +++++++++++++----- src/compiler.php | 39 +++ 27 files changed, 1117 insertions(+), 253 deletions(-) create mode 100644 phpunit/src/Build/PhpBuilderConfigurationTest.php create mode 100644 src/Build/PeclExtensionSource.php create mode 100644 src/Build/PhpBuilderConfiguration.php create mode 100644 src/Build/PhpBuilderSource.php diff --git a/README-CN.md b/README-CN.md index 80689c66..2f6412c7 100644 --- a/README-CN.md +++ b/README-CN.md @@ -190,11 +190,11 @@ php bin/tpc.php --help embed 安装前缀;在类 Unix 系统中,该目录应包含 `bin/php-config`、PHP 头文件和 `lib/libphp.so`。 -### 构建 `libphp.so` +### PHP 运行时选择 -二进制和共享库构建需要 PHP 的 `embed` SAPI。如果 Linux 上缺少 `libphp.so`, -`tpc.php` 可以交互式下载 PHP 源码并自动构建。PHP 扩展构建从宿主 SAPI 解析 Zend -符号,不能再加载第二份 `libphp`。详见[自动构建 libphp.so](docs/zh-cn/LIBPHP_INSTALLER.md)。 +二进制模式默认使用 Embed SAPI 和宿主机 `libphp`。找不到该库时,交互式构建会询问 +是否启用 `php-builder`,从 php-src 构建私有静态运行时。CLI 和 FPM 目标始终要求 +启用 `php-builder`。详见 [PHP builder](docs/zh-cn/LIBPHP_INSTALLER.md)。 ## 快速开始 @@ -687,13 +687,15 @@ bin/tpc.php --wasm=browser app.php | `-d`, `--debug` | 调试构建,带符号和源码跟踪 | | `-o`, `--output ` | 输出文件名 | | `-m`, `--mode ` | 构建模式(默认 `bin`) | +| `--sapi ` | 二进制使用的 SAPI(默认 `embed`,支持逗号分隔多个值) | +| `--php-builder <配置>` | 从 php-src 构建私有 PHP 运行时 | | `-r`, `--run` | 构建成功后运行 | | `-j`, `--job ` | 并行编译任务数(默认 `4`) | | `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 | | `--build-dir ` | 生成 C++ 与中间产物的目录 | | `--dry` | 只生成 C++,跳过编译与链接 | | `--php-version <8.4\|8.5>` | 接受的 PHP 语法版本 | -| `--proxy ` | 下载 PHP 元数据和源码归档时使用的代理 | +| `--proxy ` | 所有网络传输使用的代理 | | `--cxx-std ` | C++ 标准(如 `c++17`、`c++20`) | | `--march ` | 目标指令集(如 `native`) | | `--target-platform ` | 交叉编译目标 triple | @@ -714,8 +716,9 @@ source <(./tpc --generate-completion=bash) ## 常见问题 -- **缺少 `libphp.so` / `libphp.dylib`:** 安装或编译与当前 PHP 匹配的 embed SAPI,设置 - `PHP_HOME`,或使用 `bin/tpc.php` 在 Linux 上提供的交互式安装流程。 +- **缺少 `libphp.so` / `libphp.dylib`:** 安装匹配的 Embed SAPI、设置 `PHP_HOME`、 + 接受交互式 `php-builder` 提示,或在非交互构建中传入 + `--php-builder='extensions: []; zts: off'`。 - **找不到 PHPX:** 将 `PHPX_HOME` 指向包含 `include/` 和 `lib/libphpx.so`(或对应平台文件)的 PHPX 安装目录,并在编译项目前先构建 PHPX。 - **启动崩溃或出现 ABI 错误:** PHP 头文件、`php-config`、`libphp` 和扩展 ABI diff --git a/README.md b/README.md index 3f8cd2e0..0b6c077e 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,12 @@ php bin/tpc.php --help may point to the PHP embed prefix; it must contain `bin/php-config`, PHP headers, and `lib/libphp.so` on Unix-like systems. -### Building `libphp.so` +### PHP runtime selection -Binary and shared-library builds require PHP's `embed` SAPI. If `libphp.so` is -missing on Linux, `tpc.php` can interactively download the PHP source and build -it for you. A PHP extension build resolves Zend symbols from the host SAPI and -must not load a second `libphp`. See -[Automatic libphp.so build](docs/en/LIBPHP_INSTALLER.md). +Binary mode uses the Embed SAPI and the host `libphp` by default. If that library +is missing, an interactive build offers to enable `php-builder`, which builds a +private static runtime from php-src. CLI and FPM targets always require +`php-builder`. See [PHP builder](docs/en/LIBPHP_INSTALLER.md). ## Quick Start @@ -755,13 +754,15 @@ Key options: | `-d`, `--debug` | Debug build with symbols and source tracking | | `-o`, `--output ` | Output file name | | `-m`, `--mode ` | Build mode (default `bin`) | +| `--sapi ` | Binary SAPI target (default `embed`; comma-separated lists accepted) | +| `--php-builder ` | Build a private PHP runtime from php-src | | `-r`, `--run` | Run after a successful build | | `-j`, `--job ` | Parallel compile jobs (default `4`) | | `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache | | `--build-dir ` | Directory for generated C++ and intermediates | | `--dry` | Generate C++ only, skip compile and link | | `--php-version <8.4\|8.5>` | PHP syntax version to accept | -| `--proxy ` | Proxy used for PHP metadata and source archive downloads | +| `--proxy ` | Proxy used for network transfers | | `--cxx-std ` | C++ standard (e.g. `c++17`, `c++20`) | | `--march ` | Target instruction set (e.g. `native`) | | `--target-platform ` | Cross-compilation target triple | @@ -782,8 +783,9 @@ source <(./tpc --generate-completion=bash) ## Troubleshooting -- **`libphp.so` / `libphp.dylib` is missing:** install/build the matching PHP embed SAPI, set - `PHP_HOME`, or let `bin/tpc.php` offer the interactive Linux installer. +- **`libphp.so` / `libphp.dylib` is missing:** install the matching Embed SAPI, + set `PHP_HOME`, accept the interactive `php-builder` prompt, or pass + `--php-builder='extensions: []; zts: off'` in non-interactive builds. - **PHPX cannot be found:** set `PHPX_HOME` to a PHPX installation containing `include/` and `lib/libphpx.so` (or the platform equivalent), then build PHPX before compiling the project. diff --git a/completions/tpc.bash b/completions/tpc.bash index 0ce657e2..4d22413e 100644 --- a/completions/tpc.bash +++ b/completions/tpc.bash @@ -144,7 +144,7 @@ _typephp_tpc() return ;; -* ) - COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) + COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) return ;; esac diff --git a/docs/en/COMPILER_CLI.md b/docs/en/COMPILER_CLI.md index d10560fd..a01ce45a 100644 --- a/docs/en/COMPILER_CLI.md +++ b/docs/en/COMPILER_CLI.md @@ -67,6 +67,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `-d`, `--debug` | Debug build; disables optimization and adds debug symbols and TypePHP source tracking. | | `-o`, `--output ` | Output file name. | | `-m`, `--mode ` | Build mode, default `bin`. | +| `--sapi ` | SAPI used by a `bin` target; accepts a comma-separated list. Default: `embed`. | +| `--php-builder ` | Build PHP from php-src, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. | | `-r`, `--run` | Run after a successful build. | | `-j`, `--job ` | Number of parallel compilation jobs, default `4`. | | `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. | @@ -75,10 +77,32 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `--format` | Run clang-format on the generated code. | | `--no-progress` | Do not show the progress bar; output progress per file. | | `--no-color` | Disable colored output. | -| `--proxy ` | Use an HTTP(S) or SOCKS proxy for PHP metadata and source archive downloads. | +| `--proxy ` | Use an HTTP(S) or SOCKS proxy for network transfers. | `-v` / `--version` only displays the version; it is not a verbose option. +`mode` describes the artifact type, while `sapi` describes the PHP process +interface. `cli` and `fpm` are not build modes. They require `php-builder`; +`embed` can use either the host `libphp` (the default) or a private static PHP +runtime. When the default Embed build cannot find the host library, an +interactive invocation offers to enable `php-builder`. In CI, pass the option +explicitly. + +The equivalent YAML is: + +```yaml +mode: bin +sapi: [cli, fpm, embed] +php-builder: + extensions: [swoole, mongodb] + zts: on +``` + +`php-builder` does not depend on the host PHP runtime or modify the downloaded +php-src tree. It collects extension requirements from project sources, YAML, +and Composer metadata, then configures a private static runtime using libraries +provided by the operating system. + ## Target and Toolchain | Option | Description | diff --git a/docs/en/LIBPHP_INSTALLER.md b/docs/en/LIBPHP_INSTALLER.md index 20f4645a..0557740a 100644 --- a/docs/en/LIBPHP_INSTALLER.md +++ b/docs/en/LIBPHP_INSTALLER.md @@ -1,67 +1,61 @@ -# Automatically building libphp.so +# PHP builder -TypePHP's executable and shared-library modes require the `libphp.so` provided by the PHP Embed SAPI. Many Linux distributions' PHP packages only include CLI or FPM, so the Composer-installed `tpc.php` asks whether to automatically build a private PHP when `libphp.so` is not found. +For `mode: bin`, TypePHP separates the artifact type from the PHP SAPI: -The installer targets `tpc.php` launched by the PHP interpreter. The bootstrap binary `tpc` must have `libphp.so` and `libphpx.so` loaded by the system dynamic linker before entering `main()`, so it cannot install missing libraries by itself. +```yaml +mode: bin +sapi: [embed, cli, fpm] +php-builder: + extensions: [swoole, mongodb] + zts: on +``` -This feature is only enabled on Linux and in interactive terminals. Extension mode (`-m ext`) does not need `libphp.so` and does not trigger the installer; non-interactive environments such as CI also do not automatically download or install packages, or execute `sudo`. +`sapi` selects one or more process interfaces. `embed` is the default. `cli` +and `fpm` are not modes and always require `php-builder` because a host PHP +installation does not provide the static build inputs needed by these targets. -## Usage flow +`php-builder` means that TypePHP builds a private PHP runtime from an official +php-src archive. The result does not depend on the host PHP runtime, but it does +use development libraries supplied by the operating system. The original +php-src cache is left unchanged; external PECL extensions are injected into a +derived source tree. Extension requirements are merged from the explicit +`extensions` list, project sources, YAML dependencies, and embedded Composer +metadata, and then translated to PHP configure options. -Simply run the normal compile command: +The command-line equivalent is: ```bash -vendor/bin/tpc.php project.yml +bin/tpc.php project.yml \ + --sapi=embed,cli,fpm \ + --php-builder='extensions: [swoole, mongodb]; zts: on' ``` -When `libphp.so` is missing, the installer asks in sequence: - -1. whether to automatically build the PHP Embed library; -2. the PHP version, defaulting to exactly the `PHP_VERSION` of the currently running `tpc.php`, with the option to manually enter another PHP 8.4.x/8.5.x stable version; -3. the install directory, defaulting to `~/.typephp`; -4. whether to install missing development packages via the detected `apt-get`, `dnf`, or `yum`. - -The installer reads the current `php-config --configure-options`, keeps the current PHP's supported extension configuration, replaces the install path, and adds `--enable-embed=shared`. It always uses `--without-pear` because PEAR is deprecated. PHP source is downloaded only from PHP.net, and verified using the SHA-256 from the official release information. - -After compilation, the main files are as follows: - -```text -~/.typephp/bin/php -~/.typephp/bin/php-config -~/.typephp/lib/libphp.so -~/.typephp/lib/php.ini -~/.typephp/lib/loaded-extensions.txt -``` +`zts` accepts `on` or `off`. `php-builder.sapi` is invalid; SAPI selection is +always the independent top-level `sapi` option. -The current main ini file and the configuration in the scan directory are merged. When the same PHP major/minor version is used, the shared extensions loaded in the current ini are copied to the new extension directory; across major/minor versions, binary extensions are not copied, and unusable extension configuration is commented out to avoid a generated PHP that cannot start. +## Default Embed behavior -After a successful installation, the current `tpc.php` process automatically uses the new directory as `PHP_HOME` and continues the original compile task. It can also be specified explicitly later: +Without `php-builder`, `mode: bin` plus `sapi: embed` preserves the traditional +behavior and links the host Embed library (`libphp.so` or `libphp.dylib`). If it +is unavailable on Linux or macOS, an interactive invocation asks whether to +enable `php-builder`. In a non-interactive environment, choose explicitly: ```bash -export PHP_HOME="$HOME/.typephp" -vendor/bin/tpc.php project.yml +bin/tpc.php project.yml \ + --php-builder='extensions: []; zts: off' ``` -When the same directory and version are chosen again, the installer asks whether to directly reuse the existing `libphp.so` and does not repeat the full build. +Declining the prompt leaves no usable Embed runtime and stops the build. -## Non-interactive environments +## Cache and proxy -The installer does not automatically confirm privileged operations in CI. Prepare `libphp.so` in advance, then set: +Downloaded sources and private runtimes are cached under `~/.typephp`. Compatible +runtimes are reused across application builds. `--proxy` is a global network +setting rather than a `php-builder` field; PHP and PECL metadata and archives, +and any other TypePHP network transfers, use the configured HTTP(S) or SOCKS +proxy. ```bash -PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml +bin/tpc.php project.yml --proxy=socks5h://127.0.0.1:1080 \ + --php-builder='extensions: []; zts: off' ``` - -## Automatically building libphpx.so - -After the PHP Embed check completes on Linux, the build pipeline also checks `lib/libphpx.so` in the PHPX root directory. The PHPX root directory is resolved in the following order: - -1. `PHPX_HOME`; -2. the `swoole/phpx` install path in Composer `InstalledVersions`; -3. `vendor/swoole/phpx` within the TypePHP source repository. - -When the shared library is missing, an interactive terminal asks whether to build. PHPX itself does not depend on `libphp.so`; it depends on PHP headers and `php-config`. `LibPhpxInstaller` uses the currently selected PHP prefix, sets `PHP_HOME`, puts that prefix's `bin` at the front of `PATH`, and passes `-Dphp_dir=` to the PHPX CMake, ensuring PHPX matches the PHP ABI used by the project runtime. - -Toolchain detection runs after the local library checks. After the user confirms the automatic build, the installer checks and can install GCC/G++, make, CMake, pkg-config, and the dependencies required by PHP configure via `apt-get`, `dnf`, or `yum`; therefore a Composer environment does not need a full pre-installed C/C++ toolchain. - -The build always uses Release, disables PHPX tests, and builds only the `phpx` target; parallelism is capped at 8. The output must be `/lib/libphpx.so`, otherwise the installer reports an error. Non-interactive environments only report the missing library and do not run CMake. diff --git a/docs/zh-cn/COMPILER_CLI.md b/docs/zh-cn/COMPILER_CLI.md index 48a007e6..729c6336 100644 --- a/docs/zh-cn/COMPILER_CLI.md +++ b/docs/zh-cn/COMPILER_CLI.md @@ -67,6 +67,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `-d`, `--debug` | 调试构建;关闭优化、增加调试符号和 TypePHP 源码跟踪。 | | `-o`, `--output ` | 输出文件名。 | | `-m`, `--mode ` | 构建模式,默认 `bin`。 | +| `--sapi ` | `bin` 目标使用的 SAPI,支持逗号分隔的多个值;默认 `embed`。 | +| `--php-builder <配置>` | 从 php-src 构建 PHP,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 | | `-r`, `--run` | 构建成功后运行。 | | `-j`, `--job ` | 并行编译任务数,默认 `4`。 | | `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 | @@ -75,10 +77,29 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `--format` | 对生成代码运行 clang-format。 | | `--no-progress` | 不显示进度条,逐文件输出进度。 | | `--no-color` | 禁用彩色输出。 | -| `--proxy ` | 下载 PHP 元数据和源码归档时使用 HTTP(S) 或 SOCKS 代理。 | +| `--proxy ` | 所有网络传输使用 HTTP(S) 或 SOCKS 代理。 | `-v` / `--version` 只显示版本,不是 verbose 选项。 +`mode` 表达产物类型,`sapi` 表达 PHP 进程接口;`cli` 和 `fpm` 不是构建模式。 +两者都强依赖 `php-builder`。`embed` 既可使用默认的宿主机 `libphp`,也可使用 +私有的静态 PHP 运行时。当默认 Embed 构建找不到宿主库时,交互式运行会询问是否启用 +`php-builder`;CI 中应显式传入该选项。 + +等价的 YAML 配置为: + +```yaml +mode: bin +sapi: [cli, fpm, embed] +php-builder: + extensions: [swoole, mongodb] + zts: on +``` + +`php-builder` 不依赖宿主机 PHP 运行时,也不会修改下载的 php-src 原始目录。它会从 +项目源码、YAML 和 Composer 元数据收集扩展依赖,自动生成 configure 参数,并使用 +操作系统提供的底层库构建私有静态运行时。 + ## 目标和工具链 | 选项 | 说明 | diff --git a/docs/zh-cn/LIBPHP_INSTALLER.md b/docs/zh-cn/LIBPHP_INSTALLER.md index 1ca18e1f..b7dd96ac 100644 --- a/docs/zh-cn/LIBPHP_INSTALLER.md +++ b/docs/zh-cn/LIBPHP_INSTALLER.md @@ -1,67 +1,55 @@ -# 自动构建 libphp.so +# PHP builder -TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 Composer 安装的 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 +在 `mode: bin` 下,TypePHP 将产物类型与 PHP SAPI 分开配置: -该安装器面向由 PHP 解释器启动的 `tpc.php`。自举二进制 `tpc` 在进入 `main()` 前就必须由系统动态链接器加载 `libphp.so` 和 `libphpx.so`,不能依靠自身补装缺失库。 +```yaml +mode: bin +sapi: [embed, cli, fpm] +php-builder: + extensions: [swoole, mongodb] + zts: on +``` -该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo`。 +`sapi` 用于选择一个或多个进程接口,默认值是 `embed`。`cli` 和 `fpm` 不是 mode; +由于宿主 PHP 安装不提供这些目标所需的静态构建输入,它们始终强依赖 +`php-builder`。 -## 使用流程 +`php-builder` 表示 TypePHP 从官方 php-src 归档构建私有 PHP 运行时。产物不依赖 +宿主机 PHP 运行时,但会使用操作系统提供的开发库。官方 php-src 缓存不会被修改; +外部 PECL 扩展会注入派生的源码目录。显式 `extensions`、项目源码、YAML 依赖和 +内嵌 Composer 元数据中的扩展需求会合并,并自动转换为 PHP configure 参数。 -正常执行编译命令即可: +等价的命令行为: ```bash -vendor/bin/tpc.php project.yml +bin/tpc.php project.yml \ + --sapi=embed,cli,fpm \ + --php-builder='extensions: [swoole, mongodb]; zts: on' ``` -缺少 `libphp.so` 时,安装器会依次询问: - -1. 是否自动构建 PHP Embed 库; -2. PHP 版本,默认与当前运行 `tpc.php` 的 `PHP_VERSION` 完全一致,也可手动输入其他 PHP 8.4.x/8.5.x 稳定版本; -3. 安装目录,默认为 `~/.typephp`; -4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。 - -安装器读取当前 `php-config --configure-options`,保留当前 PHP 支持的扩展配置,替换安装路径并加入 `--enable-embed=shared`。由于 PEAR 已废弃,构建始终使用 `--without-pear`。PHP 源码只从 PHP.net 下载,并使用官方发布信息中的 SHA-256 校验。 - -编译完成后主要文件如下: - -```text -~/.typephp/bin/php -~/.typephp/bin/php-config -~/.typephp/lib/libphp.so -~/.typephp/lib/php.ini -~/.typephp/lib/loaded-extensions.txt -``` +`zts` 接受 `on` 或 `off`。`php-builder.sapi` 是非法配置;SAPI 始终通过独立的 +顶层 `sapi` 选项指定。 -当前 ini 主文件和扫描目录中的配置会被合并。使用相同 PHP 主次版本时,当前 ini 中加载的共享扩展会复制到新扩展目录;跨主次版本时不会复制二进制扩展,不可用的扩展配置会被注释,避免生成的 PHP 无法启动。 +## 默认 Embed 行为 -安装成功后,当前 `tpc.php` 进程会自动将新目录作为 `PHP_HOME` 并继续原来的编译任务。后续也可以显式指定: +未配置 `php-builder` 时,`mode: bin` 加 `sapi: embed` 保持原有行为,链接宿主机 +Embed 库(`libphp.so` 或 `libphp.dylib`)。Linux 或 macOS 上缺少该库时,交互式 +运行会询问是否启用 `php-builder`。非交互环境必须显式选择: ```bash -export PHP_HOME="$HOME/.typephp" -vendor/bin/tpc.php project.yml +bin/tpc.php project.yml \ + --php-builder='extensions: []; zts: off' ``` -再次选择同一目录和版本时,安装器会询问是否直接复用已有的 `libphp.so`,不会重复执行完整构建。 +若拒绝提示,则没有可用的 Embed 运行时,构建会停止。 -## 非交互环境 +## 缓存与代理 -安装器不会在 CI 中自动确认权限操作。请提前准备 `libphp.so`,然后设置: +下载的源码和私有运行时缓存在 `~/.typephp`,兼容的运行时会在不同应用构建之间复用。 +`--proxy` 是全局网络设置,不属于 `php-builder`;PHP、PECL 元数据与归档,以及 +TypePHP 的其他网络传输,都会使用指定的 HTTP(S) 或 SOCKS 代理。 ```bash -PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml +bin/tpc.php project.yml --proxy=socks5h://127.0.0.1:1080 \ + --php-builder='extensions: []; zts: off' ``` - -## 自动构建 libphpx.so - -Linux 下完成 PHP Embed 检查后,构建流水线还会检查 PHPX 根目录中的 `lib/libphpx.so`。PHPX 根目录按以下顺序解析: - -1. `PHPX_HOME`; -2. Composer `InstalledVersions` 中的 `swoole/phpx` 安装路径; -3. TypePHP 源码仓库内的 `vendor/swoole/phpx`。 - -缺少共享库时,交互式终端会询问是否构建。PHPX 本身不依赖 `libphp.so`;它依赖 PHP 头文件和 `php-config`。`LibPhpxInstaller` 使用当前选定的 PHP 前缀,同时设置 `PHP_HOME`、将该前缀的 `bin` 放到 `PATH` 首位,并向 PHPX CMake 传递 `-Dphp_dir=`,确保 PHPX 与项目运行时使用的 PHP ABI 一致。 - -工具链检测在本地库检查之后执行。用户确认自动构建后,安装器通过 `apt-get`、`dnf` 或 `yum` 检查并可安装 GCC/G++、make、CMake、pkg-config 以及 PHP configure 所需依赖;因此 Composer 环境无需预先安装完整 C/C++ 工具链。 - -构建固定使用 Release、关闭 PHPX tests,并只构建 `phpx` target;并行度最多为 8。输出必须为 `/lib/libphpx.so`,否则安装器报错。非交互环境只提示缺失,不执行 CMake。 diff --git a/phpunit/code/compiler_api/extension_clean_maps.php b/phpunit/code/compiler_api/extension_clean_maps.php index 74dd0adb..7e87dd21 100644 --- a/phpunit/code/compiler_api/extension_clean_maps.php +++ b/phpunit/code/compiler_api/extension_clean_maps.php @@ -4,3 +4,8 @@ function main(): void { echo "ok\n"; } + +final class RequestStaticCache +{ + private static array $values = []; +} diff --git a/phpunit/src/Build/PhpBuilderConfigurationTest.php b/phpunit/src/Build/PhpBuilderConfigurationTest.php new file mode 100644 index 00000000..ae9786d7 --- /dev/null +++ b/phpunit/src/Build/PhpBuilderConfigurationTest.php @@ -0,0 +1,59 @@ + 'on', + 'extensions' => ['swoole', 'ext-mongodb', 'swoole'], + ]); + + self::assertTrue($configuration->zts); + self::assertSame(['mongodb', 'swoole'], $configuration->extensions); + } + + public function testParsesCommandLineSyntax(): void + { + $configuration = PhpBuilderConfiguration::fromCommandLine( + 'extensions: [curl, mbstring]; zts: off;', + ); + + self::assertFalse($configuration->zts); + self::assertSame(['curl', 'mbstring'], $configuration->extensions); + } + + public function testRejectsLegacyListOfMappingsYamlShape(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('`php-builder` must be a mapping'); + + PhpBuilderConfiguration::fromYaml([ + ['zts' => true], + ['extensions' => []], + ]); + } + + public function testRejectsUnknownOption(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown `php-builder` option'); + + PhpBuilderConfiguration::fromYaml(['sapi' => ['embed']]); + } +} diff --git a/phpunit/src/Build/SapiBuildConfigurationTest.php b/phpunit/src/Build/SapiBuildConfigurationTest.php index 58609df2..1490fa45 100644 --- a/phpunit/src/Build/SapiBuildConfigurationTest.php +++ b/phpunit/src/Build/SapiBuildConfigurationTest.php @@ -1,24 +1,24 @@ expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Expected cli, fpm, or both'); + $this->expectExceptionMessage('Expected embed, cli, or fpm'); SapiBuildConfiguration::parseTargets('apache'); } } diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index 8431fc30..0003eb07 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -9,6 +9,7 @@ namespace TypePhp\Tests; use PHPUnit\Framework\TestCase; +use TypePhp\Build\PhpBuilderConfiguration; use TypePhp\CompilerBase; use TypePhp\CompilerTest; use TypePhp\Exception\TestError; @@ -250,10 +251,17 @@ PHP); $this->compiler->setBuildMode('extension'); $this->assertSame(CompilerBase::BUILD_MODE_EXT, $this->compiler->getBuildMode()); - $this->compiler->setBuildMode('cli'); + $this->compiler->setBuildMode('binary'); $this->assertSame(CompilerBase::BUILD_MODE_BIN, $this->compiler->getBuildMode()); } + public function testCliIsNotAcceptedAsABuildMode(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Invalid build mode `cli`. Expected bin, lib, or ext.'); + $this->compiler->setBuildMode('cli'); + } + public function testPhpLanguageVersionControlsParser(): void { $this->assertSame('8.5.0', $this->compiler->getPhpVersion()); @@ -908,13 +916,14 @@ YAML, 'myproject.yml', 'examples/tetris-sdl'); ); } - public function testBothSapiTargetsHaveExplicitTargetSuffixes(): void + public function testMultipleSapiTargetsHaveExplicitTargetSuffixes(): void { $this->compiler->setOutputPath($this->testDir . '/build/app'); - $this->invokeMethod('configureSapiTargets', 'both'); + $this->invokeMethod('configureSapiTargets', ['embed', 'cli', 'fpm']); $this->assertSame( [ + 'embed' => $this->testDir . '/build/app-embed', 'cli' => $this->testDir . '/build/app-cli', 'fpm' => $this->testDir . '/build/app-fpm', ], @@ -922,17 +931,72 @@ YAML, 'myproject.yml', 'examples/tetris-sdl'); ); } - public function testSapiBuildModeDefaultsToCliTarget(): void + public function testCombinedSapiModuleKeepsEmbedOnlyFunctionsOutOfCliAndFpm(): void + { + global $translator; + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $compiler->setTargetName('combined_sapi'); + $configureSapi = new \ReflectionMethod($compiler, 'configureSapiTargets'); + $configureSapi->invoke($compiler, ['embed', 'cli', 'fpm']); + $testFile = TYPEPHP_ROOT_PATH . '/phpunit/code/compiler_api/extension_clean_maps.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $compiler->convertFile($testFile); + + $extension = (string) file_get_contents($compiler->genExtension()); + self::assertStringContainsString('static const zend_function_entry ext_functions[]', $extension); + self::assertStringContainsString('static const zend_function_entry sapi_ext_functions[]', $extension); + self::assertSame(1, substr_count($extension, 'PHP_FE(cli_set_process_title')); + self::assertStringContainsString('_sapi_module_entry;', $extension); + self::assertStringContainsString('if (strcmp(sapi_module.name, "embed") == 0)', $extension); + } + + public function testPhpBuilderUsesDefaultEmbedSapi(): void { - $this->compiler->setBuildMode(CompilerBase::BUILD_MODE_SAPI); + $this->invokeMethod('configurePhpBuilder', PhpBuilderConfiguration::fromYaml([])); - $this->assertSame(['cli'], $this->compiler->getSapiTargets()); - $this->assertTrue($this->compiler->isSapiBuild()); + $this->assertSame(['embed'], $this->compiler->getSapiTargets()); + $this->assertTrue($this->compiler->isPhpBuilderBuild()); + $this->assertFalse($this->compiler->isSapiBuild()); + $this->assertTrue($this->compiler->isBuildModeBin()); } - public function testSapiCliEntryIsEmbeddedAndExcludedFromAotSources(): void + public function testCliSapiRequiresPhpBuilder(): void + { + $this->invokeMethod('configureSapiTargets', 'fpm'); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('The cli and fpm SAPIs require `php-builder`'); + $this->invokeMethod('validateLoadedProjectConfiguration'); + } + + public function testSapiOptionIsOnlyValidForBinaryMode(): void + { + $this->compiler->setBuildMode('lib'); + $this->invokeMethod('configureSapiTargets', 'embed'); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('`sapi` is only supported with `mode: bin`'); + $this->invokeMethod('validateLoadedProjectConfiguration'); + } + + public function testPhpBuilderIsOnlyValidForBinaryMode(): void + { + $this->compiler->setBuildMode('ext'); + $this->invokeMethod('configurePhpBuilder', PhpBuilderConfiguration::fromYaml([])); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('`php-builder` is only supported with `mode: bin`'); + $this->invokeMethod('validateLoadedProjectConfiguration'); + } + + public function testPhpBuilderCliEntryIsEmbeddedAndExcludedFromAotSources(): void { $projectFile = $this->createProjectFile(<<<'YAML' +php-builder: + zts: on + extensions: [] sapi: cli entry: main.php sources: @@ -949,9 +1013,10 @@ YAML); $this->invokeMethod('validateLoadedProjectConfiguration'); } - public function testSapiCliRequiresEntry(): void + public function testPhpBuilderCliRequiresEntry(): void { $projectFile = $this->createProjectFile(<<<'YAML' +php-builder: {} sapi: cli sources: - main.php @@ -959,13 +1024,14 @@ YAML); $this->invokeMethod('parseProjectYaml', $projectFile); $this->expectException(TestError::class); - $this->expectExceptionMessage('CLI SAPI builds require an `entry` PHP file'); + $this->expectExceptionMessage('`sapi` containing cli requires an `entry` PHP file'); $this->invokeMethod('validateLoadedProjectConfiguration'); } - public function testSapiFpmDoesNotRequireCliEntry(): void + public function testPhpBuilderFpmDoesNotRequireCliEntry(): void { $projectFile = $this->createProjectFile(<<<'YAML' +php-builder: {} sapi: fpm sources: - main.php @@ -976,6 +1042,25 @@ YAML); $this->assertSame(['fpm'], $this->compiler->getSapiTargets()); } + public function testPhpBuilderFpmDoesNotRequireCompiledMainFunction(): void + { + global $translator; + $compiler = CompilerTest::create($this->testDir); + $translator = $compiler; + $compiler->setTargetName('fpm_without_main'); + $configureSapi = new \ReflectionMethod($compiler, 'configureSapiTargets'); + $configureSapi->invoke($compiler, 'fpm'); + $source = $this->testDir . '/fpm-source.php'; + file_put_contents($source, "addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + + $extension = (string) file_get_contents($compiler->genExtension()); + self::assertStringContainsString('sapi_ext_functions', $extension); + self::assertStringNotContainsString('php::eval(', $extension); + } + public function testProjectWithoutAotSourcesUsesRuntimeDeclarationHeader(): void { $this->compiler->setTargetName('opcode_only'); @@ -1590,7 +1675,26 @@ YAML); $this->assertIsInt($moduleInitStart, $mode); $this->assertIsInt($moduleCleanStart, $mode); $moduleInit = substr($extension, $moduleInitStart, $moduleCleanStart - $moduleInitStart); + $moduleClean = substr($extension, $moduleCleanStart); $this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode); + if ($mode === CompilerBase::BUILD_MODE_BIN) { + $this->assertStringContainsString( + 'if (strcmp(sapi_module.name, "embed") == 0)', + $moduleClean, + $mode, + ); + $this->assertStringContainsString( + 'php::setStaticProperty("RequestStaticCache", "values", php::Array{});', + $moduleClean, + $mode, + ); + } else { + $this->assertStringNotContainsString( + 'if (strcmp(sapi_module.name, "embed") == 0)', + $moduleClean, + $mode, + ); + } $this->assertMatchesRegularExpression( '/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*' . 'php::request_shutdown\(\);\s*' @@ -1751,6 +1855,9 @@ YAML); $extension = file_get_contents($extensionFile); $this->assertStringContainsString('php::Str php_exported_defaults_arg_0_default_value() {', $extension); $this->assertStringContainsString('static php::Str _literal_strings[]', $extension); + $this->assertStringNotContainsString('_literal_string_values', $extension); + $this->assertStringNotContainsString('ZVAL_STRINGL(_literal_strings[', $extension); + $this->assertStringNotContainsString('ZVAL_NULL(_literal_strings[', $extension); $this->assertStringContainsString('php::Str &get_str(uint32_t index) noexcept {', $extension); $this->assertStringContainsString('return get_str(', $extension); $this->assertStringContainsString('php::Array php_exported_variadic_arg_0_default_value() {', $extension); diff --git a/phpunit/src/Installer/PhpBuildConfigurationTest.php b/phpunit/src/Installer/PhpBuildConfigurationTest.php index 59e66bbc..e6758def 100644 --- a/phpunit/src/Installer/PhpBuildConfigurationTest.php +++ b/phpunit/src/Installer/PhpBuildConfigurationTest.php @@ -140,7 +140,7 @@ final class PhpBuildConfigurationTest extends TestCase public function testSapiDerivationBuildsStaticCliAndSelectedFpm(): void { - $options = PhpBuildConfiguration::deriveSapi( + $options = PhpBuildConfiguration::derivePhpBuilder( [ '--prefix=/usr', '--enable-embed=shared', @@ -151,11 +151,13 @@ final class PhpBuildConfigurationTest extends TestCase ], '/tmp/typephp-sapi', ['cli', 'fpm'], + true, ); self::assertContains('--prefix=/tmp/typephp-sapi', $options); self::assertContains('--enable-cli', $options); self::assertContains('--enable-fpm', $options); + self::assertContains('--enable-zts', $options); self::assertContains('--enable-opcache', $options); self::assertContains('--with-curl', $options); self::assertContains('--without-pear', $options); @@ -164,6 +166,16 @@ final class PhpBuildConfigurationTest extends TestCase self::assertNotContains('--with-curl=shared', $options); } + public function testPhpBuilderDerivationBuildsStaticEmbedWithoutZts(): void + { + $options = PhpBuildConfiguration::derivePhpBuilder([], '/tmp/typephp-embed', ['embed'], false); + + self::assertContains('--enable-embed=static', $options); + self::assertContains('--enable-cli', $options); + self::assertContains('--disable-fpm', $options); + self::assertContains('--disable-zts', $options); + } + public function testParseShellWordsRejectsIncompleteInput(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/src/Backend/GccLikeBackend.php b/src/Backend/GccLikeBackend.php index 2d94ad16..06922bbb 100644 --- a/src/Backend/GccLikeBackend.php +++ b/src/Backend/GccLikeBackend.php @@ -292,6 +292,9 @@ abstract class GccLikeBackend extends CompilerBackend if (!empty($options['libraries'])) { $cmd .= ' ' . $this->formatLibraries($options['libraries']); } + if (!empty($options['post_ldflags'])) { + $cmd .= ' ' . $options['post_ldflags']; + } return $cmd; } diff --git a/src/Build/NativeBuildConfigurationTrait.php b/src/Build/NativeBuildConfigurationTrait.php index 185a37a8..f6fc7585 100644 --- a/src/Build/NativeBuildConfigurationTrait.php +++ b/src/Build/NativeBuildConfigurationTrait.php @@ -177,9 +177,9 @@ trait NativeBuildConfigurationTrait $includePaths = array_merge($includePaths, $phpSdkPaths); } else { // Linux/macOS - $phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir(), $this->isSapiBuild()); + $phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir(), $this->isPhpBuilderBuild()); $includePaths = array_merge($includePaths, $phpPaths); - if ($this->isSapiBuild() && $this->sapiPhpBuildDirectory !== null) { + if ($this->isPhpBuilderBuild() && $this->sapiPhpBuildDirectory !== null) { $makefile = $this->sapiPhpBuildDirectory . '/Makefile'; $contents = is_file($makefile) ? (string) file_get_contents($makefile) : ''; if (preg_match('/^INCLUDES[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) { @@ -211,9 +211,11 @@ trait NativeBuildConfigurationTrait } $platform = $this->getPlatform(); - $libraryPaths = [ - $this->getPhpxDir() . '/lib', - ]; + $libraryPaths = []; + if ($this->isPhpBuilderBuild() && $this->sapiPhpxArchive !== null) { + $libraryPaths[] = dirname($this->sapiPhpxArchive); + } + $libraryPaths[] = $this->getPhpxDir() . '/lib'; // Add the platform-specific PHP library paths if ($platform instanceof Windows) { @@ -335,6 +337,10 @@ trait NativeBuildConfigurationTrait $platform = $this->getPlatform(); + if ($this->isPhpBuilderBuild() && $this->sapiPhpxArchive !== null) { + return is_file($this->sapiPhpxArchive) ? $this->sapiPhpxArchive : null; + } + if ($platform instanceof Windows) { $phpxLibPath = $this->getPhpxDir() . '\\lib\\phpx.lib'; return is_file($phpxLibPath) ? $phpxLibPath : null; diff --git a/src/Build/NativeCommandOptionsTrait.php b/src/Build/NativeCommandOptionsTrait.php index cb01ce40..05ffa9a6 100644 --- a/src/Build/NativeCommandOptionsTrait.php +++ b/src/Build/NativeCommandOptionsTrait.php @@ -156,6 +156,19 @@ trait NativeCommandOptionsTrait $libraries = array_merge($libraries, $this->linkLibs); $ldflags = $this->ldflags; + $postLdflags = ''; + if ($this->isPhpBuilderBuild() + && $this->hasSapi('embed') + && $this->sapiPhpBuildDirectory !== null + ) { + $makefile = $this->sapiPhpBuildDirectory . '/Makefile'; + $contents = is_file($makefile) ? (string) file_get_contents($makefile) : ''; + if (preg_match('/^EXTRA_LIBS[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) { + // Static libphp references these system libraries, so they must + // appear after -lphp on linkers that resolve archives left-to-right. + $postLdflags = trim($match[1]); + } + } $targetPlatform = $this->targetPlatform; if ($this->fullStatic) { // The bundled libphp.a carries musl libc, so the link must use musl's @@ -181,6 +194,7 @@ trait NativeCommandOptionsTrait 'library_paths' => $libraryPaths, 'libraries' => $libraries, 'ldflags' => $ldflags, + 'post_ldflags' => $postLdflags, 'debug' => $this->debug, 'no_console' => $this->noConsole, 'build_mode' => $this->buildMode, diff --git a/src/Build/PeclExtensionSource.php b/src/Build/PeclExtensionSource.php new file mode 100644 index 00000000..19b0ab57 --- /dev/null +++ b/src/Build/PeclExtensionSource.php @@ -0,0 +1,112 @@ +output = \Closure::fromCallable($output); + $this->downloader = new Downloader($proxy); + } + + /** @return array{directory:string,version:string} */ + public function prepare(string $extension): array + { + $extension = SapiExtensionRequirements::normalize($extension); + if (preg_match('/^[a-z][a-z0-9_-]*$/D', $extension) !== 1) { + throw new \InvalidArgumentException("Invalid PECL extension name `{$extension}`"); + } + + $version = trim($this->downloader->downloadText( + 'https://pecl.php.net/rest/r/' . rawurlencode($extension) . '/stable.txt', + )); + if (preg_match('/^[0-9][A-Za-z0-9._-]*$/D', $version) !== 1) { + throw new \RuntimeException("PECL extension `{$extension}` has no stable source release"); + } + + $root = $this->cacheDirectory . '/pecl/' . $extension . '-' . $version; + if ($this->isComplete($root)) { + return ['directory' => $root, 'version' => $version]; + } + $this->mkdir($this->cacheDirectory . '/pecl'); + $lock = fopen($this->cacheDirectory . '/pecl/download.lock', 'c+'); + if ($lock === false || !flock($lock, LOCK_EX)) { + throw new \RuntimeException('Unable to lock the PECL source cache'); + } + try { + if ($this->isComplete($root)) { + return ['directory' => $root, 'version' => $version]; + } + if (is_dir($root)) { + throw new \RuntimeException("Incomplete PECL source cache must be removed: {$root}"); + } + $archives = $this->cacheDirectory . '/archives'; + $this->mkdir($archives); + $archive = $archives . '/' . $extension . '-' . $version . '.tgz'; + if (!is_file($archive)) { + ($this->output)("Downloading PECL extension {$extension}-{$version}"); + $temporary = $archive . '.part-' . bin2hex(random_bytes(6)); + try { + $this->downloader->downloadFile( + 'https://pecl.php.net/get/' . rawurlencode($extension . '-' . $version) . '.tgz', + $temporary, + ); + if (!rename($temporary, $archive)) { + throw new \RuntimeException("Unable to cache PECL archive: {$archive}"); + } + } finally { + if (is_file($temporary)) { + @unlink($temporary); + } + } + } + ($this->output)("Extracting PECL extension {$extension}-{$version}"); + $this->run(['tar', '-xzf', $archive, '-C', $this->cacheDirectory . '/pecl']); + if (!$this->isComplete($root)) { + throw new \RuntimeException("PECL archive did not produce extension source: {$root}"); + } + return ['directory' => $root, 'version' => $version]; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function isComplete(string $directory): bool + { + return is_dir($directory) + && (is_file($directory . '/config.m4') || is_file($directory . '/config0.m4')); + } + + /** @param list $command */ + private function run(array $command): void + { + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new \RuntimeException('Command failed: ' . implode(' ', $command)); + } + } + + private function mkdir(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException("Cannot create PECL source cache directory: {$directory}"); + } + } +} diff --git a/src/Build/PhpBuilderConfiguration.php b/src/Build/PhpBuilderConfiguration.php new file mode 100644 index 00000000..b9588e77 --- /dev/null +++ b/src/Build/PhpBuilderConfiguration.php @@ -0,0 +1,143 @@ + $extensions + */ + public function __construct( + public bool $zts, + public array $extensions, + ) { + } + + public static function fromYaml(mixed $value): self + { + if (!is_array($value) || ($value !== [] && array_is_list($value))) { + throw new \InvalidArgumentException('`php-builder` must be a mapping'); + } + return self::fromArray($value); + } + + public static function fromCommandLine(string $value): self + { + $value = trim($value); + if ($value === '') { + throw new \InvalidArgumentException('Option --php-builder requires a non-empty configuration'); + } + + try { + $parsed = Yaml::parse(self::semicolonSeparatedYaml($value)); + } catch (\Throwable $exception) { + throw new \InvalidArgumentException( + 'Invalid --php-builder configuration: ' . $exception->getMessage(), + previous: $exception, + ); + } + return self::fromYaml($parsed); + } + + /** @param array $config */ + private static function fromArray(array $config): self + { + $unknown = array_diff(array_keys($config), ['zts', 'extensions']); + if ($unknown !== []) { + throw new \InvalidArgumentException( + 'Unknown `php-builder` option' . (count($unknown) > 1 ? 's' : '') + . ': ' . implode(', ', array_map(static fn (string $key): string => "`{$key}`", $unknown)), + ); + } + $extensions = self::stringList($config['extensions'] ?? [], 'php-builder.extensions', true); + $extensions = SapiExtensionRequirements::merge($extensions); + + return new self( + self::boolean($config['zts'] ?? false), + $extensions, + ); + } + + /** @return list */ + private static function stringList(mixed $value, string $name, bool $allowEmpty = false): array + { + $values = is_string($value) ? preg_split('/\s*,\s*/', trim($value)) : $value; + if (!is_array($values)) { + throw new \InvalidArgumentException("`{$name}` must be a string or list"); + } + $result = []; + foreach ($values as $entry) { + if (!is_string($entry) || trim($entry) === '') { + throw new \InvalidArgumentException("Each `{$name}` entry must be a non-empty string"); + } + $result[] = strtolower(trim($entry)); + } + if (!$allowEmpty && $result === []) { + throw new \InvalidArgumentException("`{$name}` must not be empty"); + } + return $result; + } + + private static function boolean(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === 1 || $value === 0) { + return (bool) $value; + } + if (is_string($value)) { + return match (strtolower(trim($value))) { + 'on', 'true', 'yes', '1' => true, + 'off', 'false', 'no', '0' => false, + default => throw new \InvalidArgumentException('`php-builder.zts` must be on or off'), + }; + } + throw new \InvalidArgumentException('`php-builder.zts` must be on or off'); + } + + private static function semicolonSeparatedYaml(string $value): string + { + $result = ''; + $quote = null; + $depth = 0; + $escaped = false; + foreach (str_split($value) as $character) { + if ($escaped) { + $result .= $character; + $escaped = false; + continue; + } + if ($character === '\\' && $quote === '"') { + $result .= $character; + $escaped = true; + continue; + } + if (($character === '"' || $character === "'") && ($quote === null || $quote === $character)) { + $quote = $quote === null ? $character : null; + $result .= $character; + continue; + } + if ($quote === null) { + if ($character === '[' || $character === '{') { + $depth++; + } elseif ($character === ']' || $character === '}') { + $depth--; + } elseif ($character === ';' && $depth === 0) { + $result .= "\n"; + continue; + } + } + $result .= $character; + } + return preg_replace('/\n[ \t]+/', "\n", trim($result)) ?? trim($result); + } +} diff --git a/src/Build/PhpBuilderSource.php b/src/Build/PhpBuilderSource.php new file mode 100644 index 00000000..a64a04c8 --- /dev/null +++ b/src/Build/PhpBuilderSource.php @@ -0,0 +1,94 @@ +output = \Closure::fromCallable($output); + } + + /** + * @param list $extensions + * @return array{source:string,external:array} + */ + public function prepare(string $officialSource, array $extensions): array + { + $external = []; + $sources = []; + $pecl = new PeclExtensionSource($this->cacheDirectory, $this->output, $this->proxy); + foreach (SapiExtensionRequirements::merge($extensions) as $extension) { + $directoryName = str_replace('-', '_', $extension); + if (is_dir($officialSource . '/ext/' . $directoryName)) { + continue; + } + $release = $pecl->prepare($extension); + $external[$extension] = $release['version']; + $sources[$extension] = $release['directory']; + } + if ($external === []) { + return ['source' => $officialSource, 'external' => []]; + } + + ksort($external, SORT_STRING); + $version = OfficialPhpSource::version($officialSource); + $fingerprint = substr(hash('sha256', json_encode($external, JSON_THROW_ON_ERROR)), 0, 16); + $source = $this->cacheDirectory . '/php-builder-src/php-' . $version . '-' . $fingerprint; + $manifest = $source . '/.typephp-extensions.json'; + $metadata = json_encode($external, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + . PHP_EOL; + if (is_file($source . '/configure') && @file_get_contents($manifest) === $metadata) { + return ['source' => $source, 'external' => $external]; + } + if (is_dir($source)) { + throw new \RuntimeException("Incomplete PHP builder source cache must be removed: {$source}"); + } + + $parent = dirname($source); + $this->mkdir($parent); + $temporary = $source . '.part-' . bin2hex(random_bytes(6)); + $this->mkdir($temporary); + ($this->output)('Preparing php-src with external extensions: ' . implode(', ', array_keys($external))); + $this->run(['cp', '-a', rtrim($officialSource, '/\\') . '/.', $temporary]); + foreach ($sources as $extension => $extensionSource) { + $target = $temporary . '/ext/' . str_replace('-', '_', $extension); + $this->mkdir($target); + $this->run(['cp', '-a', rtrim($extensionSource, '/\\') . '/.', $target]); + } + $this->run(['sh', $temporary . '/buildconf', '--force'], $temporary); + AtomicFile::write($temporary . '/.typephp-extensions.json', $metadata); + if (!rename($temporary, $source)) { + throw new \RuntimeException("Unable to publish PHP builder source tree: {$source}"); + } + return ['source' => $source, 'external' => $external]; + } + + /** @param list $command */ + private function run(array $command, ?string $directory = null): void + { + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $directory); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new \RuntimeException('Command failed: ' . implode(' ', $command)); + } + } + + private function mkdir(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException("Cannot create PHP builder source directory: {$directory}"); + } + } +} diff --git a/src/Build/SapiApplicationLinker.php b/src/Build/SapiApplicationLinker.php index 30cea5b5..3e114ecd 100644 --- a/src/Build/SapiApplicationLinker.php +++ b/src/Build/SapiApplicationLinker.php @@ -96,6 +96,13 @@ final class SapiApplicationLinker $arguments[] = 'cli'; } else { $arguments[] = 'PHP_FPM_OBJS=' . $this->onlyEntryObjects($fpmObjects, ['fpm_main.lo']) + // The shared generated object also contains the Embed-only + // process-title function table. FPM does not register that + // table, but the linker must still resolve its callbacks. + . ' ' . $this->onlyEntryObjects( + $cliObjects, + ['ps_title.lo', 'php_cli_process_title.lo'], + ) . ' ' . $runtimeArchive; $arguments[] = 'SAPI_FPM_PATH=' . $linkOutput; $arguments[] = 'fpm'; diff --git a/src/Build/SapiBuildConfiguration.php b/src/Build/SapiBuildConfiguration.php index 1e1a5e3d..76698165 100644 --- a/src/Build/SapiBuildConfiguration.php +++ b/src/Build/SapiBuildConfiguration.php @@ -17,20 +17,16 @@ final class SapiBuildConfiguration throw new \InvalidArgumentException('`sapi` entries must be non-empty strings'); } $target = strtolower(trim($target)); - if ($target === 'both') { - array_push($targets, 'cli', 'fpm'); - continue; - } - if (!in_array($target, ['cli', 'fpm'], true)) { + if (!in_array($target, ['embed', 'cli', 'fpm'], true)) { throw new \InvalidArgumentException( - "Invalid SAPI target `{$target}`. Expected cli, fpm, or both.", + "Invalid SAPI target `{$target}`. Expected embed, cli, or fpm.", ); } $targets[] = $target; } $targets = array_values(array_unique($targets)); if ($targets === []) { - throw new \InvalidArgumentException('`sapi` must select cli, fpm, or both'); + throw new \InvalidArgumentException('`sapi` must select embed, cli, or fpm'); } return $targets; } diff --git a/src/Build/SapiPhpBuilder.php b/src/Build/SapiPhpBuilder.php index ed42549f..26bee646 100644 --- a/src/Build/SapiPhpBuilder.php +++ b/src/Build/SapiPhpBuilder.php @@ -32,29 +32,38 @@ final class SapiPhpBuilder array $targets, int $jobs, array $requiredExtensions = [], + bool $zts = false, ): SapiPhpBuild { if (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') { throw new \RuntimeException('Self-contained SAPI builds currently require Linux or macOS'); } - if ($targets === [] || array_diff($targets, ['cli', 'fpm']) !== []) { - throw new \InvalidArgumentException('SAPI targets must contain cli, fpm, or both'); + if ($targets === [] || array_diff($targets, ['embed', 'cli', 'fpm']) !== []) { + throw new \InvalidArgumentException('PHP builder SAPI targets must contain embed, cli, or fpm'); } - $source = (new OfficialPhpSource( + $officialSource = (new OfficialPhpSource( OfficialPhpSource::defaultCacheDirectory(), $this->output, $this->proxy, ))->prepare($phpVersion); + $preparedSource = (new PhpBuilderSource( + OfficialPhpSource::defaultCacheDirectory(), + $this->output, + $this->proxy, + ))->prepare($officialSource, $requiredExtensions); + $source = $preparedSource['source']; + $externalExtensions = $preparedSource['external']; $sourceVersion = OfficialPhpSource::version($source); $baseOptions = $this->sourceConfigureOptions($source); $requiredExtensions = SapiExtensionRequirements::merge($requiredExtensions); $extensionOptions = SapiExtensionConfiguration::configureOptions($source, $requiredExtensions); - // Build both official executable SAPIs once. Individual projects can - // then select either archive without configuring or compiling php-src. - $runtimeTargets = ['cli', 'fpm']; + $runtimeTargets = array_values(array_unique($targets)); + sort($runtimeTargets, SORT_STRING); $identity = [ $sourceVersion, $baseOptions, $runtimeTargets, + $zts, + $externalExtensions, PHP_OS_FAMILY, php_uname('m'), getenv('CC') ?: '', @@ -62,7 +71,7 @@ final class SapiPhpBuilder filemtime($source . '/configure'), ]; $compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); - $cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/sapi'; + $cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/php-builder'; $cached = $this->findCompatibleRuntime( $cacheDirectory, $sourceVersion, @@ -84,7 +93,7 @@ final class SapiPhpBuilder $this->mkdir($root); $lock = fopen($root . '/build.lock', 'c+'); if ($lock === false || !flock($lock, LOCK_EX)) { - throw new \RuntimeException("Unable to lock SAPI runtime cache: {$root}"); + throw new \RuntimeException("Unable to lock PHP builder runtime cache: {$root}"); } try { $build = $root . '/build'; @@ -92,29 +101,33 @@ final class SapiPhpBuilder $this->mkdir($build); $this->mkdir($prefix . '/lib/conf.d'); - $options = PhpBuildConfiguration::deriveSapi( + $options = PhpBuildConfiguration::derivePhpBuilder( [...$baseOptions, ...$extensionOptions], $prefix, $runtimeTargets, + $zts, ); $configured = $build . '/.typephp-configure.json'; $configuration = json_encode($options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); if (!is_file($build . '/Makefile') || @file_get_contents($configured) !== $configuration) { - ($this->output)('Configuring private PHP ' . $sourceVersion . ' SAPI runtime'); + ($this->output)('Configuring PHP ' . $sourceVersion . ' source runtime'); $this->run([$source . '/configure', ...$options], $build); AtomicFile::write($configured, $configuration); } $php = $prefix . '/bin/php'; $fpm = $prefix . '/sbin/php-fpm'; - $needsInstall = !is_executable($php) || !is_executable($fpm); + $embed = $prefix . '/lib/libphp.a'; + $needsInstall = !is_executable($php) + || (in_array('fpm', $runtimeTargets, true) && !is_executable($fpm)) + || (in_array('embed', $runtimeTargets, true) && !is_file($embed)); if ($needsInstall) { ($this->output)('Building private PHP runtime (cached across application builds)'); $this->run(['make', '-j' . max(1, $jobs)], $build); $this->run(['make', 'install'], $build); } - $sapiArchives = $this->buildSapiArchives($build, $root, $jobs); + $sapiArchives = $this->buildSapiArchives($build, $root, $jobs, $runtimeTargets, $embed); $phpxBuild = $root . '/phpx-build'; $phpxArchive = $phpxBuild . '/lib/libphpx.a'; @@ -142,10 +155,13 @@ final class SapiPhpBuilder } AtomicFile::write($root . '/runtime.json', json_encode([ 'version' => $sourceVersion, + 'source' => $source, 'compatibility' => $compatibility, 'requested_extensions' => $requiredExtensions, 'enabled_extensions' => $enabledExtensions, 'configure_options' => $options, + 'sapis' => $runtimeTargets, + 'zts' => $zts, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL); return new SapiPhpBuild( $source, @@ -185,7 +201,7 @@ final class SapiPhpBuilder if (array_diff($requiredExtensions, $enabled) !== []) { continue; } - $runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled); + $runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled, $requiredExtensions); if ($runtime !== null) { $matches[] = [$runtime, count($enabled), filemtime($manifest) ?: 0]; } @@ -199,23 +215,37 @@ final class SapiPhpBuilder string $root, string $version, array $enabledExtensions, + array $requiredExtensions, ): ?SapiPhpBuild { $build = $root . '/build'; $prefix = $root . '/install'; $phpxArchive = $root . '/phpx-build/lib/libphpx.a'; - $archives = [ - 'cli' => $root . '/lib/libphp-cli.a', - 'fpm' => $root . '/lib/libphp-fpm.a', - ]; + $metadata = json_decode((string) @file_get_contents($root . '/runtime.json'), true); + $targets = is_array($metadata) && is_array($metadata['sapis'] ?? null) + ? $metadata['sapis'] + : ['cli', 'fpm']; + $archives = []; + foreach ($targets as $target) { + if ($target === 'embed') { + $archives['embed'] = $prefix . '/lib/libphp.a'; + } elseif ($target === 'cli' || $target === 'fpm') { + $archives[$target] = $root . '/lib/libphp-' . $target . '.a'; + } + } if (!is_executable($prefix . '/bin/php') || !is_file($phpxArchive) - || !is_file($archives['cli']) - || !is_file($archives['fpm']) + || array_diff($requiredExtensions, $enabledExtensions) !== [] + || array_filter($archives, static fn (string $archive): bool => !is_file($archive)) !== [] || !is_file($build . '/Makefile') ) { return null; } - $source = OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version; + $source = is_array($metadata) && is_string($metadata['source'] ?? null) + ? $metadata['source'] + : OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version; + if (!is_dir($source)) { + return null; + } return new SapiPhpBuild( $source, $build, @@ -251,15 +281,24 @@ final class SapiPhpBuilder } /** @return array */ - private function buildSapiArchives(string $build, string $root, int $jobs): array + private function buildSapiArchives( + string $build, + string $root, + int $jobs, + array $targets, + string $embedArchive, + ): array { $libraryDirectory = $root . '/lib'; $this->mkdir($libraryDirectory); - $archives = [ - 'cli' => $libraryDirectory . '/libphp-cli.a', - 'fpm' => $libraryDirectory . '/libphp-fpm.a', - ]; - if (is_file($archives['cli']) && is_file($archives['fpm'])) { + $archives = []; + foreach (array_intersect($targets, ['cli', 'fpm']) as $target) { + $archives[$target] = $libraryDirectory . '/libphp-' . $target . '.a'; + } + if (in_array('embed', $targets, true)) { + $archives['embed'] = $embedArchive; + } + if (array_filter($archives, static fn (string $archive): bool => !is_file($archive)) === []) { return $archives; } // The normal PHP build creates every core and SAPI object. Archive @@ -273,6 +312,9 @@ final class SapiPhpBuilder 'cli' => ['PHP_CLI_OBJS', 'sapi/cli/php_cli.lo'], 'fpm' => ['PHP_FPM_OBJS', 'sapi/fpm/fpm/fpm_main.lo'], ] as $target => [$variable, $entryObject]) { + if (!isset($archives[$target])) { + continue; + } if (is_file($archives[$target])) { continue; } @@ -289,6 +331,9 @@ final class SapiPhpBuilder throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]); } } + if (isset($archives['embed']) && !is_file($archives['embed'])) { + throw new \RuntimeException('PHP Embed static archive was not installed: ' . $archives['embed']); + } return $archives; } diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 16bdac2a..971569f6 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -13,7 +13,7 @@ use Ajaxray\AnsiKit\Components\Progressbar; use TypePhp\Backend\CompilerFactory; use TypePhp\Exception\SyntaxError; use TypePhp\Exception\Unsupported; -use TypePhp\Installer\LibPhpInstaller; +use TypePhp\Installer\InteractiveConsole; use TypePhp\Installer\LibPhpxInstaller; use TypePhp\Platform\Linux; use TypePhp\Platform\Wasi; @@ -509,11 +509,20 @@ trait SourcePipelineTrait private function validateLoadedProjectConfiguration(): void { - if (in_array('cli', $this->sapiTargets, true) && $this->sapiEntryFile === null) { - $this->error('CLI SAPI builds require an `entry` PHP file'); + if ($this->sapiConfigured && !$this->isBuildModeBin()) { + $this->error('`sapi` is only supported with `mode: bin`'); } - if ($this->sapiEntryFile !== null && !in_array('cli', $this->sapiTargets, true)) { - $this->error('`entry` requires the CLI SAPI target'); + if ($this->isPhpBuilderBuild() && !$this->isBuildModeBin()) { + $this->error('`php-builder` is only supported with `mode: bin`'); + } + if ($this->isSapiBuild() && !$this->isPhpBuilderBuild()) { + $this->error('The cli and fpm SAPIs require `php-builder`'); + } + if ($this->hasSapi('cli') && $this->sapiEntryFile === null) { + $this->error('`sapi` containing cli requires an `entry` PHP file'); + } + if ($this->sapiEntryFile !== null && !$this->hasSapi('cli')) { + $this->error('`entry` requires `sapi` to contain cli'); } if ($this->embeddedFiles !== [] && !$this->isBuildModeBin() && !$this->isSapiBuild()) { $this->error('`embedded-files` requires `mode: bin`'); @@ -573,21 +582,16 @@ trait SourcePipelineTrait return; } - if ($this->isSapiBuild()) { - $this->prepareSapiBuildEnvironment(); + $this->offerPhpBuilderForMissingEmbedRuntime(); + + if ($this->isPhpBuilderBuild()) { + $this->preparePhpBuilderEnvironment(); $this->getOpcodeBuildExtensionArgs(); return; } $platform = $this->getPlatform(); $phpDir = $this->getPhpDir(); - if ($this->isBuildModeEmbed() && $platform instanceof Linux) { - try { - $phpDir = (new LibPhpInstaller(proxy: $this->downloadProxy))->ensure($phpDir) ?? $phpDir; - } catch (\Throwable $e) { - $this->error('Unable to install libphp.so: ' . $e->getMessage()); - } - } if (!$platform instanceof Wasi) { $this->validatePhpRuntimeMinimum($phpDir); @@ -611,6 +615,43 @@ trait SourcePipelineTrait } } + private function offerPhpBuilderForMissingEmbedRuntime(): void + { + if ($this->isPhpBuilderBuild() + || !$this->isBuildModeBin() + || !$this->hasSapi('embed') + || (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') + ) { + return; + } + + try { + $this->getPlatform()->detectPhpLibs($this->getPhpDir()); + return; + } catch (\RuntimeException $exception) { + $detail = $exception->getMessage(); + } + + $console = new InteractiveConsole(); + if (!$console->isInteractive()) { + $this->error( + "The host PHP embed library is missing: {$detail}. " + . 'Run tpc in an interactive terminal to enable php-builder, or pass ' + . "--php-builder='extensions: []; zts: off'", + ); + } + + $console->write('The host PHP installation does not provide an Embed SAPI library.'); + if (!$console->confirm('Build a private PHP runtime from php-src with php-builder?', true)) { + $this->error('The embed SAPI requires a host libphp library or `php-builder`'); + } + + $this->phpBuilderEnabled = true; + $this->phpBuilderZts = false; + $this->phpBuilderExtensions = []; + $console->write('php-builder enabled; required extensions will be collected automatically.'); + } + private function reportBuildLibraryWarnings(): void { // shell_exec and define are already called directly via php::fn::, so no @@ -618,7 +659,7 @@ trait SourcePipelineTrait // All Windows build modes depend on the PHPX import library and runtime. // Other platforms only run the existing checks in embedded build mode. - if (!$this->isNanoMode() && !$this->isSapiBuild() + if (!$this->isNanoMode() && !$this->isPhpBuilderBuild() && ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) { foreach ($this->getPlatform()->getBuildLibraryWarnings( $this->getPhpDir(), @@ -642,13 +683,14 @@ trait SourcePipelineTrait } /** @param list|null $sourceDependencies */ - private function prepareSapiBuildEnvironment(?array $sourceDependencies = null): void + private function preparePhpBuilderEnvironment(?array $sourceDependencies = null): void { try { $composerDependencies = SapiExtensionRequirements::fromEmbeddedVendorFiles( $this->embeddedFiles, ); $requiredExtensions = SapiExtensionRequirements::merge( + $this->phpBuilderExtensions, $this->extensionDependencies, $composerDependencies, $sourceDependencies ?? [], @@ -671,6 +713,7 @@ trait SourcePipelineTrait $this->sapiTargets, min(8, max(1, $this->maxJob)), $requiredExtensions, + $this->phpBuilderZts, ); } catch (\Throwable $exception) { $this->error('Unable to prepare self-contained PHP SAPI runtime: ' . $exception->getMessage()); @@ -682,6 +725,7 @@ trait SourcePipelineTrait $this->sapiPhpxArchive = $runtime->phpxArchive; $this->sapiRuntimeArchives = $runtime->sapiArchives; $this->sapiEnabledExtensions = $runtime->enabledExtensions; + $this->isPhpZts = $this->phpBuilderZts; putenv('PHP_HOME=' . $runtime->prefix); $_ENV['PHP_HOME'] = $runtime->prefix; $this->validatePhpRuntimeMinimum($runtime->prefix); @@ -877,11 +921,11 @@ trait SourcePipelineTrait $previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT); $this->initializeProjectConversion($files); $conversion = $this->convertProjectFiles($files); - if ($this->isSapiBuild()) { + if ($this->isPhpBuilderBuild()) { // Function and class ownership is known only after lowering all // project bodies. Upgrade the cached PHP runtime before code is // compiled if those bodies introduced another extension. - $this->prepareSapiBuildEnvironment($this->resolveExtensionDependencies()); + $this->preparePhpBuilderEnvironment($this->resolveExtensionDependencies()); } return $this->finalizeProjectConversion($conversion); } finally { diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 7fec8c31..9118e113 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -374,7 +374,6 @@ class CompilerBase implements PropertyAccessContext public const string BUILD_MODE_BIN = 'bin'; public const string BUILD_MODE_EXT = 'ext'; public const string BUILD_MODE_LIB = 'lib'; - public const string BUILD_MODE_SAPI = 'sapi'; public const string ENTRY_FUNCTION = 'main'; protected const string PHASE_IDLE = 'idle'; protected const string PHASE_PREPARE = 'prepare'; @@ -552,8 +551,17 @@ class CompilerBase implements PropertyAccessContext /** Generate a VM-less program entry for the Composer php-nano runtime. */ protected bool $nanoMode = false; + protected bool $phpBuilderEnabled = false; + + /** @var list */ + protected array $sapiTargets = ['embed']; + + protected bool $sapiConfigured = false; + + protected bool $phpBuilderZts = false; + /** @var list */ - protected array $sapiTargets = []; + protected array $phpBuilderExtensions = []; /** Absolute path of the embedded primary script used by the CLI SAPI. */ protected ?string $sapiEntryFile = null; @@ -970,19 +978,24 @@ class CompilerBase implements PropertyAccessContext return $this->buildMode === self::BUILD_MODE_LIB; } - public function isBuildModeSapi(): bool + public function isBuildModeEmbed(): bool { - return $this->buildMode === self::BUILD_MODE_SAPI; + return $this->isBuildModeBin() || $this->isBuildModeLib(); } - public function isBuildModeEmbed(): bool + public function isPhpBuilderBuild(): bool + { + return $this->phpBuilderEnabled; + } + + public function hasSapi(string $sapi): bool { - return $this->isBuildModeBin() || $this->isBuildModeLib() || $this->isBuildModeSapi(); + return in_array($sapi, $this->sapiTargets, true); } public function isSapiBuild(): bool { - return $this->sapiTargets !== []; + return $this->hasSapi('cli') || $this->hasSapi('fpm'); } /** @return list */ diff --git a/src/Installer/PhpBuildConfiguration.php b/src/Installer/PhpBuildConfiguration.php index 9e59ff08..d7b23176 100644 --- a/src/Installer/PhpBuildConfiguration.php +++ b/src/Installer/PhpBuildConfiguration.php @@ -159,13 +159,19 @@ final class PhpBuildConfiguration * @param list $targets * @return list */ - public static function deriveSapi(string|array $configureOptions, string $prefix, array $targets): array + public static function derivePhpBuilder( + string|array $configureOptions, + string $prefix, + array $targets, + bool $zts, + ): array { $replace = [ '--prefix', '--with-config-file-path', '--with-config-file-scan-dir', '--enable-cli', '--disable-cli', '--enable-fpm', '--disable-fpm', '--enable-cgi', '--disable-cgi', '--enable-phpdbg', '--disable-phpdbg', '--enable-embed', '--disable-embed', '--enable-opcache', '--disable-opcache', + '--enable-zts', '--disable-zts', '--with-pear', '--without-pear', ]; $drop = ['--with-apxs', '--with-apxs2', '--with-fpm-systemd', ...self::PREFIX_DERIVED]; @@ -194,7 +200,8 @@ final class PhpBuildConfiguration in_array('fpm', $targets, true) ? '--enable-fpm' : '--disable-fpm', '--disable-cgi', '--disable-phpdbg', - '--disable-embed', + in_array('embed', $targets, true) ? '--enable-embed=static' : '--disable-embed', + $zts ? '--enable-zts' : '--disable-zts', '--enable-opcache', '--without-pear', ...$result, diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index e5c70fb0..b11f9297 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -179,7 +179,7 @@ class Constants ], 'proxy' => [ 'longPrefix' => 'proxy', - 'description' => 'Proxy URL used for file downloads (HTTP(S) or SOCKS)', + 'description' => 'Proxy URL used for network transfers (HTTP(S) or SOCKS)', 'required' => false, ], 'force' => [ @@ -192,13 +192,18 @@ class Constants 'mode' => [ 'longPrefix' => 'mode', 'prefix' => 'm', - 'description' => 'Build mode: bin, lib, ext, or sapi; default: bin', + 'description' => 'Build mode: bin, lib, or ext; default: bin', 'required' => false, 'defaultValue' => CompilerBase::BUILD_MODE_BIN, ], + 'php-builder' => [ + 'longPrefix' => 'php-builder', + 'description' => 'Build PHP from source, e.g. "extensions: [curl]; zts: on"', + 'required' => false, + ], 'sapi' => [ 'longPrefix' => 'sapi', - 'description' => 'Build a self-contained PHP application SAPI: cli, fpm, or both', + 'description' => 'PHP SAPI target: embed, cli, fpm, or a comma-separated list', 'required' => false, ], 'run' => [ diff --git a/src/Translator.php b/src/Translator.php index 9c9ae93b..bd8c2bd6 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -32,6 +32,7 @@ use TypePhp\Build\NanoSourceComposer; use TypePhp\Build\NativeBuilder; use TypePhp\Build\NativeCommandOptionsTrait; use TypePhp\Build\NativeDependencyAuditor; +use TypePhp\Build\PhpBuilderConfiguration; use TypePhp\Build\PhpSourceExtensionIndex; use TypePhp\Build\PrecompiledHeaderManager; use TypePhp\Build\ResourceCompilationTrait; @@ -437,7 +438,8 @@ class Translator extends Preprocessor ['-o, --output ', 'Output name or path (default: input basename)'], ['-f, --force', 'Clear incremental caches and force a full rebuild'], ['-m, --mode ', 'Build mode: bin, lib, or ext (default: bin)'], - ['--sapi ', 'Build a self-contained PHP CLI, FPM, or both'], + ['--sapi ', 'PHP SAPI target: embed (default), CLI, and/or FPM'], + ['--php-builder ', 'Build a private PHP runtime from source'], ['-r, --run', 'Run the compiled binary after a successful build'], ['-j, --job ', 'Number of parallel compilation jobs (default: 4)'], ['--cxx-std ', 'C++ standard version (default: c++17)'], @@ -450,7 +452,7 @@ class Translator extends Preprocessor ['--lto', 'Enable Link Time Optimization (-flto)'], ['--no-literal-strings', 'Disable literal string optimization'], ['--php-version ', 'Accepted PHP language version (8.4-8.5, default: 8.5)'], - ['--proxy ', 'Proxy URL used for file downloads'], + ['--proxy ', 'Proxy URL used for network transfers'], ['--no-progress', 'Print one compilation line per file instead of a progress bar'], ['--no-console', 'Hide the console window (Windows GUI applications only)'], ['--sanitize ', 'Enable a sanitizer such as address or undefined'], @@ -512,6 +514,15 @@ class Translator extends Preprocessor if ($this->climate->arguments->defined('sapi')) { $this->configureSapiTargets((string) $this->climate->arguments->get('sapi')); } + if ($this->climate->arguments->defined('php-builder')) { + try { + $this->configurePhpBuilder(PhpBuilderConfiguration::fromCommandLine( + (string) $this->climate->arguments->get('php-builder'), + )); + } catch (\InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + } + } // --full-static links the whole musl C runtime into the artifact, so it // only makes sense for a self-contained executable. A shared object @@ -894,21 +905,17 @@ class Translator extends Preprocessor { $mode = strtolower(trim($mode)); $mode = match ($mode) { - 'binary', 'cli' => self::BUILD_MODE_BIN, + 'binary' => self::BUILD_MODE_BIN, 'extension' => self::BUILD_MODE_EXT, 'library', 'shared', 'dll', 'dylib', 'so' => self::BUILD_MODE_LIB, - 'sapi' => self::BUILD_MODE_SAPI, default => $mode, }; - if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB, self::BUILD_MODE_SAPI], true)) { - $this->error("Invalid build mode `{$mode}`. Expected bin, lib, ext, or sapi."); + if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB], true)) { + $this->error("Invalid build mode `{$mode}`. Expected bin, lib, or ext."); } $this->buildMode = $mode; - if ($mode === self::BUILD_MODE_SAPI && $this->sapiTargets === []) { - $this->sapiTargets = ['cli']; - } } public function setTargetName(string $name): void @@ -1285,9 +1292,9 @@ class Translator extends Preprocessor private function doGenExtension(): string { - if ($this->isBuildModeBin()) { + if ($this->isBuildModeBin() && $this->hasSapi('embed')) { if (!$this->hasFunction(self::ENTRY_FUNCTION)) { - $this->climate->red('When the build mode is a binary executable file, the `main()` function must be defined'); + $this->climate->red('The Embed SAPI requires a global `main()` function'); exit(1); } } @@ -1320,7 +1327,9 @@ class Translator extends Preprocessor if ($this->isSapiBuild()) { $code .= '#include ' . PHP_EOL; - } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { + } + if ($this->isBuildModeEmbed() && !$this->isNanoMode() + && (!$this->isSapiBuild() || $this->hasSapi('embed'))) { $code .= '#include ' . PHP_EOL; if ($this->embeddedFiles === [] && $this->embeddedOpcodeFiles === []) { // The runtime still calls these hooks; keep empty builds in this translation unit. @@ -1553,16 +1562,18 @@ CODE; $traitMetadata = $this->genTraitMetadataCode(); $code .= $traitMetadata['declarations']; - $code .= "// clang-format off\n"; - $code .= "static const zend_function_entry ext_functions[] = {\n"; + $processTitleFunctions = ''; if (!$this->isNanoMode() && $this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) { - $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"; + $processTitleFunctions .= $this->getIndent() + . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n"; + $processTitleFunctions .= $this->getIndent() + . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n"; } + $projectFunctions = ''; foreach ($this->symbols->functions() as $functionDef) { if ($functionDef->attributeFactory) { continue; @@ -1580,11 +1591,25 @@ CODE; $zifName = $this->escapeZendFnName($fullName); // TypePHP is always strict. Store the flag in the registered // zend_function instead of rewriting shared metadata on every call. - $code .= $this->getIndent() . 'ZEND_RAW_FENTRY("' . $this->escapeString($fullName) + $projectFunctions .= $this->getIndent() . 'ZEND_RAW_FENTRY("' . $this->escapeString($fullName) . '", ZEND_FN(' . $zifName . '), arginfo_' . $zifName . ', ZEND_ACC_STRICT_TYPES, NULL, NULL)' . PHP_EOL; } + $code .= "// clang-format off\n"; + $code .= "static const zend_function_entry ext_functions[] = {\n"; + $code .= $processTitleFunctions; + $code .= $projectFunctions; $code .= $this->getIndent() . "ZEND_FE_END\n};\n// clang-format on" . PHP_EOL . PHP_EOL; + if ($this->isSapiBuild() && $processTitleFunctions !== '') { + // PHP CLI already registers these functions, while FPM does not + // provide their implementation. The SAPI module therefore uses a + // table containing project functions only. Embed keeps the + // original process-title API through ext_functions. + $code .= "// clang-format off\n"; + $code .= "static const zend_function_entry sapi_ext_functions[] = {\n"; + $code .= $projectFunctions; + $code .= $this->getIndent() . "ZEND_FE_END\n};\n// clang-format on" . PHP_EOL . PHP_EOL; + } // minit begin $releaseAstConstantFns = array_unique($this->releaseAstConstantFns); @@ -1767,6 +1792,28 @@ CODE; foreach ($this->nativeStaticInitializers as $name => $_) { $code .= $this->escapeGlobalVar($name) . ' = false;' . PHP_EOL; } + if ($this->isBuildModeBin() && (!$this->isSapiBuild() || $this->hasSapi('embed'))) { + // Embed registers the generated module after request startup and + // unloads that temporary module before Zend tears down class + // statics. Release only static array caches which may retain + // Reflection objects pointing into the temporary module. This is + // deliberately skipped for ext/CLI/FPM request shutdown. + $code .= 'if (strcmp(sapi_module.name, "embed") == 0) {' . PHP_EOL; + foreach ($this->symbols->classes() as $classDef) { + if ($classDef->trait) { + continue; + } + foreach ($classDef->properties as $property) { + if (!$property->isStatic() || !$property->arrayInitPlan) { + continue; + } + $code .= $this->getIndent(2) . 'php::setStaticProperty(' + . $this->genCharPtr($classDef->getNamespacedName(false), true) . ', ' + . $this->genCharPtr($property->name) . ', php::Array{});' . PHP_EOL; + } + } + $code .= '}' . PHP_EOL; + } $code .= $this->genRequestArrayDefaultCleanup(); foreach ($this->constants as $name => $const) { if ($const->type !== Type::VAR) { @@ -1811,7 +1858,10 @@ CODE; } $code .= 'module_init();' . PHP_EOL; - if ($this->isBuildModeBin() && !$this->isNanoMode()) { + if ($this->isBuildModeBin() && $this->hasSapi('embed') && !$this->isNanoMode()) { + if ($this->isSapiBuild()) { + $code .= 'if (strcmp(sapi_module.name, "embed") == 0) {' . PHP_EOL; + } $entryFunction = $this->symbols->function(self::ENTRY_FUNCTION); if ($this->isNanoPolicyMode()) { // Windows keeps the complete PHP/PHPX DLL runtime, but a Nano @@ -1849,6 +1899,9 @@ CODE; $code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL; } + if ($this->isSapiBuild()) { + $code .= '}' . PHP_EOL; + } } if ($this->isSapiBuild()) { @@ -1922,19 +1975,39 @@ zend_module_entry {$moduleName}_module_entry = { }; CODE; $code .= PHP_EOL . PHP_EOL; + if ($this->isSapiBuild() && $processTitleFunctions !== '') { + $code .= <<isBuildModeExt()) { $code .= "ZEND_GET_MODULE({$moduleName});\n"; $code .= '} // namespace ' . $projectNamespace . PHP_EOL; - } elseif ($this->isBuildModeSapi()) { - $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; - $code .= 'extern "C" zend_module_entry *' . $this->getSapiModulePointerName() - . ' = &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; - $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; - $code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; - $code .= '}' . PHP_EOL; + if ($this->isSapiBuild()) { + $code .= 'extern "C" zend_module_entry *' . $this->getSapiModulePointerName() + . ' = &' . $projectNamespace . '::' . $moduleName + . ($processTitleFunctions !== '' ? '_sapi' : '') . '_module_entry;' . PHP_EOL; + } + if (!$this->isSapiBuild() || $this->hasSapi('embed')) { + $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; + $code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; + $code .= '}' . PHP_EOL; + } } else { $code .= '} // namespace ' . $projectNamespace . PHP_EOL; } @@ -1960,7 +2033,7 @@ CODE; { $dependencies = $this->extensionDependencies; $seen = []; - $sourceIndex = $this->isSapiBuild() && $this->sapiPhpSourceDirectory !== null + $sourceIndex = $this->isPhpBuilderBuild() && $this->sapiPhpSourceDirectory !== null ? PhpSourceExtensionIndex::forSource($this->sapiPhpSourceDirectory) : null; foreach ($dependencies as $dependency) { @@ -2413,7 +2486,7 @@ CODE; array_push($sourceFiles, ...$this->getEmbeddedRuntimeSources()); } - if ($this->isBuildModeBin() + if (($this->isBuildModeBin() || $this->hasSapi('embed')) && !$this->isWasiTarget() && !$this->isIosTarget()) { $sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c'; @@ -2426,7 +2499,7 @@ CODE; private function getEmbeddedRuntimeSources(): array { $sources = []; - if (!$this->isSapiBuild()) { + if (!$this->isSapiBuild() || $this->hasSapi('embed')) { $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc'; // PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer PHPX // versions split it out so the object can be shared across projects. @@ -2803,31 +2876,66 @@ CODE; public function build(array $objectFiles): string { - if ($this->isSapiBuild()) { + if ($this->isPhpBuilderBuild()) { if ($this->sapiPhpSourceDirectory === null || $this->sapiPhpBuildDirectory === null || $this->sapiPhpxArchive === null || $this->sapiRuntimeArchives === []) { - throw new \LogicException('SAPI runtime was not prepared before linking'); - } - try { - $target = (new SapiApplicationLinker( - $this->sapiPhpSourceDirectory, - $this->sapiPhpBuildDirectory, - $this->sapiPhpxArchive, - $this->sapiRuntimeArchives, - $this->getBuildDir(), - $this->sapiTargets, - $this->sapiEntryFile, - fn (string $message) => $this->output($message, 'lightBlue'), - ))->link($objectFiles, $this->getSapiOutputFiles()); - } catch (\Throwable $exception) { - $this->error('SAPI link failed: ' . $exception->getMessage()); - } - $this->climate->green('Build successful: ' . $target); + throw new \LogicException('PHP builder runtime was not prepared before linking'); + } + $outputs = $this->getSapiOutputFiles(); + $mainObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_main.cc'); + $runtimeObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_runtime.cc'); + $sapiObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/typephp_sapi.cc'); + $processTitleObject = $this->getObjectFile( + $this->getPhpxDir() . '/src/misc/php_cli_process_title.c', + ); + $psTitleObject = $this->getObjectFile($this->getPhpxDir() . '/src/misc/ps_title.c'); + $internalFunctionsObject = $this->getObjectFile( + $this->getBuildDir() . '/sapi-internal-functions-' . $this->targetName . '.c', + ); + + if ($this->isSapiBuild()) { + $sapiTargets = array_values(array_intersect($this->sapiTargets, ['cli', 'fpm'])); + $sapiObjects = array_values(array_diff( + $objectFiles, + [$mainObject, $runtimeObject, $processTitleObject, $psTitleObject], + )); + try { + (new SapiApplicationLinker( + $this->sapiPhpSourceDirectory, + $this->sapiPhpBuildDirectory, + $this->sapiPhpxArchive, + $this->sapiRuntimeArchives, + $this->getBuildDir(), + $sapiTargets, + $this->sapiEntryFile, + fn (string $message) => $this->output($message, 'lightBlue'), + ))->link($sapiObjects, $outputs); + } catch (\Throwable $exception) { + $this->error('PHP builder SAPI link failed: ' . $exception->getMessage()); + } + } + if ($this->hasSapi('embed')) { + $embedObjects = array_values(array_diff( + $objectFiles, + [$sapiObject, $internalFunctionsObject], + )); + $this->linkNativeTarget($embedObjects, $outputs['embed']); + } + + $runnableTargets = array_values(array_intersect($this->sapiTargets, ['embed', 'cli'])); + $primaryTarget = $runnableTargets[0] ?? $this->sapiTargets[0]; + $target = $outputs[$primaryTarget]; + $this->climate->green('Build successful: ' . implode(', ', $outputs)); return $target; } - $targetFile = $this->getTargetFileName(); + return $this->linkNativeTarget($objectFiles, $this->getTargetFileName()); + } + + /** @param list $objectFiles */ + private function linkNativeTarget(array $objectFiles, string $targetFile): string + { // Windows: add the .res resource file to the link if ($this->isWindows() && $this->hasResourceFile()) { @@ -2936,8 +3044,10 @@ CODE; public function run(string $targetFile): never { - if ($this->isSapiBuild() && !in_array('cli', $this->sapiTargets, true)) { - $this->climate->error('--run requires the CLI target in SAPI mode'); + if ($this->isPhpBuilderBuild() + && !$this->hasSapi('embed') + && !$this->hasSapi('cli')) { + $this->climate->error('--run requires an embed or CLI target in `sapi`'); exit(1); } if ($this->buildMode !== self::BUILD_MODE_BIN && !$this->isSapiBuild()) { @@ -4177,6 +4287,13 @@ CODE; if (array_key_exists('sapi', $cfg) && !$this->climate->arguments->defined('sapi')) { $this->configureSapiTargets($cfg['sapi']); } + if (array_key_exists('php-builder', $cfg) && !$this->climate->arguments->defined('php-builder')) { + try { + $this->configurePhpBuilder(PhpBuilderConfiguration::fromYaml($cfg['php-builder'])); + } catch (\InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + } + } if (array_key_exists('entry', $cfg)) { if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') { $this->error('`entry` must be a non-empty PHP file path'); @@ -4477,9 +4594,6 @@ CODE; $buildMode = $cfg['mode'] ?? $cfg['build-mode'] ?? $cfg['type'] ?? null; if (!empty($buildMode)) { $normalizedMode = strtolower(trim((string) $buildMode)); - if ($this->isSapiBuild() && $normalizedMode !== self::BUILD_MODE_SAPI) { - $this->error('`sapi` is an independent build mode and cannot be combined with `mode`'); - } $this->setBuildMode($normalizedMode); } @@ -4558,16 +4672,23 @@ CODE; return $list; } + private function configurePhpBuilder(PhpBuilderConfiguration $configuration): void + { + $this->phpBuilderEnabled = true; + $this->phpBuilderZts = $configuration->zts; + $this->phpBuilderExtensions = $configuration->extensions; + // The SAPI executable owns main(). Generated TypePHP code is linked as + // an internal Zend module and therefore has no dynamic get_module(). + } + private function configureSapiTargets(string|array $value): void { try { $this->sapiTargets = SapiBuildConfiguration::parseTargets($value); + $this->sapiConfigured = true; } catch (\InvalidArgumentException $exception) { $this->error($exception->getMessage()); } - // The SAPI executable owns main(). Generated TypePHP code is linked as - // an internal Zend module and therefore has no dynamic get_module(). - $this->buildMode = self::BUILD_MODE_SAPI; } /** @return list */ diff --git a/src/compiler.php b/src/compiler.php index 0f5d70f0..3fac6578 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -27,6 +27,8 @@ function runCompiler(int $argc, array $argv, CompilerRuntime $runtime): void require_once dirname(__DIR__) . '/vendor/autoload.php'; } + configureProxyEnvironment($argv); + $completionStatus = CompletionCommand::execute($argv); if ($completionStatus !== null) { if ($completionStatus !== 0) { @@ -68,6 +70,43 @@ function runCompiler(int $argc, array $argv, CompilerRuntime $runtime): void (new ProjectBuildRunner(Translator::getInstance($runtime)))->run($argv); } +/** + * Export an explicit CLI proxy for every network-capable child process. The + * downloader still receives the value directly so credentials can be redacted + * from its diagnostics, while tools such as package managers inherit the + * conventional proxy variables from the compiler process. + */ +function configureProxyEnvironment(array $argv): void +{ + $proxy = null; + for ($i = 1, $count = count($argv); $i < $count; ++$i) { + $argument = $argv[$i]; + if ($argument === '--proxy') { + if (!isset($argv[$i + 1]) || trim($argv[$i + 1]) === '') { + fwrite(STDERR, "Option --proxy requires a URL\n"); + exit(1); + } + $proxy = trim($argv[++$i]); + continue; + } + if (str_starts_with($argument, '--proxy=')) { + $proxy = trim(substr($argument, strlen('--proxy='))); + if ($proxy === '') { + fwrite(STDERR, "Option --proxy requires a URL\n"); + exit(1); + } + } + } + + if ($proxy === null) { + return; + } + foreach (['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'] as $name) { + putenv($name . '=' . $proxy); + $_ENV[$name] = $proxy; + } +} + function shouldCompileNativeSourceProject(array $argv): bool { $arguments = array_slice($argv, 1);