feat(build): add cache management for precompiled headers with automated pruning

- Implement cache size limit of 8 entries and age limit of 30 days
- Add markUsedAndPrune method to handle cache cleanup when accessing entries
- Create getCacheEntries method to retrieve and sort cache entries by modification time
- Implement removeCacheDirectory method for recursive directory deletion
- Add unit tests covering expired and excess cache entry pruning scenarios
- Include cstring header in global headers and remove duplicate inclusion in generated code
- Update version from 1097 to 1098
pull/43/head
韩天峰 4 weeks ago
parent 21291453f8
commit 682b17f73f
  1. 0
      package.php
  2. 88
      phpunit/src/Build/PrecompiledHeaderManagerTest.php
  3. 57
      src/Build/PrecompiledHeaderManager.php
  4. 4
      src/Translator.php
  5. 2
      version.txt

@ -0,0 +1,88 @@
<?php
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Build\CompileOptions;
use TypePhp\Build\NativeBuilder;
use TypePhp\Build\PrecompiledHeaderManager;
final class PrecompiledHeaderManagerTest extends TestCase
{
private string $cacheDirectory;
protected function setUp(): void
{
$this->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 <cstring>\n#include <phpx.h>\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);
}
}

@ -6,6 +6,9 @@ use TypePhp\Backend\CompilerBackend;
final readonly class PrecompiledHeaderManager final readonly class PrecompiledHeaderManager
{ {
private const int MAX_CACHE_ENTRIES = 8;
private const int MAX_CACHE_AGE = 30 * 86400;
public function __construct( public function __construct(
private CompilerBackend $backend, private CompilerBackend $backend,
private NativeBuilder $builder, private NativeBuilder $builder,
@ -46,6 +49,7 @@ final readonly class PrecompiledHeaderManager
} }
if (is_file($artifact)) { if (is_file($artifact)) {
$this->markUsedAndPrune($cacheDirectory, $directory);
return ['header' => $headerFile, 'artifact' => $artifact, 'cached' => true, 'command' => '']; 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)); 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']]; 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<array{path: string, mtime: int}> */
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<string> $headers @param list<string> $dependencyDirectories */ /** @param list<string> $headers @param list<string> $dependencyDirectories */
private function buildFingerprint(array $headers, array $dependencyDirectories, CompileOptions $options): string private function buildFingerprint(array $headers, array $dependencyDirectories, CompileOptions $options): string
{ {

@ -77,6 +77,7 @@ class Translator extends Preprocessor
protected array $resourceConfig = []; protected array $resourceConfig = [];
protected bool $useRegisterSymbolsFn = false; protected bool $useRegisterSymbolsFn = false;
protected array $globalHeaders = [ protected array $globalHeaders = [
'cstring',
'phpx.h', 'phpx.h',
'phpx_helper.h', 'phpx_helper.h',
'phpx_big_int.h', 'phpx_big_int.h',
@ -746,8 +747,7 @@ class Translator extends Preprocessor
$this->genClassCeList(); $this->genClassCeList();
$this->indentLevel++; $this->indentLevel++;
$code = '#include <cstring>' . PHP_EOL; $code = $this->genIncludeHeaderFiles();
$code .= $this->genIncludeHeaderFiles();
if ($this->isBuildModeLib() && !$this->isWindows()) { if ($this->isBuildModeLib() && !$this->isWindows()) {
// PHPX's embedded runtime references this CLI-only symbol even when main() is disabled. // PHPX's embedded runtime references this CLI-only symbol even when main() is disabled.

@ -1 +1 @@
1097 1098
Loading…
Cancel
Save