Skip to content

Commit 0fd3475

Browse files
committed
RobotLoader: make atomic write robust against transient Windows file locks
On Windows, rename() over an existing cache file intermittently fails with "Access is denied" when the target is momentarily locked by another process (antivirus scanning the freshly written file, or a memory-mapped opcache handle). Unlike POSIX, the replace is not atomic against open handles. Extracts the tmp-write + rename from saveCache() into atomicWrite(), which now drops the target's opcache handle before renaming and, on Windows only, retries the rename a few times with a short backoff. On other platforms behavior is unchanged - a single rename failure still throws immediately.
1 parent ed72642 commit 0fd3475

2 files changed

Lines changed: 30 additions & 7 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"require": {
1818
"php": "8.1 - 8.5",
1919
"ext-tokenizer": "*",
20-
"nette/utils": "^4.0"
20+
"nette/utils": "^4.0.6"
2121
},
2222
"require-dev": {
2323
"nette/tester": "^2.6",

src/RobotLoader/RobotLoader.php

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
use Nette;
1111
use Nette\Utils\FileSystem;
12+
use Nette\Utils\Helpers;
1213
use SplFileInfo;
13-
use function array_merge, defined, extension_loaded, file_get_contents, file_put_contents, filemtime, flock, fopen, function_exists, hash, is_array, is_dir, is_file, realpath, rename, serialize, spl_autoload_register, sprintf, strlen, unlink, var_export;
14+
use function array_merge, extension_loaded, file_get_contents, file_put_contents, filemtime, flock, fopen, function_exists, hash, is_array, is_dir, is_file, realpath, rename, serialize, spl_autoload_register, sprintf, strlen, unlink, usleep, var_export;
1415

1516

1617
/**
@@ -439,7 +440,7 @@ private function loadCache(): void
439440
// 1) We want to do as little as possible IO calls on production and also directory and file can be not writable (#19)
440441
// so on Linux we include the file directly without shared lock, therefore, the file must be created atomically by renaming.
441442
// 2) On Windows file cannot be renamed-to while is open (ie by include() #11), so we have to acquire a lock.
442-
$lock = defined('PHP_WINDOWS_VERSION_BUILD')
443+
$lock = Helpers::IsWindows
443444
? $this->acquireLock("$file.lock", LOCK_SH)
444445
: null;
445446

@@ -482,14 +483,36 @@ private function saveCache($lock = null): void
482483
$file = $this->generateCacheFileName();
483484
$lock = $lock ?: $this->acquireLock("$file.lock", LOCK_EX);
484485
$code = "<?php\nreturn " . var_export([$this->classes, $this->missingClasses, $this->emptyFiles], return: true) . ";\n";
486+
$this->atomicWrite($file, $code);
487+
}
488+
485489

486-
if (file_put_contents("$file.tmp", $code) !== strlen($code) || !rename("$file.tmp", $file)) {
487-
@unlink("$file.tmp"); // @ file may not exist
488-
throw new \RuntimeException(sprintf("Unable to create '%s'.", $file));
490+
/**
491+
* Atomically writes $content to $file via a temporary file and rename().
492+
* On Windows rename() over a momentarily locked target intermittently fails, so it is retried briefly.
493+
*/
494+
private function atomicWrite(string $file, string $content): void
495+
{
496+
$tmp = "$file.tmp";
497+
if (file_put_contents($tmp, $content) !== strlen($content)) {
498+
@unlink($tmp); // @ - file may not exist
499+
throw new \RuntimeException(sprintf("Unable to create '%s'. %s", $file, Helpers::getLastError()));
500+
}
501+
502+
if (function_exists('opcache_invalidate')) {
503+
@opcache_invalidate($file, force: true); // @ - can be restricted
504+
}
505+
506+
for ($attempt = 1; !@rename($tmp, $file); $attempt++) {
507+
if ($attempt >= 3 || !Helpers::IsWindows) {
508+
@unlink($tmp);
509+
throw new \RuntimeException(sprintf("Unable to create '%s'. %s", $file, Helpers::getLastError()));
510+
}
511+
usleep(100_000);
489512
}
490513

491514
if (function_exists('opcache_invalidate')) {
492-
@opcache_invalidate($file, force: true); // @ can be restricted
515+
@opcache_invalidate($file, force: true); // @ - can be restricted
493516
}
494517
}
495518

0 commit comments

Comments
 (0)