Skip to content

Commit e22759a

Browse files
committed
Optimize StreamWrapper for performance with caching and improved file handling
1 parent 28fd10a commit e22759a

1 file changed

Lines changed: 84 additions & 30 deletions

File tree

src/Internal/StreamWrapper.php

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,26 @@ final class StreamWrapper implements StreamWrapperInterface
4242
private static string $cacheDir = '';
4343

4444
/**
45-
* Resets the compiled pattern cache. Useful for test isolation.
45+
* In-memory cache for url_stat to avoid 100,000+ unregister/register cycles on file_exists/is_file.
46+
*
47+
* @var array<string, array<int|string, int>|false>
48+
*/
49+
private static array $statCache = [];
50+
51+
/**
52+
* In-memory cache for isApplicationFile path decisions.
53+
*
54+
* @var array<string, bool>
55+
*/
56+
private static array $appFileDecisionCache = [];
57+
58+
/**
59+
* Resets all internal caches.
4660
*/
4761
public static function reset(): void
4862
{
63+
self::$statCache = [];
64+
self::$appFileDecisionCache = [];
4965
PathMatcher::reset();
5066
}
5167

@@ -128,6 +144,8 @@ public static function transformSource(string $source, string $filePath = ''): s
128144

129145
$transformedLineCount = substr_count($transformed, "\n");
130146
$drift = $transformedLineCount - $originalLineCount;
147+
$count1 = 0;
148+
$count2 = 0;
131149

132150
if ($drift > 0) {
133151
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\{[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' { /*__TYPEPHP_INJECTED_START__*/', $transformed, $drift, $count1) ?? $transformed;
@@ -153,6 +171,17 @@ public static function transformSource(string $source, string $filePath = ''): s
153171
*/
154172
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
155173
{
174+
// Fast-path: non-PHP files are never transformed
175+
if (! str_ends_with(strtolower($path), '.php')) {
176+
return $this->openDirectHandle($path, $mode);
177+
}
178+
179+
// Fast-path: unwhitelisted vendor files are never transformed
180+
$normalizedRaw = str_replace('\\', '/', $path);
181+
if (str_contains($normalizedRaw, '/vendor/') || str_starts_with($normalizedRaw, 'vendor/')) {
182+
return $this->openDirectHandle($path, $mode);
183+
}
184+
156185
self::unregister();
157186
$exists = self::silent(fn () => file_exists($path));
158187
$resolvedPath = $exists ? realpath($path) : '';
@@ -161,16 +190,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
161190
$isAppFile = $exists && ! self::isReadOnlyCall() && self::isApplicationFile($path, $resolvedPath);
162191

163192
if (! $isAppFile || $resolvedPath === false) {
164-
self::unregister();
165-
$targetFile = ($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path;
166-
167-
/** @var resource|false $handle */
168-
$handle = self::silent(fn () => fopen($targetFile, $mode));
169-
170-
$this->handle = $handle !== false ? $handle : null;
171-
self::register();
172-
173-
return $this->handle !== null;
193+
return $this->openDirectHandle(($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path, $mode);
174194
}
175195

176196
self::unregister();
@@ -184,6 +204,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
184204
return $success;
185205
}
186206

207+
/**
208+
* Opens a raw file handle directly without stream interception.
209+
*/
210+
private function openDirectHandle(string $targetFile, string $mode): bool
211+
{
212+
self::unregister();
213+
/** @var resource|false $handle */
214+
$handle = self::silent(fn () => fopen($targetFile, $mode));
215+
$this->handle = $handle !== false ? $handle : null;
216+
self::register();
217+
218+
return $this->handle !== null;
219+
}
220+
187221
public function stream_read(int $count): string
188222
{
189223
if ($this->handle === null || $count <= 0) {
@@ -286,7 +320,7 @@ public function stream_seek(int $offset, int $whence = SEEK_SET): bool
286320
return false;
287321
}
288322

289-
return fseek($this->handle, $offset, $whence) === 0;
323+
return @fseek($this->handle, $offset, $whence) === 0;
290324
}
291325

292326
public function stream_set_option(int $option, int $arg1, int $arg2): bool
@@ -303,16 +337,23 @@ public function stream_close(): void
303337
}
304338

305339
/**
340+
* High-speed stat resolution with $O(1)$ memoization cache.
341+
*
306342
* @return array<int|string, int>|false
307343
*/
308344
public function url_stat(string $path, int $flags): array|false
309345
{
346+
$normalized = str_replace('\\', '/', $path);
347+
if (\array_key_exists($normalized, self::$statCache)) {
348+
return self::$statCache[$normalized];
349+
}
350+
310351
self::unregister();
311352
/** @var array<int|string, int>|false $result */
312353
$result = self::silent(fn () => stat($path));
313354
self::register();
314355

315-
return $result;
356+
return self::$statCache[$normalized] = $result;
316357
}
317358

318359
public function stream_metadata(string $path, int $option, mixed $value): bool
@@ -396,6 +437,9 @@ public function rmdir(string $path, int $options): bool
396437

397438
public function unlink(string $path): bool
398439
{
440+
$normalized = str_replace('\\', '/', $path);
441+
unset(self::$statCache[$normalized]);
442+
399443
self::unregister();
400444
$result = (bool) self::silent(fn () => unlink($path));
401445
self::register();
@@ -405,13 +449,29 @@ public function unlink(string $path): bool
405449

406450
public function rename(string $pathFrom, string $pathTo): bool
407451
{
452+
$normFrom = str_replace('\\', '/', $pathFrom);
453+
$normTo = str_replace('\\', '/', $pathTo);
454+
unset(self::$statCache[$normFrom], self::$statCache[$normTo]);
455+
408456
self::unregister();
409457
$result = (bool) self::silent(fn () => rename($pathFrom, $pathTo));
410458
self::register();
411459

412460
return $result;
413461
}
414462

463+
/**
464+
* Determines if the current stream_open call is directly for reading file contents
465+
* rather than PHP engine's require/include execution.
466+
*/
467+
private static function isReadOnlyCall(): bool
468+
{
469+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
470+
$callerFunc = strtolower($trace[2]['function'] ?? '');
471+
472+
return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], true);
473+
}
474+
415475
/**
416476
* Executes a callback while temporarily suppressing PHP error and warning handlers.
417477
*
@@ -433,19 +493,7 @@ private static function silent(callable $callback): mixed
433493
}
434494

435495
/**
436-
* Determines if the current stream_open call is directly for reading file contents (e.g. file_get_contents)
437-
* rather than PHP engine's require/include execution.
438-
*/
439-
private static function isReadOnlyCall(): bool
440-
{
441-
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
442-
$callerFunc = strtolower($trace[2]['function'] ?? '');
443-
444-
return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], strict: true);
445-
}
446-
447-
/**
448-
* Determines whether a target PHP file path should be intercepted using Pattern Specificity.
496+
* Determines whether a target PHP file path should be intercepted with $O(1)$ caching.
449497
*/
450498
private static function isApplicationFile(string $path, string|false $resolvedPath): bool
451499
{
@@ -459,12 +507,16 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
459507

460508
$normalizedPath = PathMatcher::normalizePath($resolvedPath);
461509

510+
if (isset(self::$appFileDecisionCache[$normalizedPath])) {
511+
return self::$appFileDecisionCache[$normalizedPath];
512+
}
513+
462514
if (PathMatcher::isLibraryInternal($normalizedPath)) {
463-
return false;
515+
return self::$appFileDecisionCache[$normalizedPath] = false;
464516
}
465517

466518
if (PathMatcher::isCachePath($normalizedPath)) {
467-
return false;
519+
return self::$appFileDecisionCache[$normalizedPath] = false;
468520
}
469521

470522
$config = Config::get();
@@ -473,7 +525,9 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
473525
/** @var array<int, string> $excludes */
474526
$excludes = \is_array($config['exclude'] ?? null) ? $config['exclude'] : ['vendor/**', 'storage/**', 'var/**', 'cache/**'];
475527

476-
return PathMatcher::isPathIncluded($normalizedPath, $includes, $excludes, $path);
528+
$isIncluded = PathMatcher::isPathIncluded($normalizedPath, $includes, $excludes, $path);
529+
530+
return self::$appFileDecisionCache[$normalizedPath] = $isIncluded;
477531
}
478532

479533
private function openMemoryStream(string $resolvedPath): bool
@@ -502,7 +556,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool
502556
{
503557
$cacheDir = self::$cacheDir;
504558
if (! is_dir($cacheDir)) {
505-
self::silent(fn () => mkdir($cacheDir, 0777, recursive: true));
559+
self::silent(fn () => mkdir($cacheDir, 0777, true));
506560
}
507561

508562
$cachedFile = CacheManager::getCachedFilePath($resolvedPath);

0 commit comments

Comments
 (0)