Skip to content

Commit 7df53bb

Browse files
authored
Optimize stream wrapper performance (#41)
* Enhance PathMatcher and StreamWrapper with caching improvements; add tests for new functionality in PathMatcher and StreamWrapper * Remove isReadOnlyCall method from StreamWrapper to streamline code * Add read-only function detection to StreamWrapper for optimized handling * Fix missing newline at end of file in StreamWrapper and PathMatcherTest * Refactor PathMatcherTest and StreamWrapperTest to ensure Config resets after each test case
1 parent 571f4d8 commit 7df53bb

4 files changed

Lines changed: 485 additions & 77 deletions

File tree

src/Internal/PathMatcher.php

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ final class PathMatcher
2323
*/
2424
private static array $compiledExcludesCache = [];
2525

26+
/**
27+
* Cache for include prefix lookup decisions.
28+
*
29+
* @var array<string, bool>
30+
*/
31+
private static array $includePrefixCache = [];
32+
2633
/**
2734
* Cached normalized cache directory path.
2835
*/
@@ -40,6 +47,7 @@ public static function reset(): void
4047
{
4148
self::$compiledIncludesCache = [];
4249
self::$compiledExcludesCache = [];
50+
self::$includePrefixCache = [];
4351
self::$cachedCacheDir = null;
4452
self::$cachedLibSrcDir = null;
4553
}
@@ -82,8 +90,6 @@ public static function isCachePath(string $normalizedPath): bool
8290

8391
/**
8492
* Determines whether a path belongs to TypePHP's own internal engine source files.
85-
* In vendor mode: skips the entire library package.
86-
* In development mode: skips only actual internal subdirectories, allowing test fixtures to be tested.
8793
*/
8894
public static function isLibraryInternal(string $normalizedPath): bool
8995
{
@@ -125,6 +131,80 @@ public static function isLibraryInternal(string $normalizedPath): bool
125131
return false;
126132
}
127133

134+
/**
135+
* Fast-checks if an include glob list contains any pattern matching a given prefix.
136+
*
137+
* @param array<int, string> $includes
138+
*/
139+
public static function hasIncludeMatchingPrefix(string $prefix, array $includes): bool
140+
{
141+
$cacheKey = $prefix . '|' . implode(',', $includes);
142+
if (isset(self::$includePrefixCache[$cacheKey])) {
143+
return self::$includePrefixCache[$cacheKey];
144+
}
145+
146+
foreach ($includes as $inc) {
147+
if (\is_string($inc)) {
148+
$norm = str_replace('\\', '/', trim($inc));
149+
if (str_starts_with($norm, $prefix) || str_contains($norm, '/' . $prefix)) {
150+
return self::$includePrefixCache[$cacheKey] = true;
151+
}
152+
}
153+
}
154+
155+
return self::$includePrefixCache[$cacheKey] = false;
156+
}
157+
158+
/**
159+
* Determines whether a directory path is a dynamic writable cache/log directory.
160+
*/
161+
public static function isDynamicWritablePath(string $normalizedPath): bool
162+
{
163+
return str_contains($normalizedPath, '/var/cache/') || str_starts_with($normalizedPath, 'var/cache/')
164+
|| str_contains($normalizedPath, '/var/log/') || str_starts_with($normalizedPath, 'var/log/')
165+
|| str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')
166+
|| str_contains($normalizedPath, '/cache/') || str_starts_with($normalizedPath, 'cache/');
167+
}
168+
169+
/**
170+
* High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included,
171+
* while respecting user whitelists for vendor, var, and storage directories.
172+
*/
173+
public static function mayPathBeIncluded(string $normalizedPath): bool
174+
{
175+
if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) {
176+
return false;
177+
}
178+
179+
if (self::isCachePath($normalizedPath)) {
180+
return false;
181+
}
182+
183+
$config = Config::get();
184+
/** @var array<int, string> $includes */
185+
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];
186+
187+
if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) {
188+
if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) {
189+
return false;
190+
}
191+
}
192+
193+
if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) {
194+
if (! self::hasIncludeMatchingPrefix('var/', $includes)) {
195+
return false;
196+
}
197+
}
198+
199+
if (str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')) {
200+
if (! self::hasIncludeMatchingPrefix('storage/', $includes)) {
201+
return false;
202+
}
203+
}
204+
205+
return true;
206+
}
207+
128208
/**
129209
* Converts a glob pattern into an absolute anchored regex pattern.
130210
*/

src/Internal/StreamWrapper.php

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,42 @@ final class StreamWrapper implements StreamWrapperInterface
4848
*/
4949
private static array $statCache = [];
5050

51+
/**
52+
* In-memory cache for static-path negative misses only (e.g. vendor).
53+
* Never stores negative misses for dynamic paths (var/cache, storage).
54+
*
55+
* @var array<string, true>
56+
*/
57+
private static array $staticNegativeStatCache = [];
58+
5159
/**
5260
* In-memory cache for isApplicationFile path decisions.
5361
*
5462
* @var array<string, bool>
5563
*/
5664
private static array $appFileDecisionCache = [];
5765

66+
/**
67+
* Fast-lookup table for read-only source view functions.
68+
*
69+
* @var array<string, true>
70+
*/
71+
private const READ_ONLY_FUNCTIONS = [
72+
'file_get_contents' => true,
73+
'file' => true,
74+
'readfile' => true,
75+
'highlight_file' => true,
76+
'show_source' => true,
77+
'token_get_all' => true,
78+
];
79+
5880
/**
5981
* Resets all internal caches.
6082
*/
6183
public static function reset(): void
6284
{
6385
self::$statCache = [];
86+
self::$staticNegativeStatCache = [];
6487
self::$appFileDecisionCache = [];
6588
PathMatcher::reset();
6689
}
@@ -171,26 +194,47 @@ public static function transformSource(string $source, string $filePath = ''): s
171194
*/
172195
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
173196
{
174-
// Fast-path: non-PHP files are never transformed
197+
if ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') {
198+
return $this->openDirectHandle($path, $mode);
199+
}
200+
175201
if (! str_ends_with(strtolower($path), '.php')) {
176202
return $this->openDirectHandle($path, $mode);
177203
}
178204

179-
// Fast-path: unwhitelisted vendor files are never transformed
205+
if (! Config::isEnabled()) {
206+
return $this->openDirectHandle($path, $mode);
207+
}
208+
180209
$normalizedRaw = str_replace('\\', '/', $path);
181-
if (str_contains($normalizedRaw, '/vendor/') || str_starts_with($normalizedRaw, 'vendor/')) {
210+
211+
if (! PathMatcher::mayPathBeIncluded($normalizedRaw)) {
182212
return $this->openDirectHandle($path, $mode);
183213
}
184214

215+
if (isset(self::$appFileDecisionCache[$normalizedRaw])) {
216+
if (! self::$appFileDecisionCache[$normalizedRaw]) {
217+
return $this->openDirectHandle($path, $mode);
218+
}
219+
}
220+
185221
self::unregister();
186-
$exists = self::silent(fn () => file_exists($path));
187-
$resolvedPath = $exists ? realpath($path) : '';
222+
$exists = (bool) self::silent(fn () => file_exists($path));
223+
$resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false;
188224
self::register();
189225

190-
$isAppFile = $exists && ! self::isReadOnlyCall() && self::isApplicationFile($path, $resolvedPath);
226+
if (! $exists || $resolvedPath === false) {
227+
return $this->openDirectHandle($path, $mode);
228+
}
229+
230+
$normalizedResolved = str_replace('\\', '/', $resolvedPath);
231+
232+
if (! self::isApplicationFile($path, $resolvedPath)) {
233+
return $this->openDirectHandle($normalizedResolved, $mode);
234+
}
191235

192-
if (! $isAppFile || $resolvedPath === false) {
193-
return $this->openDirectHandle(($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path, $mode);
236+
if (self::isReadOnlyCall()) {
237+
return $this->openDirectHandle($normalizedResolved, $mode);
194238
}
195239

196240
self::unregister();
@@ -204,6 +248,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
204248
return $success;
205249
}
206250

251+
/**
252+
* Determines if the stream_open call is for reading raw file contents/snippets
253+
* (e.g. error screen renderers) rather than PHP engine execution.
254+
*/
255+
private static function isReadOnlyCall(): bool
256+
{
257+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
258+
259+
$caller1 = strtolower($trace[1]['function'] ?? '');
260+
$caller2 = strtolower($trace[2]['function'] ?? '');
261+
262+
return isset(self::READ_ONLY_FUNCTIONS[$caller1]) || isset(self::READ_ONLY_FUNCTIONS[$caller2]);
263+
}
264+
207265
/**
208266
* Opens a raw file handle directly without stream interception.
209267
*/
@@ -338,25 +396,36 @@ public function stream_close(): void
338396

339397
/**
340398
* High-speed stat resolution with $O(1)$ memoization cache.
341-
* Never caches false (negative lookups) so newly created directories and files are immediately discovered.
399+
* Caches positive stat hits.
400+
* Only caches negative misses for STATIC directories (vendor, tests).
401+
* NEVER caches negative misses for dynamic writable paths (var/cache, storage),
402+
* guaranteeing Symfony/Shopware cache creation is detected immediately.
342403
*
343404
* @return array<int|string, int>|false
344405
*/
345406
public function url_stat(string $path, int $flags): array|false
346407
{
347408
$normalized = str_replace('\\', '/', $path);
409+
348410
if (isset(self::$statCache[$normalized])) {
349411
return self::$statCache[$normalized];
350412
}
351413

414+
if (isset(self::$staticNegativeStatCache[$normalized])) {
415+
return false;
416+
}
417+
352418
self::unregister();
353419
/** @var array<int|string, int>|false $result */
354420
$result = self::silent(fn () => stat($path));
355421
self::register();
356422

357-
// Only cache positive results (existing files/dirs)
358423
if ($result !== false) {
359424
self::$statCache[$normalized] = $result;
425+
} else {
426+
if (! PathMatcher::isDynamicWritablePath($normalized)) {
427+
self::$staticNegativeStatCache[$normalized] = true;
428+
}
360429
}
361430

362431
return $result;
@@ -365,7 +434,7 @@ public function url_stat(string $path, int $flags): array|false
365434
public function stream_metadata(string $path, int $option, mixed $value): bool
366435
{
367436
$normalized = str_replace('\\', '/', $path);
368-
unset(self::$statCache[$normalized]);
437+
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
369438

370439
self::unregister();
371440
$result = false;
@@ -429,10 +498,10 @@ public function dir_closedir(): bool
429498
public function mkdir(string $path, int $mode, int $options): bool
430499
{
431500
$normalized = str_replace('\\', '/', $path);
432-
unset(self::$statCache[$normalized]);
501+
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
433502

434503
self::unregister();
435-
$result = (bool) self::silent(fn () => mkdir($path, $mode, (bool) ($options & STREAM_MKDIR_RECURSIVE)));
504+
$result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0));
436505
self::register();
437506

438507
return $result;
@@ -441,7 +510,7 @@ public function mkdir(string $path, int $mode, int $options): bool
441510
public function rmdir(string $path, int $options): bool
442511
{
443512
$normalized = str_replace('\\', '/', $path);
444-
unset(self::$statCache[$normalized]);
513+
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
445514

446515
self::unregister();
447516
$result = (bool) self::silent(fn () => rmdir($path));
@@ -453,7 +522,7 @@ public function rmdir(string $path, int $options): bool
453522
public function unlink(string $path): bool
454523
{
455524
$normalized = str_replace('\\', '/', $path);
456-
unset(self::$statCache[$normalized]);
525+
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
457526

458527
self::unregister();
459528
$result = (bool) self::silent(fn () => unlink($path));
@@ -466,7 +535,12 @@ public function rename(string $pathFrom, string $pathTo): bool
466535
{
467536
$normFrom = str_replace('\\', '/', $pathFrom);
468537
$normTo = str_replace('\\', '/', $pathTo);
469-
unset(self::$statCache[$normFrom], self::$statCache[$normTo]);
538+
unset(
539+
self::$statCache[$normFrom],
540+
self::$statCache[$normTo],
541+
self::$staticNegativeStatCache[$normFrom],
542+
self::$staticNegativeStatCache[$normTo]
543+
);
470544

471545
self::unregister();
472546
$result = (bool) self::silent(fn () => rename($pathFrom, $pathTo));
@@ -475,18 +549,6 @@ public function rename(string $pathFrom, string $pathTo): bool
475549
return $result;
476550
}
477551

478-
/**
479-
* Determines if the current stream_open call is directly for reading file contents
480-
* rather than PHP engine's require/include execution.
481-
*/
482-
private static function isReadOnlyCall(): bool
483-
{
484-
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
485-
$callerFunc = strtolower($trace[2]['function'] ?? '');
486-
487-
return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], true);
488-
}
489-
490552
/**
491553
* Executes a callback while temporarily suppressing PHP error and warning handlers.
492554
*

0 commit comments

Comments
 (0)