From c7ebc55d6fabe7474d4ce200d7bcaff3528412f4 Mon Sep 17 00:00:00 2001 From: agis Date: Thu, 27 Aug 2026 15:39:41 +0700 Subject: [PATCH 1/2] fix(cache): tolerate corrupt file cache entries instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partially-written or corrupt cache file (e.g. a torn write from concurrent processes booting right after a deploy) made `FileStore::getPayload()` call `unserialize()` on garbage. The emitted warning is promoted to an ErrorException by the framework error handler, so the read threw before the caller could react — surfacing as a 500 during boot when the route cache file was being built concurrently. Wrap the unserialize in try/catch: on failure, forget the entry and return a miss so the caller rebuilds. Adds a CachedRouting test that a corrupt cache file triggers a rebuild rather than an error. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Illuminate/Cache/FileStore.php | 15 +++++++++++- .../CachedRouting/RoutingIntegrationTest.php | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Cache/FileStore.php b/src/Illuminate/Cache/FileStore.php index dc97b5c09..d46289bdd 100755 --- a/src/Illuminate/Cache/FileStore.php +++ b/src/Illuminate/Cache/FileStore.php @@ -79,7 +79,20 @@ protected function getPayload($key) return array('data' => null, 'time' => null); } - $data = unserialize(substr($contents, 10)); + // A corrupt or partially-written file (e.g. a torn write from concurrent + // processes) would make unserialize emit a warning that the error handler + // promotes to an exception. Treat such an entry as a miss and forget it so + // the caller rebuilds rather than failing. + try + { + $data = unserialize(substr($contents, 10)); + } + catch (\Throwable $e) + { + $this->forget($key); + + return array('data' => null, 'time' => null); + } // Next, we'll extract the number of minutes that are remaining for a cache // so that we can properly retain the time for things like the increment diff --git a/tests/CachedRouting/RoutingIntegrationTest.php b/tests/CachedRouting/RoutingIntegrationTest.php index fa06ebb3c..909411c17 100755 --- a/tests/CachedRouting/RoutingIntegrationTest.php +++ b/tests/CachedRouting/RoutingIntegrationTest.php @@ -390,4 +390,28 @@ public function testCanClearCache(): void $router->clearCache(__FILE__); static::assertFalse($this->app->cache->has($key), 'Routes must no longer be cached'); } + + public function testRebuildsWhenCachedFileIsCorrupt(): void + { + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router) { + $router->get('/', 'HomeController@actionIndex'); + }); + + // Simulate a torn/partial write: keep a far-future expiry prefix so the + // entry is not treated as expired, but leave an unserializable body. + foreach (glob(self::$cachePath . '/*/*/*') as $file) { + file_put_contents($file, '9999999999corrupt-payload'); + } + + $rebuilt = false; + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router, &$rebuilt) { + $rebuilt = true; + $router->get('/', 'HomeController@actionIndex'); + }); + + static::assertTrue($rebuilt, 'Corrupt cache must trigger a rebuild, not a failure'); + static::assertEquals(1, $router->getRoutes()->count(), 'Routes must be rebuilt from the callback'); + } } From c2563f0695a8c65d5a4e09f9d7572873e863714f Mon Sep 17 00:00:00 2001 From: agis Date: Thu, 27 Aug 2026 15:47:51 +0700 Subject: [PATCH 2/2] fix(cached-routing): make route caching best-effort, never fail boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production stack trace showed the real failure is a cache WRITE, not a corrupt read: `file_put_contents(.../app_storage/cache/..): No such file or directory` — the cache directory is not creatable/writable by php-fpm, so `FileStore::put()` throws and the exception propagates out of `Router::cache()` during boot, returning a 500. Route caching is only an optimization, so no cache I/O failure should break boot. Wrap both the read and the write in `Router::cache()`: - read failure (corrupt/unreadable entry) -> treat as a miss and rebuild - write failure (unwritable dir, disk full) -> ignore; routes stay defined in memory, just uncached Reverts the earlier FileStore-level read guard (superseded by the read try/catch here). Adds a test that an unwritable cache path still boots with routes defined, alongside the corrupt-cache rebuild test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Illuminate/Cache/FileStore.php | 15 +----------- src/Illuminate/CachedRouting/Router.php | 24 +++++++++++++++---- .../CachedRouting/RoutingIntegrationTest.php | 20 ++++++++++++++++ 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/Illuminate/Cache/FileStore.php b/src/Illuminate/Cache/FileStore.php index d46289bdd..dc97b5c09 100755 --- a/src/Illuminate/Cache/FileStore.php +++ b/src/Illuminate/Cache/FileStore.php @@ -79,20 +79,7 @@ protected function getPayload($key) return array('data' => null, 'time' => null); } - // A corrupt or partially-written file (e.g. a torn write from concurrent - // processes) would make unserialize emit a warning that the error handler - // promotes to an exception. Treat such an entry as a miss and forget it so - // the caller rebuilds rather than failing. - try - { - $data = unserialize(substr($contents, 10)); - } - catch (\Throwable $e) - { - $this->forget($key); - - return array('data' => null, 'time' => null); - } + $data = unserialize(substr($contents, 10)); // Next, we'll extract the number of minutes that are remaining for a cache // so that we can properly retain the time for things like the increment diff --git a/src/Illuminate/CachedRouting/Router.php b/src/Illuminate/CachedRouting/Router.php index f71252030..b4e41d5ff 100644 --- a/src/Illuminate/CachedRouting/Router.php +++ b/src/Illuminate/CachedRouting/Router.php @@ -85,8 +85,18 @@ public function cache($filename, Closure $callback, $cacheMinutes = 1440) $cacher = $this->getRouteCacher(); $cacheKey = $this->getCacheKey($filename); - // Check if the current route group is cached. - if (($cache = $cacher->get($cacheKey)) !== null) { + // Route caching is best-effort: an unreadable/corrupt entry or an + // unwritable cache directory (e.g. permissions, or a torn write from + // concurrent boots after a deploy) must never break application boot. + // On any cache I/O failure we fall back to defining the routes directly. + $cache = null; + try { + $cache = $cacher->get($cacheKey); + } catch (\Throwable $e) { + // Treat an unreadable cache as a miss and rebuild below. + } + + if ($cache !== null) { $this->routes->restoreRouteCache($cache); } else { // Back up current RouteCollection contents. @@ -95,9 +105,13 @@ public function cache($filename, Closure $callback, $cacheMinutes = 1440) // Call closure to define routes that should be cached. call_user_func($callback, $this); - // Put routes in cache. - $cache = $this->routes->getCacheableRoutes(); - $cacher->put($cacheKey, $cache, $cacheMinutes); + // Persist the routes, ignoring failures so a broken cache store + // never propagates out of boot (routes stay defined in memory). + try { + $cacher->put($cacheKey, $this->routes->getCacheableRoutes(), $cacheMinutes); + } catch (\Throwable $e) { + // Best-effort cache; a write failure is non-fatal. + } // And restore the routes that shouldn't be cached. $this->routes->restoreRouteCollection(); diff --git a/tests/CachedRouting/RoutingIntegrationTest.php b/tests/CachedRouting/RoutingIntegrationTest.php index 909411c17..631e84f62 100755 --- a/tests/CachedRouting/RoutingIntegrationTest.php +++ b/tests/CachedRouting/RoutingIntegrationTest.php @@ -414,4 +414,24 @@ public function testRebuildsWhenCachedFileIsCorrupt(): void static::assertTrue($rebuilt, 'Corrupt cache must trigger a rebuild, not a failure'); static::assertEquals(1, $router->getRoutes()->count(), 'Routes must be rebuilt from the callback'); } + + public function testBootStillWorksWhenCacheIsUnwritable(): void + { + // Point the cache at a path that cannot be created (a file where a + // directory is expected), so the underlying write fails — mirroring an + // unwritable cache directory on a production node. + $files = new Filesystem; + $files->makeDirectory(self::$cachePath, 0777, true, true); + $blocker = self::$cachePath . '/blocker'; + file_put_contents($blocker, 'x'); + $this->app['config']['cache.path'] = $blocker . '/nested'; + + $router = $this->getRouter(); + $key = $router->cache(__FILE__, function () use ($router) { + $router->get('/', 'HomeController@actionIndex'); + }); + + static::assertNotNull($key, 'cache() must return normally despite the write failure'); + static::assertEquals(1, $router->getRoutes()->count(), 'Routes must still be defined when caching fails'); + } }