66use PHPStan \Cache \Cache ;
77use PHPStan \DependencyInjection \AutowiredParameter ;
88use PHPStan \DependencyInjection \AutowiredService ;
9+ use PHPStan \File \FileContentHasher ;
910use PHPStan \File \FileFinder ;
1011use PHPStan \Internal \DirectoryCreator ;
1112use PHPStan \Internal \DirectoryCreatorException ;
1516use function fclose ;
1617use function flock ;
1718use function fopen ;
18- use function hash_file ;
1919use function hrtime ;
20+ use function is_array ;
2021use function ksort ;
2122use function serialize ;
2223use function sha1 ;
@@ -41,13 +42,22 @@ final class OptimizedDirectorySourceLocatorFactory
4142
4243 private const SCAN_LOCK_POLL_INTERVAL_MICROSECONDS = 50_000 ;
4344
45+ /**
46+ * The hash lock is polled much finer than the scan lock: every worker
47+ * reaches the same directories in near lockstep at startup, and most
48+ * directories hash in well under the scan lock's 50ms tick, so a coarse
49+ * poll would make lock losers sleep longer than the work they skip.
50+ */
51+ private const HASH_LOCK_POLL_INTERVAL_MICROSECONDS = 5_000 ;
52+
4453 public function __construct (
4554 private FileNodesFetcher $ fileNodesFetcher ,
4655 #[AutowiredParameter(ref: '@fileFinderScan ' )]
4756 private FileFinder $ fileFinder ,
4857 private PhpVersion $ phpVersion ,
4958 private SymbolFinderInFiles $ symbolFinderInFiles ,
5059 private Cache $ cache ,
60+ private FileContentHasher $ fileContentHasher ,
5161 #[AutowiredParameter]
5262 private string $ tmpDir ,
5363 )
@@ -56,17 +66,63 @@ public function __construct(
5666
5767 public function createByDirectory (string $ directory ): OptimizedDirectorySourceLocator
5868 {
59- $ files = $ this ->fileFinder ->findFiles ([$ directory ])->getFiles ();
60- $ fileHashes = [];
61- foreach ($ files as $ file ) {
62- $ hash = hash_file ('sha256 ' , $ file );
63- if ($ hash === false ) {
64- continue ;
69+ $ cacheKey = sprintf ('odsl-%s ' , $ directory );
70+ $ hashesRecordKey = 'odsl-filehashes- ' . $ directory ;
71+
72+ // The walk + hash of a directory is identical in every process of a
73+ // run, and running it once per worker in parallel multiplies both the
74+ // CPU and — on hosts where concurrent open() is expensive — the wall
75+ // cost of the analysis startup. When the run has a shared arena, the
76+ // first process publishes the file-hash map and everyone else reuses
77+ // it. hasRecord() on the analysed-files record (published by the
78+ // master before workers spawn) doubles as the "is an arena active?"
79+ // probe — the seam has no explicit method for that and only grows one
80+ // together with the extension.
81+ $ arenaActive = ArenaCache ::hasRecord ('analysed-files ' );
82+ $ hashesLock = null ;
83+ if ($ arenaActive ) {
84+ $ shared = ArenaCache ::lookup ($ hashesRecordKey );
85+ if (is_array ($ shared )) {
86+ /** @var array<string, string> $shared */
87+ return $ this ->createCachedDirectorySourceLocator ($ shared , $ cacheKey );
88+ }
89+
90+ // Single-flight the walk + hash, same pattern as the cold-cache
91+ // scan below: the winner computes and publishes, losers wait and
92+ // re-read the record. A lost lock (timeout, unwritable tmp) just
93+ // means this worker hashes the directory itself.
94+ $ hashesLock = $ this ->acquireDirectoryScanLock ('hashes- ' . $ directory , self ::HASH_LOCK_POLL_INTERVAL_MICROSECONDS );
95+ if ($ hashesLock !== null ) {
96+ $ shared = ArenaCache ::lookup ($ hashesRecordKey );
97+ if (is_array ($ shared )) {
98+ $ this ->releaseDirectoryScanLock ($ hashesLock );
99+
100+ /** @var array<string, string> $shared */
101+ return $ this ->createCachedDirectorySourceLocator ($ shared , $ cacheKey );
102+ }
103+ }
104+ }
105+
106+ try {
107+ $ files = $ this ->fileFinder ->findFiles ([$ directory ])->getFiles ();
108+ $ fileHashes = [];
109+ foreach ($ files as $ file ) {
110+ $ hash = $ this ->fileContentHasher ->hash ($ file );
111+ if ($ hash === false ) {
112+ continue ;
113+ }
114+ $ fileHashes [$ file ] = $ hash ;
115+ }
116+
117+ if ($ arenaActive ) {
118+ ArenaCache ::publish ($ hashesRecordKey , $ fileHashes );
119+ }
120+ } finally {
121+ if ($ hashesLock !== null ) {
122+ $ this ->releaseDirectoryScanLock ($ hashesLock );
65123 }
66- $ fileHashes [$ file ] = $ hash ;
67124 }
68125
69- $ cacheKey = sprintf ('odsl-%s ' , $ directory );
70126 return $ this ->createCachedDirectorySourceLocator ($ fileHashes , $ cacheKey );
71127 }
72128
@@ -96,6 +152,7 @@ private function createCachedDirectorySourceLocator(array $fileHashes, string $c
96152 $ this ->fileNodesFetcher ,
97153 $ this ->cache ,
98154 $ this ->phpVersion ,
155+ $ this ->fileContentHasher ,
99156 [],
100157 [],
101158 [],
@@ -188,6 +245,7 @@ private function createCachedDirectorySourceLocator(array $fileHashes, string $c
188245 $ this ->fileNodesFetcher ,
189246 $ this ->cache ,
190247 $ this ->phpVersion ,
248+ $ this ->fileContentHasher ,
191249 $ classToFile ,
192250 $ functionToFiles ,
193251 $ constantToFile ,
@@ -202,7 +260,7 @@ private function createCachedDirectorySourceLocator(array $fileHashes, string $c
202260 *
203261 * @return resource|null
204262 */
205- private function acquireDirectoryScanLock (string $ lockKey )
263+ private function acquireDirectoryScanLock (string $ lockKey, int $ pollIntervalMicroseconds = self :: SCAN_LOCK_POLL_INTERVAL_MICROSECONDS )
206264 {
207265 $ lockDirectory = sprintf ('%s/cache/locks ' , $ this ->tmpDir );
208266 try {
@@ -231,7 +289,7 @@ private function acquireDirectoryScanLock(string $lockKey)
231289 return null ;
232290 }
233291
234- usleep (self :: SCAN_LOCK_POLL_INTERVAL_MICROSECONDS );
292+ usleep ($ pollIntervalMicroseconds );
235293 }
236294
237295 return $ lockHandle ;
@@ -266,7 +324,7 @@ public function createByFiles(array $files, string $uniqueCacheIdentifier): Opti
266324 {
267325 $ fileHashes = [];
268326 foreach ($ files as $ file ) {
269- $ hash = hash_file ( ' sha256 ' , $ file );
327+ $ hash = $ this -> fileContentHasher -> hash ( $ file );
270328 if ($ hash === false ) {
271329 continue ;
272330 }
0 commit comments