diff --git a/.github/workflows/ext-matrix-tests.yml b/.github/workflows/ext-matrix-tests.yml index e93e9145..45f9f432 100644 --- a/.github/workflows/ext-matrix-tests.yml +++ b/.github/workflows/ext-matrix-tests.yml @@ -2,13 +2,14 @@ name: "Extension Matrix Tests" on: workflow_dispatch: - push: + pull_request: + types: [opened, synchronize, labeled] jobs: test: name: "${{ matrix.extension }} (PHP ${{ matrix.php-version }} on ${{ matrix.operating-system }})" runs-on: ${{ matrix.operating-system }} - if: contains(github.event.head_commit.message, 'extension test') || contains(github.event.head_commit.message, 'test extensions') + if: contains(github.event.pull_request.labels.*.name, 'need-full-test') strategy: fail-fast: false matrix: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 34dbf959..690de888 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -227,7 +227,7 @@ jobs: echo "matrix=$(echo "$FINAL" | jq -c '{"combo": .}')" >> "$GITHUB_OUTPUT" ext-test: - name: "Ext test: ${{ matrix.combo.extension }} (PHP ${{ matrix.combo.php-version }} ยท ${{ matrix.combo.os }}-${{ matrix.combo.arch }})" + name: "Ext test: ${{ matrix.combo.extension }} [${{ matrix.combo.sapi }}] (PHP ${{ matrix.combo.php-version }} - ${{ matrix.combo.os }}-${{ matrix.combo.arch }})" needs: gen-matrix runs-on: ${{ matrix.combo.runner }} timeout-minutes: 120 @@ -266,5 +266,5 @@ jobs: if: always() && hashFiles('log/**') != '' uses: actions/upload-artifact@v4 with: - name: logs-${{ matrix.combo.os }}-${{ matrix.combo.arch }}-${{ matrix.combo.extension }}-php${{ matrix.combo.php-version }} + name: logs-${{ matrix.combo.os }}-${{ matrix.combo.arch }}-${{ matrix.combo.sapi }}-${{ matrix.combo.extension }}-php${{ matrix.combo.php-version }} path: log diff --git a/captainhook.json b/captainhook.json index 1057cb29..cd718dbe 100644 --- a/captainhook.json +++ b/captainhook.json @@ -20,7 +20,7 @@ ] }, { - "action": "bin/spc dev:lint-config --check", + "action": "php bin/spc dev:lint-config --check", "conditions": [ { "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\InDirectory", diff --git a/config/pkg/ext/ext-imagick.yml b/config/pkg/ext/ext-imagick.yml index 2a1c221c..89ddb341 100644 --- a/config/pkg/ext/ext-imagick.yml +++ b/config/pkg/ext/ext-imagick.yml @@ -13,4 +13,5 @@ ext-imagick: os: - Linux - Darwin + - Windows arg-type: custom diff --git a/config/pkg/lib/imagemagick.yml b/config/pkg/lib/imagemagick.yml index 4c4a8e1c..10acadfa 100644 --- a/config/pkg/lib/imagemagick.yml +++ b/config/pkg/lib/imagemagick.yml @@ -16,13 +16,59 @@ imagemagick: - libtiff - libheif - bzip2 + depends@windows: + - zlib suggests: - zstd - xz - libzip - libxml2 + headers@windows: + - imagemagick/MagickWand/MagickWand.h lang: cpp pkg-configs: - Magick++-7.Q16HDRI - MagickCore-7.Q16HDRI - MagickWand-7.Q16HDRI + static-libs@windows: + - CORE_RL_MagickWand_.lib + - CORE_RL_MagickCore_.lib + - CORE_RL_coders_.lib + - CORE_RL_filters_.lib + - CORE_RL_aom_.lib + - CORE_RL_brotli_.lib + - CORE_RL_bzip2_.lib + - CORE_RL_cairo_.lib + - CORE_RL_croco_.lib + - CORE_RL_de265_.lib + - CORE_RL_exr_.lib + - CORE_RL_ffi_.lib + - CORE_RL_freetype_.lib + - CORE_RL_fribidi_.lib + - CORE_RL_gdk-pixbuf_.lib + - CORE_RL_glib_.lib + - CORE_RL_harfbuzz_.lib + - CORE_RL_heif_.lib + - CORE_RL_highway_.lib + - CORE_RL_imath_.lib + - CORE_RL_jpeg-turbo-12_.lib + - CORE_RL_jpeg-turbo-16_.lib + - CORE_RL_jpeg-turbo_.lib + - CORE_RL_jpeg-xl_.lib + - CORE_RL_lcms_.lib + - CORE_RL_lqr_.lib + - CORE_RL_lzma_.lib + - CORE_RL_openh264_.lib + - CORE_RL_openjpeg_.lib + - CORE_RL_openjph_.lib + - CORE_RL_pango_.lib + - CORE_RL_pixman_.lib + - CORE_RL_png_.lib + - CORE_RL_raqm_.lib + - CORE_RL_raw_.lib + - CORE_RL_rsvg_.lib + - CORE_RL_tiff_.lib + - CORE_RL_webp_.lib + - CORE_RL_xml_.lib + - CORE_RL_zip_.lib + - CORE_RL_zlib_.lib diff --git a/config/pkg/lib/libiconv-win.yml b/config/pkg/lib/libiconv-win.yml index 103acf2e..91327149 100644 --- a/config/pkg/lib/libiconv-win.yml +++ b/config/pkg/lib/libiconv-win.yml @@ -9,5 +9,4 @@ libiconv-win: license-files: [source/COPYING] license: GPL-3.0-or-later static-libs@windows: - - libiconv.lib - libiconv_a.lib diff --git a/config/pkg/lib/libzip.yml b/config/pkg/lib/libzip.yml index 3d8a375a..db45a512 100644 --- a/config/pkg/lib/libzip.yml +++ b/config/pkg/lib/libzip.yml @@ -11,6 +11,10 @@ libzip: license: BSD-3-Clause depends: - zlib + depends@windows: + - zlib + - bzip2 + - xz suggests: - bzip2 - xz diff --git a/config/pkg/lib/openssl.yml b/config/pkg/lib/openssl.yml index 42672183..aa3aa42c 100644 --- a/config/pkg/lib/openssl.yml +++ b/config/pkg/lib/openssl.yml @@ -27,4 +27,5 @@ openssl: - libcrypto.lib tools@windows: - jom + - nasm - strawberry-perl diff --git a/config/pkg/lib/postgresql.yml b/config/pkg/lib/postgresql.yml index ee78072e..439e4e30 100644 --- a/config/pkg/lib/postgresql.yml +++ b/config/pkg/lib/postgresql.yml @@ -5,8 +5,6 @@ postgresql: type: ghtagtar repo: postgres/postgres match: REL_18_\d+ - binary: - windows-x86_64: { type: url, url: 'https://get.enterprisedb.com/postgresql/postgresql-16.8-1-windows-x64-binaries.zip', extract: { lib/libpq.lib: '{build_root_path}/lib/libpq.lib', lib/libpgport.lib: '{build_root_path}/lib/libpgport.lib', lib/libpgcommon.lib: '{build_root_path}/lib/libpgcommon.lib', include/libpq-fe.h: '{build_root_path}/include/libpq-fe.h', include/postgres_ext.h: '{build_root_path}/include/postgres_ext.h', include/pg_config_ext.h: '{build_root_path}/include/pg_config_ext.h', include/libpq/libpq-fs.h: '{build_root_path}/include/libpq/libpq-fs.h' } } metadata: license-files: ['@/postgresql.txt'] license: PostgreSQL @@ -16,6 +14,9 @@ postgresql: - openssl - zlib - libedit + depends@windows: + - openssl + - zlib suggests@unix: - icu - libxslt @@ -27,3 +28,9 @@ postgresql: - libpq.lib - libpgport.lib - libpgcommon.lib + tools@windows: + - python-win + - meson + - ninja + - winflexbison + - strawberry-perl diff --git a/config/pkg/tool/go-win.yml b/config/pkg/tool/go-win.yml index a65b6f5a..ce35de27 100644 --- a/config/pkg/tool/go-win.yml +++ b/config/pkg/tool/go-win.yml @@ -5,7 +5,7 @@ go-win: env: GOROOT: '{pkg_root_path}/go-win' GOBIN: '{pkg_root_path}/go-win/bin' - GOPATH: '{pkg_root_path}/go-win/go' + GOPATH: '{pkg_root_path}/go-win/gopath' path@windows: - '{pkg_root_path}/go-win/bin' tool: diff --git a/config/pkg/tool/meson.yml b/config/pkg/tool/meson.yml new file mode 100644 index 00000000..0021bf93 --- /dev/null +++ b/config/pkg/tool/meson.yml @@ -0,0 +1,11 @@ +meson: + type: tool + artifact: + binary: + windows-x86_64: { type: url, url: 'https://github.com/mesonbuild/meson/releases/download/1.11.1/meson-1.11.1-py3-none-any.whl', extract: '{pkg_root_path}/meson/meson-1.11.1-py3-none-any.whl' } + path@windows: + - '{pkg_root_path}\meson' + tool: + provides: + - meson.bat + binary-subdir: meson diff --git a/config/pkg/tool/ninja.yml b/config/pkg/tool/ninja.yml new file mode 100644 index 00000000..820e154d --- /dev/null +++ b/config/pkg/tool/ninja.yml @@ -0,0 +1,9 @@ +ninja: + type: tool + artifact: + binary: + windows-x86_64: { type: url, url: 'https://github.com/ninja-build/ninja/releases/download/v1.13.2/ninja-win.zip', extract: { ninja.exe: '{pkg_root_path}/bin/ninja.exe' } } + tool: + provides: + - ninja.exe + binary-subdir: bin diff --git a/config/pkg/tool/python-win.yml b/config/pkg/tool/python-win.yml new file mode 100644 index 00000000..388c1ace --- /dev/null +++ b/config/pkg/tool/python-win.yml @@ -0,0 +1,8 @@ +python-win: + type: tool + artifact: + binary: custom + tool: + provides: + - python.exe + binary-subdir: python-win/tools diff --git a/config/pkg/tool/winflexbison.yml b/config/pkg/tool/winflexbison.yml new file mode 100644 index 00000000..f117b5b9 --- /dev/null +++ b/config/pkg/tool/winflexbison.yml @@ -0,0 +1,12 @@ +winflexbison: + type: tool + artifact: + binary: + windows-x86_64: { type: url, url: 'https://github.com/lexxmark/winflexbison/releases/download/v2.5.25/win_flex_bison-2.5.25.zip', extract: '{pkg_root_path}/winflexbison' } + path@windows: + - '{pkg_root_path}\winflexbison' + tool: + provides: + - win_flex.exe + - win_bison.exe + binary-subdir: winflexbison diff --git a/src/Package/Artifact/meson_win.php b/src/Package/Artifact/meson_win.php new file mode 100644 index 00000000..1594579b --- /dev/null +++ b/src/Package/Artifact/meson_win.php @@ -0,0 +1,48 @@ +execWithResult("{$candidate} --version", false); + if ($code === 0) { + $python = $candidate; + break; + } + } + if ($python === null) { + throw new EnvironmentException('meson needs Python 3; the python-win tool package did not install and no system Python was found.'); + } + + if (is_dir($venv)) { + FileSystem::removeDir($venv); + } + cmd()->exec("{$python} -m venv \"{$venv}\"") + ->exec("\"{$venv}\\Scripts\\python.exe\" -m pip install --no-index --no-deps \"{$target_path}\""); + + FileSystem::writeFile("{$dir}\\meson.bat", "@echo off\r\n\"%~dp0venv\\Scripts\\meson.exe\" %*\r\n"); + } +} diff --git a/src/Package/Artifact/python_win.php b/src/Package/Artifact/python_win.php new file mode 100644 index 00000000..f3aa7135 --- /dev/null +++ b/src/Package/Artifact/python_win.php @@ -0,0 +1,66 @@ +getLatestVersion($downloader->getRetry()); + + $url = "https://api.nuget.org/v3-flatcontainer/python/{$version}/python.{$version}.nupkg"; + // .nupkg is a zip; name the cache file .zip so the extractor treats it as one + $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . "python-win-{$version}.zip"; + default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); + + return DownloadResult::archive(basename($path), ['url' => $url, 'version' => $version], extract: '{pkg_root_path}/python-win', version: $version); + } + + #[CustomBinaryCheckUpdate('python-win', ['windows-x86_64'])] + public function checkUpdateBinary(?string $old_version): CheckUpdateResult + { + $version = $this->getLatestVersion(); + return new CheckUpdateResult( + old: $old_version, + new: $version, + needUpdate: $old_version === null || $version !== $old_version, + ); + } + + #[AfterBinaryExtract('python-win', ['windows-x86_64'])] + public function afterExtract(string $target_path): void + { + if (!file_exists("{$target_path}\\tools\\python.exe")) { + throw new DownloaderException("Python installation appears incomplete: python.exe not found at {$target_path}\\tools\\python.exe"); + } + } + + private function getLatestVersion(int $retries = 0): string + { + $index = default_shell()->executeCurl('https://api.nuget.org/v3-flatcontainer/python/index.json', retries: $retries); + $versions = $index ? (json_decode($index, true)['versions'] ?? []) : []; + $stable = array_filter($versions, fn ($v) => preg_match('/^\d+\.\d+\.\d+$/', $v)); + if ($stable === []) { + throw new DownloaderException('Failed to get Python versions from the nuget index'); + } + return end($stable); + } +} diff --git a/src/Package/Extension/imagick.php b/src/Package/Extension/imagick.php index 2d2aa0aa..0be88669 100644 --- a/src/Package/Extension/imagick.php +++ b/src/Package/Extension/imagick.php @@ -4,12 +4,17 @@ declare(strict_types=1); namespace Package\Extension; +use Package\Target\php; +use StaticPHP\Attribute\Package\BeforeStage; use StaticPHP\Attribute\Package\CustomPhpConfigureArg; use StaticPHP\Attribute\Package\Extension; +use StaticPHP\Attribute\PatchDescription; use StaticPHP\Package\PackageBuilder; +use StaticPHP\Package\PhpExtensionPackage; +use StaticPHP\Util\FileSystem; #[Extension('imagick')] -class imagick +class imagick extends PhpExtensionPackage { #[CustomPhpConfigureArg('Darwin')] #[CustomPhpConfigureArg('Linux')] @@ -18,4 +23,35 @@ class imagick $disable_omp = ' ac_cv_func_omp_pause_resource_all=no'; return '--with-imagick=' . ($shared ? 'shared,' : '') . $builder->getBuildRootPath() . $disable_omp; } + + #[CustomPhpConfigureArg('Windows')] + public function getWindowsConfigureArg(bool $shared): string + { + // config.w32 uses PHP_IMAGICK as an extra search path for CORE_RL_*.lib; the static + // ImageMagick libs are installed flat in buildroot/lib (headers in buildroot/include/imagemagick). + return '--with-imagick=' . BUILD_LIB_PATH; + } + + #[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-imagick')] + #[PatchDescription('Add the Win32 system libraries the static ImageMagick stack needs')] + public function patchConfigW32ForWindows(): void + { + $config = $this->getSourceDir() . '/config.w32'; + + // Idempotency guard (the source dir may be patched in place and reused across builds). + if (str_contains(FileSystem::readFile($config), 'LIBS_IMAGICK')) { + return; + } + + // The static ImageMagick stack needs several Win32 system libraries (GDI/GDI+, WIC, urlmon, ...) + // that aren't already pulled in by the other extensions. (imagick itself builds as plain C: + // ImageMagick is built with a 32-bit channel mask, see imagemagick.php buildWin, so the + // MagickCore headers don't require a C++ translation unit.) + FileSystem::replaceFileStr( + $config, + "AC_DEFINE('HAVE_IMAGICK', 1);", + 'ADD_FLAG("LIBS_IMAGICK", "gdi32.lib gdiplus.lib urlmon.lib msimg32.lib oleaut32.lib windowscodecs.lib iphlpapi.lib");' . "\n\t\t" . + "AC_DEFINE('HAVE_IMAGICK', 1);" + ); + } } diff --git a/src/Package/Extension/pgsql.php b/src/Package/Extension/pgsql.php index b5b22a00..5b7f50a0 100644 --- a/src/Package/Extension/pgsql.php +++ b/src/Package/Extension/pgsql.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace Package\Extension; use Package\Target\php; +use StaticPHP\Attribute\Package\BeforeStage; use StaticPHP\Attribute\Package\CustomPhpConfigureArg; use StaticPHP\Attribute\Package\Extension; +use StaticPHP\Attribute\PatchDescription; use StaticPHP\Package\PackageBuilder; use StaticPHP\Package\PackageInstaller; use StaticPHP\Package\PhpExtensionPackage; +use StaticPHP\Package\TargetPackage; +use StaticPHP\Util\FileSystem; use StaticPHP\Util\SPCConfigUtil; #[Extension('pgsql')] @@ -39,6 +43,27 @@ class pgsql extends PhpExtensionPackage return "--with-pgsql={$builder->getBuildRootPath()}"; } + #[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-pgsql')] + #[PatchDescription('Link the Win32 system libraries the static libpq needs')] + public function patchConfigW32ForWindows(TargetPackage $package): void + { + $config = "{$package->getSourceDir()}\\ext\\pgsql\\config.w32"; + + if (str_contains(FileSystem::readFile($config), 'LIBS_PGSQL')) { + return; + } + + // libpq uses SSPI for Windows auth (secur32) and the static libcrypto behind it + // reads the system cert store (crypt32). Nothing else puts these on the link line + // when the openssl extension itself is not part of the build. + FileSystem::replaceFileStr( + $config, + 'EXTENSION("pgsql", "pgsql.c", PHP_PGSQL_SHARED, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");', + 'EXTENSION("pgsql", "pgsql.c", PHP_PGSQL_SHARED, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");' . "\n\t\t" . + 'ADD_FLAG("LIBS_PGSQL", "secur32.lib crypt32.lib");' + ); + } + public function getSharedExtensionEnv(): array { $parent = parent::getSharedExtensionEnv(); diff --git a/src/Package/Extension/xlswriter.php b/src/Package/Extension/xlswriter.php index 9af2d6db..bcb9cafd 100644 --- a/src/Package/Extension/xlswriter.php +++ b/src/Package/Extension/xlswriter.php @@ -11,6 +11,7 @@ use StaticPHP\Attribute\Package\Extension; use StaticPHP\Attribute\PatchDescription; use StaticPHP\Package\PackageInstaller; use StaticPHP\Package\PhpExtensionPackage; +use StaticPHP\Util\FileSystem; use StaticPHP\Util\SourcePatcher; #[Extension('xlswriter')] @@ -28,6 +29,17 @@ class xlswriter extends PhpExtensionPackage return $arg; } + #[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-xlswriter')] + #[PatchDescription('Define XML_STATIC so bundled Expat uses plain symbols instead of __declspec(dllimport) in static builds')] + public function patchConfigForStaticExpat(): void + { + FileSystem::replaceFileStr( + "{$this->getSourceDir()}/config.w32", + "' /D USE_SYSTEM_MINIZIP", + "' /D XML_STATIC /D USE_SYSTEM_MINIZIP" + ); + } + #[BeforeStage('php', [php::class, 'makeForWindows'], 'ext-xlswriter')] #[PatchDescription('Fix Windows build: apply win32 patch and add UTF-8 BOM to theme.c')] public function patchBeforeMakeForWindows(): void diff --git a/src/Package/Library/imagemagick.php b/src/Package/Library/imagemagick.php index d423163a..7e660499 100644 --- a/src/Package/Library/imagemagick.php +++ b/src/Package/Library/imagemagick.php @@ -6,15 +6,86 @@ namespace Package\Library; use StaticPHP\Attribute\Package\BuildFor; use StaticPHP\Attribute\Package\Library; +use StaticPHP\Exception\EnvironmentException; use StaticPHP\Package\LibraryPackage; use StaticPHP\Runtime\Executor\UnixAutoconfExecutor; use StaticPHP\Runtime\SystemTarget; use StaticPHP\Toolchain\Interface\ToolchainInterface; use StaticPHP\Util\FileSystem; +use StaticPHP\Util\System\WindowsUtil; #[Library('imagemagick')] class imagemagick { + /** + * Build a fully static, self-contained ImageMagick 7 (Q16-HDRI, /MT) on Windows using the + * official VisualMagick build (the ImageMagick/Windows + Configure + Dependencies repos), which + * bundles every delegate. ImageMagick has no autoconf/CMake build on Windows, so this clones the + * VisualMagick tree, generates a static x64 solution via the Configure tool, and builds it with + * msbuild. The resulting CORE_RL_*.lib static libraries + MagickWand/MagickCore headers are + * installed into the build root for ext-imagick to link. + * + * The VisualMagick tree lives under source/ so bin/spc reset cleans it up like everything + * else; override the location with SPC_IMAGEMAGICK_BUILD_DIR. + */ + #[BuildFor('Windows')] + public function buildWin(LibraryPackage $lib): void + { + $work = getenv('SPC_IMAGEMAGICK_BUILD_DIR') ?: SOURCE_PATH . '\imagemagick-win'; + $configure_release = '2026.05.30.2033'; + $configure_url = "https://github.com/ImageMagick/Configure/releases/download/{$configure_release}/Configure.Release.x64.exe"; + + FileSystem::createDir($work); + // Clone the VisualMagick repos (ImageMagick source + Configure + Dependencies + all delegates). + if (!is_dir("{$work}\\ImageMagick")) { + cmd()->cd($work)->exec(SPC_GIT_EXEC . ' clone --depth 1 https://github.com/ImageMagick/Windows.git .'); + cmd()->cd($work)->exec('bash clone-repositories.sh --imagemagick7'); + } + // Use the prebuilt Configure tool (building it from source needs the MFC components). + default_shell()->executeCurlDownload($configure_url, "{$work}\\Configure\\Configure.Release.x64.exe", retries: 2); + + // Generate a static, /MT (linkRuntime), x64, Q16-HDRI solution with the configs embedded + // (zeroConfigurationSupport) and OpenMP off (no vcomp runtime dependency). + $ver = WindowsUtil::findVisualStudio(); + $vs_major = is_array($ver) ? $ver['major_version'] : 'unknown'; + $vs_arg = match ($vs_major) { + '18', + '17' => '/VS2022', + '16' => '/VS2019', + default => throw new EnvironmentException("Current VS version {$vs_major} is not supported yet!"), + }; + cmd()->cd("{$work}\\Configure") + ->exec("Configure.Release.x64.exe /noWizard {$vs_arg} /x64 /static /linkRuntime /noOpenMP /zeroConfigurationSupport"); + + // x64 IM7 defaults to a 64-bit channel mask, whose magick-baseconfig.h #errors unless the + // consuming translation unit is C++. ext-imagick is plain C, so force a 32-bit channel mask + // (ample: 32 channels >> RGBA/CMYK) before building, keeping libs and the installed header in sync. + FileSystem::replaceFileStr( + "{$work}\\ImageMagick\\MagickCore\\magick-baseconfig.h", + '#define MAGICKCORE_CHANNEL_MASK_DEPTH 64', + '#define MAGICKCORE_CHANNEL_MASK_DEPTH 32' + ); + + cmd()->cd($work) + ->exec('msbuild IM7.Static.x64.sln /m /t:Rebuild /nologo /p:Configuration=Release /p:Platform=x64'); + + $artifacts = "{$work}\\Artifacts\\lib"; + if (!is_dir($artifacts)) { + throw new EnvironmentException('ImageMagick VisualMagick build produced no Artifacts/lib; build failed.'); + } + // Install the static libs (flat, onto the build-root lib path) and the public headers. + FileSystem::createDir($lib->getLibDir()); + foreach (glob("{$artifacts}\\CORE_RL_*.lib") as $f) { + FileSystem::copy($f, $lib->getLibDir() . '\\' . basename($f)); + } + foreach (['MagickWand', 'MagickCore'] as $dir) { + FileSystem::createDir($lib->getIncludeDir() . "\\imagemagick\\{$dir}"); + foreach (glob("{$work}\\ImageMagick\\{$dir}\\*.h") as $h) { + FileSystem::copy($h, $lib->getIncludeDir() . "\\imagemagick\\{$dir}\\" . basename($h)); + } + } + } + #[BuildFor('Darwin')] #[BuildFor('Linux')] public function buildUnix(LibraryPackage $lib, ToolchainInterface $toolchain): void diff --git a/src/Package/Library/openssl.php b/src/Package/Library/openssl.php index be32a9da..f673bb67 100644 --- a/src/Package/Library/openssl.php +++ b/src/Package/Library/openssl.php @@ -6,40 +6,21 @@ namespace Package\Library; use StaticPHP\Attribute\Package\BuildFor; use StaticPHP\Attribute\Package\Library; -use StaticPHP\Attribute\Package\Validate; -use StaticPHP\DI\ApplicationContext; use StaticPHP\Exception\EnvironmentException; use StaticPHP\Package\LibraryPackage; use StaticPHP\Package\PackageBuilder; -use StaticPHP\Runtime\SystemTarget; +use StaticPHP\Package\ToolPackage; +use StaticPHP\Registry\PackageLoader; use StaticPHP\Util\FileSystem; use StaticPHP\Util\System\LinuxUtil; -use StaticPHP\Util\System\WindowsUtil; #[Library('openssl')] class openssl { - #[Validate] - public function validate(): void - { - if (SystemTarget::getTargetOS() === 'Windows') { - global $argv; - $perl_path_native = PKG_ROOT_PATH . '\strawberry-perl\perl\bin\perl.exe'; - $perl = file_exists($perl_path_native) ? ($perl_path_native) : WindowsUtil::findCommand('perl.exe'); - if ($perl === null) { - throw new EnvironmentException( - 'You need to install perl first!', - "Please run \"{$argv[0]} doctor\" to fix the environment.", - ); - } - ApplicationContext::set('perl', $perl); - } - } - #[BuildFor('Windows')] public function buildWin(LibraryPackage $lib, PackageBuilder $builder): void { - $perl = ApplicationContext::get('perl'); + $perl = $this->resolveWindowsPerl(); $cmd = cmd()->cd($lib->getSourceDir()) ->exec( escapeshellarg($perl) . ' Configure zlib VC-WIN64A ' . @@ -145,4 +126,22 @@ class openssl FileSystem::replaceFileRegex("{$pkg->getLibDir()}/pkgconfig/libcrypto.pc", '/Libs.private:.*/m', 'Requires.private: zlib'); FileSystem::replaceFileRegex("{$pkg->getLibDir()}/cmake/OpenSSL/OpenSSLConfig.cmake", '/set\(OPENSSL_LIBCRYPTO_DEPENDENCIES .*\)/m', 'set(OPENSSL_LIBCRYPTO_DEPENDENCIES "${OPENSSL_LIBRARY_DIR}/libz.a")'); } + + private function resolveWindowsPerl(): string + { + global $argv; + + $tool = PackageLoader::getPackage('strawberry-perl'); + if ($tool instanceof ToolPackage) { + $perl = $tool->getBinary('perl.exe'); + if (file_exists($perl)) { + return $perl; + } + } + + throw new EnvironmentException( + 'OpenSSL requires strawberry-perl, but it is not installed.', + "Please run \"{$argv[0]} install-pkg strawberry-perl\" or retry the build with downloads enabled.", + ); + } } diff --git a/src/Package/Library/postgresql.php b/src/Package/Library/postgresql.php index 45c48577..25d600e2 100644 --- a/src/Package/Library/postgresql.php +++ b/src/Package/Library/postgresql.php @@ -37,6 +37,10 @@ class postgresql extends LibraryPackage #[PatchDescription('Various patches before building PostgreSQL')] public function patchBeforeBuild(): bool { + // These patches target the autoconf/Make build; the Windows build uses Meson (see buildWin). + if (SystemTarget::getTargetOS() === 'Windows') { + return true; + } // skip the test on platforms where libpq infrastructure may be provided by statically-linked libraries FileSystem::replaceFileStr("{$this->getSourceDir()}/src/interfaces/libpq/Makefile", 'invokes exit\'; exit 1;', 'invokes exit\';'); // disable shared libs build @@ -53,6 +57,73 @@ class postgresql extends LibraryPackage return true; } + #[BuildFor('Windows')] + public function buildWin(LibraryPackage $lib): void + { + $src = $lib->getSourceDir(); + $build_root = $lib->getBuildRootPath(); + $lib_dir = $lib->getLibDir(); + $inc_dir = $lib->getIncludeDir(); + $build = "{$src}\\build"; + + // Export the public pg_char_to_encoding()/pg_encoding_to_char() from libpgcommon.a so a + // statically-linked libpq.a resolves them (PHP's ext/pgsql relies on them too). This mirrors + // the Unix build's -UUSE_PRIVATE_ENCODING_FUNCS patch, but for the Meson build. + FileSystem::replaceFileStr( + "{$src}\\src\\common\\meson.build", + "'c_args': ['-DUSE_PRIVATE_ENCODING_FUNCS'],", + "'c_args': []," + ); + + // Fresh Meson build dir (Meson refuses to reuse a dir configured differently). + if (is_dir($build)) { + FileSystem::removeDir($build); + } + + // Meson's OpenSSL detection link-tests CRYPTO_new_ex_data; our static libcrypto needs its + // Win32 deps (and zlib, since OpenSSL was built with zlib) on the link line to succeed. + $ld = 'ws2_32.lib gdi32.lib advapi32.lib crypt32.lib user32.lib secur32.lib zlibstatic.lib'; + + $configure = 'meson setup build' + . ' --prefix=' . escapeshellarg($build_root) + . ' -Ddefault_library=static' // static libpq.a / libpgcommon.a / libpgport.a + . ' -Db_vscrt=mt' // /MT static CRT, matching the rest of the build + . ' -Dssl=openssl' + // Everything libpq doesn't need: keeps deps minimal and avoids server-only detection. + . ' -Dzlib=disabled -Dnls=disabled -Dreadline=disabled -Dicu=disabled' + . ' -Dlz4=disabled -Dzstd=disabled -Dtap_tests=disabled' + . ' -Dplperl=disabled -Dplpython=disabled -Dpltcl=disabled' + . ' -Dgssapi=disabled -Dldap=disabled -Dlibxml=disabled -Dlibxslt=disabled' + . ' -Dextra_include_dirs=' . escapeshellarg("{$build_root}\\include") + . ' -Dextra_lib_dirs=' . escapeshellarg($lib_dir); + + // Build only the three frontend static libs (not the server) โ€” keeps it fast and avoids + // needing every backend dependency. meson/ninja/win_bison/win_flex/perl come from the + // tool packages declared in the package config (tools@windows). + $targets = 'src/interfaces/libpq/libpq.a src/common/libpgcommon.a src/port/libpgport.a'; + + cmd()->cd($src) + ->setEnv([ + 'LIB' => $lib_dir . ';' . (getenv('LIB') ?: ''), + 'LDFLAGS' => $ld, + ]) + ->exec($configure) + ->exec("ninja -C build {$targets}"); + + // Install the static libs under the names PHP's ext/pgsql + frankenphp expect (.lib). + FileSystem::createDir($lib_dir); + FileSystem::createDir($inc_dir); + FileSystem::copy("{$build}\\src\\interfaces\\libpq\\libpq.a", "{$lib_dir}\\libpq.lib"); + FileSystem::copy("{$build}\\src\\common\\libpgcommon.a", "{$lib_dir}\\libpgcommon.lib"); + FileSystem::copy("{$build}\\src\\port\\libpgport.a", "{$lib_dir}\\libpgport.lib"); + + // Install the public libpq headers (PG18 no longer ships pg_config_ext.h). + FileSystem::copy("{$src}\\src\\interfaces\\libpq\\libpq-fe.h", "{$inc_dir}\\libpq-fe.h"); + FileSystem::copy("{$src}\\src\\include\\postgres_ext.h", "{$inc_dir}\\postgres_ext.h"); + FileSystem::createDir("{$inc_dir}\\libpq"); + FileSystem::copy("{$src}\\src\\include\\libpq\\libpq-fs.h", "{$inc_dir}\\libpq\\libpq-fs.h"); + } + #[BuildFor('Darwin')] #[BuildFor('Linux')] public function buildUnix(PackageInstaller $installer, PackageBuilder $builder): void diff --git a/src/Package/Target/curl.php b/src/Package/Target/curl.php index a707fb7c..4d774722 100644 --- a/src/Package/Target/curl.php +++ b/src/Package/Target/curl.php @@ -35,12 +35,16 @@ class curl #[BuildFor('Windows')] public function buildWin(LibraryPackage $lib): void { + $lib_dir = str_replace('\\', '/', $lib->getLibDir()); + $zstd_lib = "{$lib_dir}/zstd_static.lib"; + $extra_libs = "{$lib_dir}/libcrypto.lib {$lib_dir}/libssl.lib ws2_32.lib gdi32.lib advapi32.lib crypt32.lib user32.lib"; WindowsCMakeExecutor::create($lib) ->optionalPackage('zstd', ...cmake_boolean_args('CURL_ZSTD')) ->optionalPackage('brotli', ...cmake_boolean_args('CURL_BROTLI')) ->addConfigureArgs( '-DBUILD_CURL_EXE=ON', - '-DZSTD_LIBRARY=' . BUILD_LIB_PATH . '/zstd_static.lib', + '-DCMAKE_C_STANDARD_LIBRARIES=' . escapeshellarg($extra_libs), + '-DZSTD_LIBRARY=' . escapeshellarg($zstd_lib), '-DBUILD_TESTING=OFF', '-DBUILD_EXAMPLES=OFF', '-DUSE_LIBIDN2=OFF', diff --git a/src/Package/Target/php/frankenphp.php b/src/Package/Target/php/frankenphp.php index 4c428623..854dc8cf 100644 --- a/src/Package/Target/php/frankenphp.php +++ b/src/Package/Target/php/frankenphp.php @@ -26,6 +26,55 @@ use ZM\Logger\ConsoleColor; trait frankenphp { + /** + * Source of the -mthreads stripping shim, see windowsCgoCompilers(). The %s + * placeholder receives the wrapped compiler path as a C string literal. + */ + private const string MTHREADS_SHIM_C = <<<'C' + // Generated by static-php-cli. Drops the MinGW-only -mthreads flag Go passes to + // cgo (golang/go#80290) and forwards everything else to clang. + #include + #include + #include + + static const wchar_t compiler[] = L"%s"; + + int main(void) + { + const wchar_t *args = GetCommandLineW(); + // skip our own (possibly quoted) argv[0] + if (*args == L'"') { + args++; + while (*args && *args != L'"') args++; + if (*args) args++; + } else { + while (*args && *args != L' ' && *args != L'\t') args++; + } + + // "" + args + trailing space so a flag at the very end still matches + size_t len = wcslen(compiler) + wcslen(args) + 8; + wchar_t *cmd = calloc(len, sizeof(wchar_t)); + if (cmd == NULL) return 1; + swprintf(cmd, len, L"\"%%ls\"%%ls ", compiler, args); + + // remove every space-delimited -mthreads token + wchar_t *p; + while ((p = wcsstr(cmd, L" -mthreads ")) != NULL) { + wmemmove(p, p + 10, wcslen(p + 10) + 1); + } + + STARTUPINFOW si = {sizeof(si)}; + PROCESS_INFORMATION pi; + if (!CreateProcessW(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { + return 127; + } + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + GetExitCodeProcess(pi.hProcess, &code); + return (int) code; + } + C; + #[Stage] public function buildFrankenphpForUnix(TargetPackage $package, PackageInstaller $installer, ToolchainInterface $toolchain, PackageBuilder $builder): void { @@ -233,10 +282,21 @@ trait frankenphp $dep_libs = array_unique($dep_libs); $lib_dir = str_replace('\\', '/', BUILD_LIB_PATH); $php_embed_lib = "-lphp{$major}embed"; - $win_sys_libs = '-lkernel32 -lole32 -luser32 -ladvapi32 -lshell32 -lws2_32 -ldnsapi -lpsapi -lbcrypt'; + // pathcch: PathCchCanonicalizeEx etc. used by frankenphp/caddy path handling. + // secur32: InitSecurityInterfaceA (curl Schannel/SSPI). crypt32/gdi32: OpenSSL + Schannel. + $win_sys_libs = '-lkernel32 -lole32 -luser32 -ladvapi32 -lshell32 -lws2_32 -ldnsapi -lpsapi -lbcrypt -lpathcch -lsecur32 -lcrypt32 -lgdi32'; $cgo_ldflags = clean_spaces(implode(' ', array_filter([ "-L{$lib_dir}", $php_embed_lib, + // FrankenPHP's cgo code references PHP/lexbor/zend symbols via __declspec(dllimport). + // Their definitions live in php{N}embed.lib but are only pulled in if the plain symbol + // is referenced, so the __imp_ refs go unresolved. Force-include one symbol from each + // defining object (zend_atomic.obj, lexbor url.obj, lexbor idna.obj) to pull them in; + // lld then auto-imports the __imp_ refs. (/WHOLEARCHIVE would also drag in libxml2.res, + // which collides with Go's own resource object: "more than one resource obj file".) + '-Wl,/INCLUDE:zend_atomic_bool_store', + '-Wl,/INCLUDE:lxb_url_parse', + '-Wl,/INCLUDE:lxb_unicode_idna_init', implode(' ', $dep_libs), $win_sys_libs, '-llibcmt', @@ -262,10 +322,11 @@ trait frankenphp // Fix: prepend clang's directory to PATH and use plain executable names instead, // which matches FrankenPHP's official CI approach (CC=clang, CXX=clang++). $clang_dir = dirname($clang_info['clang']); + [$cc, $cxx] = $this->windowsCgoCompilers($package, $clang_info['clang']); $env = [ 'CGO_ENABLED' => '1', - 'CC' => 'clang.exe', - 'CXX' => 'clang++.exe', + 'CC' => $cc, + 'CXX' => $cxx, 'PATH' => $clang_dir . ';' . getenv('PATH'), 'CGO_CFLAGS' => clean_spaces($cgo_cflags), 'CGO_LDFLAGS' => $cgo_ldflags, @@ -366,6 +427,48 @@ trait frankenphp } } + /** + * Return the [CC, CXX] cgo should use to build FrankenPHP on Windows. + * + * Go passes the MinGW-only `-mthreads` flag to the C compiler for cgo builds; + * Clang >= 20 rejects it for the MSVC target. When it does, wrap Clang with a + * small compiled shim that strips the flag before forwarding. An executable and + * not a .bat on purpose: batch files run through cmd.exe, which caps the command + * line at 8191 characters, and cgo link steps with big extension stacks (imagick) + * blow past that. A real process gets the full ~32K limit, and beyond that Go + * switches to a response file by itself. + * + * Workaround for https://github.com/golang/go/issues/80290, remove once Go + * stops passing the flag. + * + * @return array{0: string, 1: string} + */ + protected function windowsCgoCompilers(TargetPackage $package, string $clang): array + { + $probe = $package->getSourceDir() . '\mthreads-probe.c'; + file_put_contents($probe, "int main(void){return 0;}\n"); + [$ret] = cmd()->execWithResult('"' . $clang . '" -mthreads -c ' . escapeshellarg($probe) . ' -o ' . escapeshellarg($probe . '.o'), false); + FileSystem::removeFileIfExists($probe); + FileSystem::removeFileIfExists($probe . '.o'); + if ($ret === 0) { + return ['clang.exe', 'clang++.exe']; + } + + logger()->info('Clang rejects -mthreads; wrapping it to strip the flag (golang/go#80290)'); + $dir = dirname($clang); + $wrappers = []; + foreach (['cc-nothreads' => 'clang.exe', 'cxx-nothreads' => 'clang++.exe'] as $name => $compiler) { + $src = $package->getSourceDir() . "\\{$name}.c"; + $exe = $package->getSourceDir() . "\\{$name}.exe"; + file_put_contents($src, sprintf(self::MTHREADS_SHIM_C, str_replace('\\', '\\\\', "{$dir}\\{$compiler}"))); + cmd()->cd($package->getSourceDir()) + ->exec('"' . $clang . '" -O2 ' . escapeshellarg($src) . ' -o ' . escapeshellarg($exe)); + $wrappers[] = $exe; + } + + return $wrappers; + } + protected function getFrankenPHPVersion(TargetPackage $package): string { if ($version = getenv('FRANKENPHP_VERSION')) { diff --git a/src/Package/Target/php/unix.php b/src/Package/Target/php/unix.php index 7d4d0063..7dcc6d8b 100644 --- a/src/Package/Target/php/unix.php +++ b/src/Package/Target/php/unix.php @@ -59,7 +59,7 @@ trait unix } if (self::getPHPVersionID() >= 80300 && self::getPHPVersionID() < 80400) { - SourcePatcher::patchFile('spc_fix_avx512_cache_before_80400.patch', $this->getSourceDir()); + SourcePatcher::patchFile('spc_fix_avx512_cache_before_80400.patch', $package->getSourceDir()); } } @@ -289,16 +289,7 @@ trait unix $builder->deployBinary($package->getSourceDir() . '/sapi/micro/micro.sfx', $dst); // patch after UPX-ed micro.sfx (Linux only) if (SystemTarget::getTargetOS() === 'Linux' && $builder->getOption('with-upx-pack')) { - // cut binary with readelf to remove UPX extra segment - [$ret, $out] = shell()->execWithResult("readelf -l {$dst} | awk '/LOAD|GNU_STACK/ {getline; print \\$1, \\$2, \\$3, \\$4, \\$6, \\$7}'"); - $out[1] = explode(' ', $out[1]); - $offset = $out[1][0]; - if ($ret !== 0 || !str_starts_with($offset, '0x')) { - throw new PatchException('phpmicro UPX patcher', 'Cannot find offset in readelf output'); - } - $offset = hexdec($offset); - // remove upx extra wastes - file_put_contents($dst, substr(file_get_contents($dst), 0, $offset)); + self::patchMicroUpxPackedBinary($dst); } $package->setOutput('Binary path for micro SAPI', $dst); } @@ -680,6 +671,43 @@ trait unix return $php; } + /** + * Remove the extra segment appended by UPX when packing phpmicro. + */ + private static function patchMicroUpxPackedBinary(string $binaryPath): void + { + [$ret, $out] = shell()->execWithResult('readelf -W -l ' . escapeshellarg($binaryPath)); + if ($ret !== 0) { + throw new PatchException('phpmicro UPX patcher', 'Cannot find offset in readelf output'); + } + + $fileSizes = []; + foreach ($out as $index => $line) { + $line = trim($line); + if (!preg_match('/^(LOAD|GNU_STACK)\s+/', $line)) { + continue; + } + + $columns = preg_split('/\s+/', $line); + if (isset($columns[4]) && preg_match('/^0x[[:xdigit:]]+$/', $columns[4]) === 1) { + $fileSizes[] = $columns[4]; + continue; + } + + $next = $out[$index + 1] ?? ''; + if (preg_match('/^\s*(0x[[:xdigit:]]+)\s+0x[[:xdigit:]]+/', $next, $matches)) { + $fileSizes[] = $matches[1]; + } + } + + $offset = $fileSizes[1] ?? null; + if ($offset === null) { + throw new PatchException('phpmicro UPX patcher', 'Cannot find offset in readelf output'); + } + + file_put_contents($binaryPath, substr(file_get_contents($binaryPath), 0, intval(hexdec($offset)))); + } + /** * Rename libphp.so to libphp-.so if -release is set in LDFLAGS. */ diff --git a/src/Package/Target/php/windows.php b/src/Package/Target/php/windows.php index 7697ce4b..915b1821 100644 --- a/src/Package/Target/php/windows.php +++ b/src/Package/Target/php/windows.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Package\Target\php; +use Package\Target\php; use StaticPHP\Attribute\Package\BeforeStage; use StaticPHP\Attribute\Package\BuildFor; use StaticPHP\Attribute\Package\Stage; @@ -126,7 +127,10 @@ trait windows throw new PatchException('Windows Makefile patching for php.exe target', 'Cannot patch windows CLI Makefile, Makefile does not contain "$(BUILD_DIR)\php.exe:" line'); } $lines[$line_num] = '$(BUILD_DIR)\php.exe: generated_files $(DEPS_CLI) $(PHP_GLOBAL_OBJS) $(CLI_GLOBAL_OBJS) $(STATIC_EXT_OBJS) $(ASM_OBJS) $(BUILD_DIR)\php.exe.res $(BUILD_DIR)\php.exe.manifest'; - $lines[$line_num + 1] = "\t" . '"$(LINK)" /nologo $(PHP_GLOBAL_OBJS_RESP) $(CLI_GLOBAL_OBJS_RESP) $(STATIC_EXT_OBJS_RESP) $(STATIC_EXT_LIBS) $(ASM_OBJS) $(LIBS) $(LIBS_CLI) $(BUILD_DIR)\php.exe.res /out:$(BUILD_DIR)\php.exe $(LDFLAGS) $(LDFLAGS_CLI) /ltcg /nodefaultlib:msvcrt /nodefaultlib:msvcrtd /ignore:4286'; + // /FORCE:MULTIPLE: extensions may bundle their own static copies of common libraries (e.g. + // imagick's ImageMagick ships its own zlib/png/jpeg, duplicating gd's); let the first + // definition win instead of failing with LNK2005. /ignore:4006 silences the resulting noise. + $lines[$line_num + 1] = "\t" . '"$(LINK)" /nologo $(PHP_GLOBAL_OBJS_RESP) $(CLI_GLOBAL_OBJS_RESP) $(STATIC_EXT_OBJS_RESP) $(STATIC_EXT_LIBS) $(ASM_OBJS) $(LIBS) $(LIBS_CLI) $(BUILD_DIR)\php.exe.res /out:$(BUILD_DIR)\php.exe $(LDFLAGS) $(LDFLAGS_CLI) /ltcg /nodefaultlib:msvcrt /nodefaultlib:msvcrtd /ignore:4286 /FORCE:MULTIPLE /ignore:4006'; FileSystem::writeFile("{$package->getSourceDir()}\\Makefile", implode("\r\n", $lines)); } @@ -171,8 +175,9 @@ trait windows } } + $libs_cli = $this->makeLibsResponseFile($package, 'cli', "ws2_32.lib shell32.lib {$extra_libs}"); cmd()->cd($package->getSourceDir()) - ->exec("nmake /nologo {$debug_overrides}LIBS_CLI=\"ws2_32.lib shell32.lib {$extra_libs}\" EXTRA_LD_FLAGS_PROGRAM= php.exe"); + ->exec("nmake /nologo {$debug_overrides}LIBS_CLI=\"{$libs_cli}\" EXTRA_LD_FLAGS_PROGRAM= php.exe"); $this->deployWindowsBinary($builder, $package, 'php-cli'); } @@ -197,7 +202,10 @@ trait windows throw new PatchException('Windows Makefile patching for php-cgi.exe target', 'Cannot patch windows CGI Makefile, Makefile does not contain "$(BUILD_DIR)\php-cgi.exe:" line'); } $lines[$line_num] = '$(BUILD_DIR)\php-cgi.exe: $(DEPS_CGI) $(CGI_GLOBAL_OBJS) $(PHP_GLOBAL_OBJS) $(STATIC_EXT_OBJS) $(ASM_OBJS) $(BUILD_DIR)\php-cgi.exe.res $(BUILD_DIR)\php-cgi.exe.manifest'; - $lines[$line_num + 1] = "\t" . '@"$(LINK)" /nologo $(PHP_GLOBAL_OBJS_RESP) $(CGI_GLOBAL_OBJS_RESP) $(STATIC_EXT_OBJS_RESP) $(STATIC_EXT_LIBS) $(ASM_OBJS) $(LIBS) $(LIBS_CGI) $(BUILD_DIR)\php-cgi.exe.res /out:$(BUILD_DIR)\php-cgi.exe $(LDFLAGS) $(LDFLAGS_CGI) /ltcg /nodefaultlib:msvcrt /nodefaultlib:msvcrtd /ignore:4286'; + // /FORCE:MULTIPLE /ignore:4006: same reason as the php.exe target above. Without it the + // CGI link fails with LNK2005 (e.g. imagick's MagickCore defines gettimeofday, which + // php's time.obj also defines). + $lines[$line_num + 1] = "\t" . '@"$(LINK)" /nologo $(PHP_GLOBAL_OBJS_RESP) $(CGI_GLOBAL_OBJS_RESP) $(STATIC_EXT_OBJS_RESP) $(STATIC_EXT_LIBS) $(ASM_OBJS) $(LIBS) $(LIBS_CGI) $(BUILD_DIR)\php-cgi.exe.res /out:$(BUILD_DIR)\php-cgi.exe $(LDFLAGS) $(LDFLAGS_CGI) /ltcg /nodefaultlib:msvcrt /nodefaultlib:msvcrtd /ignore:4286 /FORCE:MULTIPLE /ignore:4006'; FileSystem::writeFile("{$package->getSourceDir()}\\Makefile", implode("\r\n", $lines)); // Patch cgi-static, comment ZEND_TSRMLS_CACHE_DEFINE() @@ -238,8 +246,9 @@ trait windows } } + $libs_cgi = $this->makeLibsResponseFile($package, 'cgi', "ws2_32.lib kernel32.lib advapi32.lib {$extra_libs}"); cmd()->cd($package->getSourceDir()) - ->exec("nmake /nologo {$debug_overrides}LIBS_CGI=\"ws2_32.lib kernel32.lib advapi32.lib {$extra_libs}\" EXTRA_LD_FLAGS_PROGRAM= php-cgi.exe"); + ->exec("nmake /nologo {$debug_overrides}LIBS_CGI=\"{$libs_cgi}\" EXTRA_LD_FLAGS_PROGRAM= php-cgi.exe"); $this->deployWindowsBinary($builder, $package, 'php-cgi'); } @@ -264,6 +273,19 @@ trait windows } } + #[BeforeStage('php', [self::class, 'makeMicroForWindows'])] + #[PatchDescription('Add /FORCE:MULTIPLE to the micro.sfx link, matching the CLI and CGI targets')] + public function patchMicroTarget(TargetPackage $package): void + { + $makefile = "{$package->getSourceDir()}\\Makefile"; + if (str_contains(FileSystem::readFile($makefile), 'LDFLAGS_MICRO=/FORCE:MULTIPLE')) { + return; + } + // Same reason as the php.exe and php-cgi.exe targets: extension-bundled libs duplicate + // symbols from php's own objects and the link fails with LNK2005. + FileSystem::replaceFileStr($makefile, "\r\nLDFLAGS_MICRO=", "\r\nLDFLAGS_MICRO=/FORCE:MULTIPLE /ignore:4006 "); + } + #[Stage] public function makeMicroForWindows(TargetPackage $package, PackageBuilder $builder, PackageInstaller $installer): void { @@ -311,8 +333,9 @@ trait windows $fake_cli = $package->getBuildOption('with-micro-fake-cli', false) ? ' /DPHP_MICRO_FAKE_CLI' : ''; + $libs_micro = $this->makeLibsResponseFile($package, 'micro', "ws2_32.lib shell32.lib {$extra_libs}"); cmd()->cd($package->getSourceDir()) - ->exec("nmake /nologo {$debug_overrides}LIBS_MICRO=\"ws2_32.lib shell32.lib {$extra_libs}\" CFLAGS_MICRO=\"/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1{$fake_cli}\" EXTRA_LD_FLAGS_PROGRAM= micro"); + ->exec("nmake /nologo {$debug_overrides}LIBS_MICRO=\"{$libs_micro}\" CFLAGS_MICRO=\"/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1{$fake_cli}\" EXTRA_LD_FLAGS_PROGRAM= micro"); $this->deployWindowsBinary($builder, $package, 'php-micro'); } @@ -714,12 +737,18 @@ C_CODE; // MSVC cl.exe format: compiler flags must come before /link, linker flags after // ldflags contains /LIBPATH which must be after /link // /FORCE:MULTIPLE: in ZTS mode both zend.obj and php_embed.obj (both packed into the fat php8embed.lib) define _tsrm_ls_cache as a __declspec(thread) variable. + // /INCLUDE: php8embed.lib's ext/uri (uri_parser_whatwg.obj) references lexbor lxb_url_*/ + // lxb_unicode_idna_* via __declspec(dllimport); their definitions live in url.obj/idna.obj + // but are only pulled in if the plain symbol is referenced. Force-include one symbol from + // each so the objects are linked and the __imp_ refs auto-import. (FrankenPHP needs the same.) + // System libs add pathcch (PathCchCanonicalizeEx), secur32 (curl Schannel InitSecurityInterface), + // crypt32/gdi32 (OpenSSL + Schannel) on top of the Makefile LIBS set. $compile_cmd = sprintf( - 'cl.exe /nologo /O2 /MT /Z7 %s embed.c /Fe:embed.exe /link /FORCE:MULTIPLE /LIBPATH:"%s\lib" %s %s', + 'cl.exe /nologo /O2 /MT /Z7 %s embed.c /Fe:embed.exe /link /FORCE:MULTIPLE /INCLUDE:lxb_url_parse /INCLUDE:lxb_unicode_idna_init /LIBPATH:"%s\lib" %s %s', $include_flags, BUILD_ROOT_PATH, $config['libs'], - 'kernel32.lib ole32.lib user32.lib advapi32.lib shell32.lib ws2_32.lib dnsapi.lib psapi.lib bcrypt.lib' // Windows system libs (match Makefile LIBS) + 'kernel32.lib ole32.lib user32.lib advapi32.lib shell32.lib ws2_32.lib dnsapi.lib psapi.lib bcrypt.lib pathcch.lib secur32.lib crypt32.lib gdi32.lib' // Windows system libs (match Makefile LIBS) + curl/openssl deps ); // Log command explicitly (workaround for cmd() not logging complex commands properly) @@ -733,9 +762,11 @@ C_CODE; ); } - // Run the embed test + // Run the embed test. Use a ".\" prefix: cmd.exe does not resolve a bare "embed.exe" from + // the current directory, while the cwd must remain $test_dir so the script's relative + // "embed.php" is found. InteractiveTerm::setMessage('Running php-embed run smoke test'); - [$ret, $output] = cmd()->cd($test_dir)->execWithResult('embed.exe'); + [$ret, $output] = cmd()->cd($test_dir)->execWithResult('.\embed.exe'); $raw_output = implode('', $output); if ($ret !== 0 || trim($raw_output) !== 'hello') { throw new ValidationException( @@ -856,4 +887,17 @@ C_CODE; $package->setOutput('PHP headers path for embed SAPI', $php_include_dir); } + + /** + * Write the collected lib list into a linker response file and return the @file + * token for the nmake command line. With big extension stacks (imagick alone pulls + * in ~60 static libs) the inline list can push the whole command past cmd.exe's + * 8191 character limit; link.exe reads @file without any length limit. + */ + private function makeLibsResponseFile(TargetPackage $package, string $sapi, string $libs): string + { + $file = "{$package->getSourceDir()}\\spc-libs-{$sapi}.rsp"; + FileSystem::writeFile($file, $libs); + return "@{$file}"; + } } diff --git a/src/StaticPHP/Artifact/ArtifactExtractor.php b/src/StaticPHP/Artifact/ArtifactExtractor.php index 3630981a..a5191686 100644 --- a/src/StaticPHP/Artifact/ArtifactExtractor.php +++ b/src/StaticPHP/Artifact/ArtifactExtractor.php @@ -228,6 +228,27 @@ class ArtifactExtractor $mode = $extract_config['mode']; if ($mode === 'selective') { + // Check if all destination files already exist before extracting + $all_files_exist = true; + foreach ($extract_config['files'] as $src_pattern => $dst_path) { + $resolved = $this->replacePathVariables($dst_path); + // For glob patterns, check if at least the parent directory exists + if (str_contains($src_pattern, '*')) { + $parent = dirname($resolved); + if (!is_dir($parent)) { + $all_files_exist = false; + break; + } + } elseif (!file_exists($resolved) && !is_dir($resolved)) { + $all_files_exist = false; + break; + } + } + if ($all_files_exist) { + logger()->debug("Binary [{$name}] already extracted (selective), skip."); + return SPC_STATUS_ALREADY_EXTRACTED; + } + $this->doSelectiveExtract($name, $cache_info, $extract_config['files']); $artifact->emitAfterBinaryExtract($target_path, $platform); logger()->debug("Emitted after-binary-extract hooks for [{$name}]"); @@ -251,8 +272,14 @@ class ArtifactExtractor logger()->debug("Emitted after-binary-extract hooks for [{$name}]"); /* @phpstan-ignore-next-line */ - if ($hash !== null && $cache_info['cache_type'] !== 'file') { - FileSystem::writeFile("{$target_path}/.spc-hash", $hash); + if ($hash !== null) { + // Directory targets: store hash inside the directory + if (is_dir($target_path)) { + FileSystem::writeFile("{$target_path}/.spc-hash", $hash); + } elseif (is_file($target_path)) { + // File targets: store hash as a sidecar file (e.g., meson .whl) + FileSystem::writeFile("{$target_path}.spc-hash", $hash); + } } return SPC_STATUS_EXTRACTED; } @@ -332,9 +359,24 @@ class ArtifactExtractor /** * Check if artifact is already extracted with correct hash. + * + * Handles both directory targets (hash stored in $path/.spc-hash) and + * file targets (hash stored in $path.spc-hash sidecar). */ protected function isAlreadyExtracted(string $path, ?string $expected_hash): bool { + // File targets: check sidecar hash file (e.g., meson .whl) + if (is_file($path)) { + if ($expected_hash === null) { + return false; + } + $hash_file = "{$path}.spc-hash"; + if (!file_exists($hash_file)) { + return false; + } + return FileSystem::readFile($hash_file) === $expected_hash; + } + if (!is_dir($path)) { return false; } diff --git a/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php index 6b5c9e6d..23bc66be 100644 --- a/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php +++ b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php @@ -14,6 +14,12 @@ class GenExtTestMatrixCommand extends BaseCommand { private const string BUILD_TARGETS = '--build-cli --build-cgi --build-micro --with-suggests -vvv'; + private const string FRANKENPHP_BUILD_TARGETS = '--build-cli --build-frankenphp --enable-zts --with-suggests -vvv'; + + private const array SUPPORTED_SAPIS = [ + 'frankenphp', + ]; + private const array OS_RUNNERS = [ 'linux' => ['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'], 'windows' => ['arch' => 'x86_64', 'runner' => 'windows-2025', 'os_key' => 'Windows'], @@ -84,6 +90,7 @@ class GenExtTestMatrixCommand extends BaseCommand $this->addOption('for-extensions', null, InputOption::VALUE_OPTIONAL, 'Filter by extension display names, comma-separated', '') ->addOption('for-libs', null, InputOption::VALUE_OPTIONAL, 'Filter by lib names (depends+suggests), comma-separated', '') ->addOption('os', null, InputOption::VALUE_OPTIONAL, 'Filter by OS (Linux/Darwin/Windows), comma-separated', '') + ->addOption('sapi', null, InputOption::VALUE_OPTIONAL, 'Add extra SAPI build tests, comma-separated (supported: frankenphp)', '') ->addOption('tier2', null, InputOption::VALUE_NONE, 'Use Tier 2 runners (Linux aarch64 + macOS x86_64, no Windows)'); } @@ -98,8 +105,15 @@ class GenExtTestMatrixCommand extends BaseCommand $filter_extensions = $parse_option('for-extensions'); $filter_libs = $parse_option('for-libs'); $filter_os_keys = $parse_option('os'); + $filter_sapis = array_unique($parse_option('sapi')); $tier2 = (bool) $this->input->getOption('tier2'); + $unknown_sapis = array_values(array_diff($filter_sapis, self::SUPPORTED_SAPIS)); + if (!empty($unknown_sapis)) { + $this->output->writeln('Unsupported SAPI(s): ' . implode(', ', $unknown_sapis) . ''); + return static::USER_ERROR; + } + $base_runners = $tier2 ? self::OS_RUNNERS_TIER2 : self::OS_RUNNERS; $all = PackageConfig::getAll(); @@ -126,6 +140,67 @@ class GenExtTestMatrixCommand extends BaseCommand } } + [$entries, $all_ext_lib_deps] = $this->buildEntriesForRunners( + $base_runners, + $filter_os_keys, + $all_regular, + $all_virtual, + $all_libraries, + self::BUILD_TARGETS, + 'default', + ); + + if (in_array('frankenphp', $filter_sapis, true)) { + [$frankenphp_entries, $frankenphp_ext_lib_deps] = $this->buildEntriesForRunners( + self::OS_RUNNERS, + [], + $all_regular, + $all_virtual, + $all_libraries, + self::FRANKENPHP_BUILD_TARGETS, + 'frankenphp', + ); + $entries = array_merge($entries, $frankenphp_entries); + $all_ext_lib_deps = array_replace_recursive($all_ext_lib_deps, $frankenphp_ext_lib_deps); + } + + if (!empty($filter_extensions) || !empty($filter_libs)) { + $entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions, $filter_libs, $all_ext_lib_deps): bool { + $names = explode(',', $entry['extension']); + + if (!empty($filter_extensions) && count(array_intersect($names, $filter_extensions)) > 0) { + return true; + } + + if (!empty($filter_libs)) { + $lib_deps = $all_ext_lib_deps[$entry['os']] ?? []; + foreach ($names as $name) { + if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) { + return true; + } + } + } + + return false; + })); + } + + $this->output->write(json_encode($entries, JSON_UNESCAPED_SLASHES)); + return static::SUCCESS; + } + + /** + * @return array{array>, array>} + */ + private function buildEntriesForRunners( + array $base_runners, + array $filter_os_keys, + array $all_regular, + array $all_virtual, + array $all_libraries, + string $build_targets, + string $sapi, + ): array { $os_runners = empty($filter_os_keys) ? $base_runners : array_filter($base_runners, fn ($info) => in_array($info['os_key'], $filter_os_keys, true)); @@ -237,40 +312,18 @@ class GenExtTestMatrixCommand extends BaseCommand sort($groups); foreach ($groups as $group) { - $extra = $this->extraBuildFlags($group); $entries[] = [ 'runner' => $os_info['runner'], 'os' => $os, 'arch' => $os_info['arch'], + 'sapi' => $sapi, 'extension' => $group, - 'build-args' => './bin/spc build "' . $group . '" ' . self::BUILD_TARGETS . ($extra !== '' ? ' ' . $extra : ''), + 'build-args' => './bin/spc build "' . $group . '" ' . $this->buildTargetsWithExtraFlags($build_targets, $group), ]; } } - if (!empty($filter_extensions) || !empty($filter_libs)) { - $entries = array_values(array_filter($entries, function (array $entry) use ($filter_extensions, $filter_libs, $all_ext_lib_deps): bool { - $names = explode(',', $entry['extension']); - - if (!empty($filter_extensions) && count(array_intersect($names, $filter_extensions)) > 0) { - return true; - } - - if (!empty($filter_libs)) { - $lib_deps = $all_ext_lib_deps[$entry['os']] ?? []; - foreach ($names as $name) { - if (count(array_intersect($lib_deps[$name] ?? [], $filter_libs)) > 0) { - return true; - } - } - } - - return false; - })); - } - - $this->output->write(json_encode($entries, JSON_UNESCAPED_SLASHES)); - return static::SUCCESS; + return [$entries, $all_ext_lib_deps]; } /** @@ -397,6 +450,18 @@ class GenExtTestMatrixCommand extends BaseCommand return implode(' ', $flags); } + private function buildTargetsWithExtraFlags(string $build_targets, string $group): string + { + $flags = explode(' ', $build_targets); + foreach (explode(' ', $this->extraBuildFlags($group)) as $extra) { + if ($extra === '' || in_array($extra, $flags, true)) { + continue; + } + $flags[] = $extra; + } + return implode(' ', $flags); + } + /** * Resolve the value of a platform-specific array field, applying the suffix fallback chain. * diff --git a/src/StaticPHP/Command/Dev/TestBotCommand.php b/src/StaticPHP/Command/Dev/TestBotCommand.php index 6ee1e1e3..72f882ec 100644 --- a/src/StaticPHP/Command/Dev/TestBotCommand.php +++ b/src/StaticPHP/Command/Dev/TestBotCommand.php @@ -25,6 +25,10 @@ class TestBotCommand extends BaseCommand private const string TIER2_LABEL = 'test/tier2'; + private const array SAPI_LABELS = [ + 'sapi/frankenphp' => 'frankenphp', + ]; + /** PHP version labels โ†’ version string (8.5 is always included as default) */ private const array PHP_VERSION_LABELS = [ 'test/php-83' => '8.3', @@ -93,6 +97,12 @@ class TestBotCommand extends BaseCommand } $tier2 = in_array(self::TIER2_LABEL, $label_names, true); $need_test = in_array('need-test', $label_names, true); + $sapis = []; + foreach (self::SAPI_LABELS as $label => $sapi) { + if (in_array($label, $label_names, true)) { + $sapis[] = $sapi; + } + } // Resolve PHP versions (default always included) $php_versions = [self::DEFAULT_PHP_VERSION]; @@ -119,6 +129,9 @@ class TestBotCommand extends BaseCommand if (!empty($os_keys)) { $flag_parts[] = '--os=' . implode(',', $os_keys); } + if (!empty($sapis)) { + $flag_parts[] = '--sapi=' . implode(',', $sapis); + } $gen_matrix_args = implode(' ', $flag_parts); if ($tier2) { @@ -128,7 +141,7 @@ class TestBotCommand extends BaseCommand fn ($k) => $k !== 'Windows' )); if (!empty($tier2_os)) { - $tier2_parts = array_values(array_filter($flag_parts, fn ($f) => !str_starts_with($f, '--os='))); + $tier2_parts = array_values(array_filter($flag_parts, fn ($f) => !str_starts_with($f, '--os=') && !str_starts_with($f, '--sapi='))); $tier2_parts[] = '--os=' . implode(',', $tier2_os); $tier2_parts[] = '--tier2'; $gen_matrix_args_tier2 = implode(' ', $tier2_parts); @@ -143,6 +156,7 @@ class TestBotCommand extends BaseCommand $label_names, $os_keys, $tier2, + $sapis, $php_versions, $need_test, ); @@ -156,6 +170,7 @@ class TestBotCommand extends BaseCommand 'gen_matrix_args_tier2' => $gen_matrix_args_tier2, 'php_versions' => $php_versions, 'tier2' => $tier2, + 'sapis' => $sapis, 'comment_body' => $comment_body, ]; @@ -239,6 +254,7 @@ class TestBotCommand extends BaseCommand array $label_names, array $os_keys, bool $tier2, + array $sapis, array $php_versions, bool $need_test, ): string { @@ -258,6 +274,7 @@ class TestBotCommand extends BaseCommand '`test/linux` `test/windows` `test/macos` (platform)', '`test/tier2` (extra arch)', '`test/php-83` `test/php-84` (PHP version)', + '`sapi/frankenphp` (extra FrankenPHP SAPI build)', ]); // Case 1: need-test absent โ†’ invite the author to add it @@ -303,7 +320,8 @@ class TestBotCommand extends BaseCommand } $php_str = implode(', ', array_map(fn ($v) => "PHP {$v}", $php_versions)) . ' NTS'; - $active_test_labels = array_values(array_filter($label_names, fn ($l) => str_starts_with($l, 'test/'))); + $sapi_str = !empty($sapis) ? ' + SAPI: ' . implode(', ', $sapis) . ' (Tier1 Linux/macOS/Windows)' : ''; + $active_test_labels = array_values(array_filter($label_names, fn ($l) => str_starts_with($l, 'test/') || str_starts_with($l, 'sapi/'))); $labels_str = !empty($active_test_labels) ? '`' . implode('`, `', $active_test_labels) . '`' : '_none_'; return implode("\n", [ @@ -313,7 +331,7 @@ class TestBotCommand extends BaseCommand $detected, '**Active labels**: ' . $labels_str, '**Available labels**: ' . $available_labels, - '**Config**: ' . implode(' + ', $platform_parts) . ' | ' . $php_str, + '**Config**: ' . implode(' + ', $platform_parts) . ' | ' . $php_str . $sapi_str, ]); } } diff --git a/src/StaticPHP/Doctor/Item/WindowsToolCheck.php b/src/StaticPHP/Doctor/Item/WindowsToolCheck.php index 3fe1d312..b802790d 100644 --- a/src/StaticPHP/Doctor/Item/WindowsToolCheck.php +++ b/src/StaticPHP/Doctor/Item/WindowsToolCheck.php @@ -12,7 +12,6 @@ use StaticPHP\Package\PackageInstaller; use StaticPHP\Runtime\SystemTarget; use StaticPHP\Toolchain\ToolchainManager; use StaticPHP\Util\FileSystem; -use StaticPHP\Util\GlobalEnvManager; use StaticPHP\Util\System\WindowsUtil; #[OptionalCheck([self::class, 'optional'])] @@ -74,27 +73,6 @@ class WindowsToolCheck return CheckResult::ok($path); } - #[CheckItem('if nasm installed', level: 995)] - public function checkNasm(): ?CheckResult - { - if (($a = WindowsUtil::findCommand('nasm.exe')) === null) { - return CheckResult::fail('nasm.exe not found in path.', 'install-nasm'); - } - return CheckResult::ok($a); - } - - #[CheckItem('if perl(strawberry) installed', limit_os: 'Windows', level: 994)] - public function checkPerl(): ?CheckResult - { - if (($path = WindowsUtil::findCommand('perl.exe')) === null) { - return CheckResult::fail('perl not found in path.', 'install-perl'); - } - if (!str_contains(implode('', cmd()->execWithResult(quote($path) . ' -v', false)[1]), 'MSWin32')) { - return CheckResult::fail($path . ' is not built for msvc.', 'install-perl'); - } - return CheckResult::ok($path); - } - #[CheckItem('if environment is properly set up', level: 1)] public function checkenv(): ?CheckResult { @@ -113,16 +91,6 @@ class WindowsToolCheck return CheckResult::ok(); } - #[FixItem('install-perl')] - public function installPerl(): bool - { - $installer = new PackageInstaller(); - $installer->addInstallPackage('strawberry-perl'); - $installer->run(true); - GlobalEnvManager::addPathIfNotExists(PKG_ROOT_PATH . '\strawberry-perl'); - return true; - } - #[FixItem('install-msys2-build-essentials')] public function installMsys2(): bool { @@ -141,15 +109,6 @@ class WindowsToolCheck return true; } - #[FixItem('install-nasm')] - public function installNasm(): bool - { - $installer = new PackageInstaller(interactive: false); - $installer->addInstallPackage('nasm'); - $installer->run(true); - return true; - } - #[FixItem('install-vswhere')] public function installVSWhere(): bool { diff --git a/src/StaticPHP/Exception/ExceptionHandler.php b/src/StaticPHP/Exception/ExceptionHandler.php index 2d8c404d..0883b0a8 100644 --- a/src/StaticPHP/Exception/ExceptionHandler.php +++ b/src/StaticPHP/Exception/ExceptionHandler.php @@ -111,7 +111,7 @@ class ExceptionHandler private static function logError($message, int $indent_space = 0, bool $output_log = true, string $color = 'red'): void { - $spc_log = fopen(SPC_OUTPUT_LOG, 'a'); + $spc_log = spc_log_stream(SPC_OUTPUT_LOG); $msg = explode("\n", (string) $message); foreach ($msg as $v) { $line = str_pad($v, strlen($v) + $indent_space, ' ', STR_PAD_LEFT); diff --git a/src/StaticPHP/Runtime/Shell/DefaultShell.php b/src/StaticPHP/Runtime/Shell/DefaultShell.php index 2761f32f..a2d1b382 100644 --- a/src/StaticPHP/Runtime/Shell/DefaultShell.php +++ b/src/StaticPHP/Runtime/Shell/DefaultShell.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace StaticPHP\Runtime\Shell; +use StaticPHP\Exception\ExecutionException; use StaticPHP\Exception\InterruptException; use StaticPHP\Exception\SPCInternalException; use StaticPHP\Runtime\SystemTarget; @@ -153,7 +154,7 @@ class DefaultShell extends Shell $this->logCommandInfo($cmd); logger()->debug("[TAR EXTRACT] {$cmd}"); - $this->passthru($cmd, $this->console_putput); + $this->passthruTolerateSymlinks($cmd); return true; } @@ -200,7 +201,7 @@ class DefaultShell extends Shell $run = function ($cmd) { $this->logCommandInfo($cmd); logger()->debug("[7Z EXTRACT] {$cmd}"); - $this->passthru($cmd, $this->console_putput); + $this->passthruTolerateSymlinks($cmd); }; $extname = FileSystem::extname($archive_path); @@ -214,4 +215,67 @@ class DefaultShell extends Shell return true; } + + /** + * Run an extraction command, tolerating symbolic links that the host cannot create. + * + * Windows tar.exe (bsdtar) cannot create the symbolic links some archives ship (e.g. zstd's + * tests/cli-tests/bin/unzstd -> zstd), failing each with "Can't create '...': Invalid argument" + * and exiting non-zero. Those entries are never needed to build, so on Windows we swallow a + * failure whose only errors are such symlink creations and continue. Any other failure still throws. + */ + private function passthruTolerateSymlinks(string $cmd): void + { + // Symlink creation only fails on a Windows host; elsewhere extraction handles symlinks fine. + if (PHP_OS_FAMILY !== 'Windows') { + $this->passthru($cmd, $this->console_putput); + return; + } + + $result = $this->passthru($cmd, $this->console_putput, capture_output: true, throw_on_error: false); + if ($result['code'] === 0) { + return; + } + if ($this->isSymlinkOnlyExtractFailure($result['output'])) { + logger()->warning('Some symbolic links could not be created during extraction and were skipped (not supported on this Windows host). This is harmless for building.'); + return; + } + throw new ExecutionException( + cmd: $cmd, + message: "Command exited with non-zero code: {$result['code']}", + code: $result['code'], + cd: $this->cd, + env: $this->env, + ); + } + + /** + * Decide whether an extraction failure was caused solely by symbolic links that could not be + * created on Windows. Returns true only when at least one such error is present and no other + * error-looking output is found, so genuine extraction failures still propagate. + */ + private function isSymlinkOnlyExtractFailure(string $output): bool + { + $saw_symlink_error = false; + foreach (preg_split('/\r\n|\r|\n/', $output) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + // bsdtar's trailing summary line; not an error on its own. + if (str_contains($line, 'Error exit delayed from previous errors')) { + continue; + } + // The symlink (or other unsupported special file) that Windows refused to create. + if (str_contains($line, "Can't create") && str_contains($line, 'Invalid argument')) { + $saw_symlink_error = true; + continue; + } + // Any other error-looking line means this was not a clean symlink-only failure. + if (preg_match('/\berror\b|cannot|can\'t|failed|denied|no space|not permitted/i', $line)) { + return false; + } + } + return $saw_symlink_error; + } } diff --git a/src/StaticPHP/Runtime/Shell/Shell.php b/src/StaticPHP/Runtime/Shell/Shell.php index 37601997..0801e50a 100644 --- a/src/StaticPHP/Runtime/Shell/Shell.php +++ b/src/StaticPHP/Runtime/Shell/Shell.php @@ -114,8 +114,8 @@ abstract class Shell if (!$this->enable_log_file) { return; } - // write executed command to log file using spc_write_log - $log_file = fopen(SPC_SHELL_LOG, 'a'); + // write executed command to log file using spc_write_log (shared handle, see spc_log_stream) + $log_file = spc_log_stream(SPC_SHELL_LOG); spc_write_log($log_file, "\n>>>>>>>>>>>>>>>>>>>>>>>>>> [" . date('Y-m-d H:i:s') . "]\n"); spc_write_log($log_file, "> Executing command: {$cmd}\n"); // get the backtrace to find the file and line number @@ -154,8 +154,8 @@ abstract class Shell ): array { $file_res = null; if ($this->enable_log_file) { - // write executed command to the log file using spc_write_log - $file_res = fopen(SPC_SHELL_LOG, 'a'); + // write executed command to the log file using spc_write_log (shared handle, see spc_log_stream) + $file_res = spc_log_stream(SPC_SHELL_LOG); } if ($console_output) { $console_res = STDOUT; @@ -263,9 +263,7 @@ abstract class Shell } fclose($pipes[1]); fclose($pipes[2]); - if ($file_res !== null) { - fclose($file_res); - } + // $file_res is a shared, process-wide handle (see spc_log_stream); do not close it here. proc_close($process); } } diff --git a/src/StaticPHP/Util/FileSystem.php b/src/StaticPHP/Util/FileSystem.php index 3bf12f99..61f2d726 100644 --- a/src/StaticPHP/Util/FileSystem.php +++ b/src/StaticPHP/Util/FileSystem.php @@ -322,7 +322,16 @@ class FileSystem } $sub_file = self::convertPath($dir . '/' . $v); if (is_link($sub_file) || is_file($sub_file)) { - if (!unlink($sub_file)) { + if (!@unlink($sub_file)) { + $last_error = error_get_last(); + logger()->debug("unlink failed for '{$sub_file}': " . ($last_error['message'] ?? 'unknown error') . ', attempting chmod + unlink'); + + // Go module cache files are often read-only; make writable and retry + if (@chmod($sub_file, 0666) && @unlink($sub_file)) { + continue; + } + + // Shell fallback: del /f /q forces deletion of read-only files on Windows $cmd = PHP_OS_FAMILY === 'Windows' ? 'del /f /q' : 'rm -f'; f_exec("{$cmd} " . escapeshellarg($sub_file), $out, $ret); if ($ret !== 0) { diff --git a/src/bootstrap.php b/src/bootstrap.php index f7875e0c..f5e0aba7 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -66,11 +66,10 @@ if (filter_var(getenv('SPC_ENABLE_LOG_FILE'), FILTER_VALIDATE_BOOLEAN)) { } } - $log_file_fd = fopen(SPC_OUTPUT_LOG, 'a'); - $ob_logger->addLogCallback(function ($level, $output) use ($log_file_fd) { - if ($log_file_fd) { - spc_write_log($log_file_fd, strip_ansi_colors($output) . "\n"); - } + // Use a single shared handle (see spc_log_stream) so the file is opened exactly once; + // on Windows a second concurrent open fails while child processes hold an inherited handle. + $ob_logger->addLogCallback(function ($level, $output) { + spc_write_log(spc_log_stream(SPC_OUTPUT_LOG), strip_ansi_colors($output) . "\n"); return true; }); } diff --git a/src/globals/functions.php b/src/globals/functions.php index c824bd3d..dd00f0e8 100644 --- a/src/globals/functions.php +++ b/src/globals/functions.php @@ -137,6 +137,11 @@ function spc_add_log_filter(array|string $filter): void function spc_write_log(mixed $stream, string $data): false|int { + // Defensive: a log stream may be false/null when its file could not be opened + // (e.g. transient sharing violations on Windows). Never let logging crash the run. + if (!is_resource($stream)) { + return false; + } // get filter global $spc_log_filters; if (is_array($spc_log_filters)) { @@ -145,6 +150,29 @@ function spc_write_log(mixed $stream, string $data): false|int return fwrite($stream, $data); } +/** + * Return a single, process-wide shared append handle for the given log file. + * + * The handle is opened lazily once and reused for the lifetime of the process. This is + * important on Windows: every time a log file is opened with fopen() its handle is inherited + * by child processes spawned via proc_open() (curl, git, tar, ...). While such a child is + * alive it keeps the file open, and any *additional* open of the same file fails with a + * sharing violation ("The process cannot access the file because it is being used by another + * process."). During parallel downloads many children run at once, so opening a fresh handle + * per log line crashes. Keeping exactly one handle per file means there is never a second open + * to violate. Returns null when the file cannot be opened. + * + * @internal + */ +function spc_log_stream(string $file): mixed +{ + static $streams = []; + if (!isset($streams[$file]) || !is_resource($streams[$file])) { + $streams[$file] = @fopen($file, 'a') ?: null; + } + return $streams[$file]; +} + // ------- function f_* part ------- // f_ means standard function wrapper