diff --git a/package.php b/package.php old mode 100644 new mode 100755 diff --git a/phpunit/src/Build/PrecompiledHeaderManagerTest.php b/phpunit/src/Build/PrecompiledHeaderManagerTest.php new file mode 100644 index 00000000..72df23b3 --- /dev/null +++ b/phpunit/src/Build/PrecompiledHeaderManagerTest.php @@ -0,0 +1,88 @@ +cacheDirectory = sys_get_temp_dir() . '/typephp_pch_' . bin2hex(random_bytes(8)); + mkdir($this->cacheDirectory, 0777, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->cacheDirectory); + } + + public function testPreparePrunesExpiredAndExcessCacheEntries(): void + { + $oldDirectory = $this->createCacheEntry(1, time() - 31 * 86400); + for ($i = 2; $i <= 10; $i++) { + $this->createCacheEntry($i, time() - (10 - $i)); + } + $unmanagedDirectory = $this->cacheDirectory . '/keep-me'; + mkdir($unmanagedDirectory); + + $backend = $this->createMock(CompilerBackend::class); + $backend->method('supportsPrecompiledHeaders')->willReturn(true); + $backend->method('getName')->willReturn('test'); + $backend->method('getCompilerCommand')->willReturn('true'); + $backend->method('getPrecompiledHeaderArtifact') + ->willReturnCallback(static fn(string $header): string => $header . '.gch'); + $backend->method('buildNativeCompileCommand') + ->willReturnCallback( + static fn(string $source, string $object): string => 'touch ' . escapeshellarg($object), + ); + + $result = (new PrecompiledHeaderManager($backend, new NativeBuilder($backend)))->prepare( + ['cstring', 'phpx.h'], + [], + $this->cacheDirectory, + new CompileOptions([]), + ); + + $this->assertFileExists($result['artifact']); + $this->assertStringContainsString("#include \n#include \n", file_get_contents($result['header'])); + $this->assertDirectoryDoesNotExist($oldDirectory); + $this->assertDirectoryExists($unmanagedDirectory); + + $managedEntries = array_filter( + scandir($this->cacheDirectory), + static fn(string $entry): bool => preg_match('/^[a-f0-9]{24}$/D', $entry) === 1, + ); + $this->assertCount(8, $managedEntries); + } + + private function createCacheEntry(int $number, int $mtime): string + { + $directory = $this->cacheDirectory . '/' . sprintf('%024x', $number); + mkdir($directory); + file_put_contents($directory . '/typephp_pch.hpp.gch', 'cached'); + touch($directory, $mtime); + return $directory; + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($directory); + } +} diff --git a/src/Build/PrecompiledHeaderManager.php b/src/Build/PrecompiledHeaderManager.php index 53d00f04..21549ac6 100644 --- a/src/Build/PrecompiledHeaderManager.php +++ b/src/Build/PrecompiledHeaderManager.php @@ -6,6 +6,9 @@ use TypePhp\Backend\CompilerBackend; final readonly class PrecompiledHeaderManager { + private const int MAX_CACHE_ENTRIES = 8; + private const int MAX_CACHE_AGE = 30 * 86400; + public function __construct( private CompilerBackend $backend, private NativeBuilder $builder, @@ -46,6 +49,7 @@ final readonly class PrecompiledHeaderManager } if (is_file($artifact)) { + $this->markUsedAndPrune($cacheDirectory, $directory); return ['header' => $headerFile, 'artifact' => $artifact, 'cached' => true, 'command' => '']; } @@ -55,9 +59,62 @@ final readonly class PrecompiledHeaderManager 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 { diff --git a/src/Translator.php b/src/Translator.php index 861279a9..52b80885 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -77,6 +77,7 @@ class Translator extends Preprocessor protected array $resourceConfig = []; protected bool $useRegisterSymbolsFn = false; protected array $globalHeaders = [ + 'cstring', 'phpx.h', 'phpx_helper.h', 'phpx_big_int.h', @@ -746,8 +747,7 @@ class Translator extends Preprocessor $this->genClassCeList(); $this->indentLevel++; - $code = '#include ' . PHP_EOL; - $code .= $this->genIncludeHeaderFiles(); + $code = $this->genIncludeHeaderFiles(); if ($this->isBuildModeLib() && !$this->isWindows()) { // PHPX's embedded runtime references this CLI-only symbol even when main() is disabled. diff --git a/version.txt b/version.txt index 8b37c8cf..dfc8356d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1097 \ No newline at end of file +1098 \ No newline at end of file