$headers * @param list $dependencyDirectories * @return array{header: string, artifact: string, cached: bool, command: string} */ public function prepare( array $headers, array $dependencyDirectories, string $cacheDirectory, CompileOptions $options, ): array { if (!$this->backend->supportsPrecompiledHeaders()) { throw new \LogicException($this->backend->getName() . ' does not support precompiled headers'); } $fingerprint = $this->buildFingerprint($headers, $dependencyDirectories, $options, $cacheDirectory); $directory = rtrim($cacheDirectory, '/\\') . DIRECTORY_SEPARATOR . $fingerprint; if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { throw new \RuntimeException('Cannot create precompiled header cache directory: ' . $directory); } $headerFile = $directory . DIRECTORY_SEPARATOR . 'typephp_pch.hpp'; $artifact = $this->backend->getPrecompiledHeaderArtifact($headerFile); $source = "#pragma once\n"; foreach ($headers as $header) { $source .= '#include <' . $header . ">\n"; } if (!is_file($headerFile) || file_get_contents($headerFile) !== $source) { if (file_put_contents($headerFile, $source) === false) { throw new \RuntimeException('Cannot write precompiled header: ' . $headerFile); } } if (is_file($artifact)) { $this->markUsedAndPrune($cacheDirectory, $directory); return ['header' => $headerFile, 'artifact' => $artifact, 'cached' => true, 'command' => '']; } $result = $this->builder->compile($headerFile, $artifact, $options, 'c++-header', true); if ($result['status'] !== 0 || !is_file($artifact)) { $message = implode(PHP_EOL, $result['output']); throw new \RuntimeException('Failed to build PHPX precompiled header' . ($message === '' ? '' : ': ' . $message)); } $this->markUsedAndPrune($cacheDirectory, $directory); return ['header' => $headerFile, 'artifact' => $artifact, 'cached' => false, 'command' => $result['command']]; } private function markUsedAndPrune(string $cacheDirectory, string $currentDirectory): void { // Cache cleanup is best-effort and must never disable an otherwise // usable PCH merely because an old entry cannot be removed. @touch($currentDirectory); try { $entries = $this->getCacheEntries($cacheDirectory, $currentDirectory); $cutoff = time() - self::MAX_CACHE_AGE; foreach ($entries as $index => $entry) { if ($entry['mtime'] < $cutoff || $index >= self::MAX_CACHE_ENTRIES - 1) { $this->removeCacheDirectory($entry['path']); } } } catch (\Throwable) { // Ignore cleanup errors; the active artifact is already valid. } } /** @return list */ private function getCacheEntries(string $cacheDirectory, string $currentDirectory): array { $entries = []; $iterator = new \FilesystemIterator($cacheDirectory, \FilesystemIterator::SKIP_DOTS); foreach ($iterator as $entry) { $path = $entry->getPathname(); if (!$entry->isDir() || $entry->isLink() || $path === $currentDirectory || preg_match('/^[a-f0-9]{24}$/D', $entry->getFilename()) !== 1) { continue; } $entries[] = ['path' => $path, 'mtime' => $entry->getMTime()]; } usort($entries, static fn(array $a, array $b): int => $b['mtime'] <=> $a['mtime']); return $entries; } private function removeCacheDirectory(string $directory): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($iterator as $entry) { if ($entry->isDir() && !$entry->isLink()) { @rmdir($entry->getPathname()); } else { @unlink($entry->getPathname()); } } @rmdir($directory); } /** @param list $headers @param list $dependencyDirectories */ private function buildFingerprint(array $headers, array $dependencyDirectories, CompileOptions $options, string $cacheDirectory): string { $compilerVersion = []; exec(escapeshellcmd($this->backend->getCompilerCommand()) . ' --version 2>&1', $compilerVersion); $context = hash_init('sha256'); hash_update($context, $this->backend::class . "\0" . implode("\n", $compilerVersion) . "\0"); $optionValues = $options->toArray(); // prof_output only affects code when profiling is enabled. Keeping a // target-specific inactive filename here would defeat PCH reuse across // projects with otherwise identical native build configurations. if (empty($optionValues['enable_profiler'])) { unset($optionValues['prof_output']); } unset($optionValues['precompiled_header']); hash_update($context, serialize($optionValues) . "\0" . implode("\0", $headers)); $files = []; foreach ($dependencyDirectories as $directory) { if (!is_dir($directory)) { continue; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile() || preg_match('/\.(?:h|hh|hpp|hxx|inc)$/i', $file->getFilename()) !== 1) { continue; } $files[] = $file->getPathname(); } } sort($files, SORT_STRING); $digestFile = rtrim($cacheDirectory, '/\\') . '/dependency-digests.json'; $saved = is_file($digestFile) ? json_decode((string) file_get_contents($digestFile), true) : []; $digests = []; // Windows ctime can mean creation time rather than metadata-change // time, so it cannot safely detect in-place mtime-preserving rewrites. $strict = PHP_OS_FAMILY === 'Windows' || getenv('TYPEPHP_STRICT_CACHE') === '1'; foreach ($files as $file) { clearstatcache(true, $file); $metadata = stat($file); if ($metadata === false) { throw new \RuntimeException('Cannot stat precompiled-header dependency: ' . $file); } // Clang records dependency mtimes in a PCH and rejects the PCH even // when a rewritten header still has identical contents. Include // the metadata as well as the content so such SDK refreshes select // a new cache entry before native compilation begins. hash_update($context, $file . "\0" . $metadata['size'] . "\0" . $metadata['mtime'] . "\0"); $signature = [$metadata['dev'], $metadata['ino'], $metadata['size'], $metadata['mtime'], $metadata['ctime']]; $entry = is_array($saved) ? ($saved[$file] ?? null) : null; // Like mature timestamp-based build tools, reuse digests for stable // SDK files. ctime detects rewrites preserving mtime; always read // recent files to close PHP stat()'s same-second timestamp window. $digest = !$strict && max($metadata['mtime'], $metadata['ctime']) < time() - 2 && is_array($entry) && ($entry['signature'] ?? null) === $signature && is_string($entry['digest'] ?? null) && preg_match('/^[a-f0-9]{64}$/D', $entry['digest']) === 1 ? $entry['digest'] : hash_file('sha256', $file); if (!is_string($digest)) { throw new \RuntimeException('Cannot fingerprint precompiled-header dependency: ' . $file); } $digests[$file] = ['signature' => $signature, 'digest' => $digest]; hash_update($context, $digest . "\0"); } if ($digests !== $saved && is_dir($cacheDirectory)) { $temporary = tempnam($cacheDirectory, '.pch-digests-'); if ($temporary !== false) { try { if (file_put_contents($temporary, json_encode($digests, JSON_THROW_ON_ERROR)) !== false) { @rename($temporary, $digestFile); } } finally { @unlink($temporary); } } } return substr(hash_final($context), 0, 24); } }