From 82f95df04c9228aff756bd39b1f2964dcae449fe Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 17:50:15 +0200 Subject: [PATCH] Stop the 503 page appearing during an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported with the network panel open: `update` and `state` both answering 503, then the overlay, then Laravel's 503 page, then a reload. Three different screens for one event. Livewire's default on a failed request is to render the response body. So a wire:poll that happened to fire while the application was in maintenance mode swapped the whole console for the 503 page — the panel the operator was watching, replaced by an error page, for something that is not an error. A deployment IS maintenance mode; that is the event, not a fault. Three changes, and they are three because the request came from three places: The status endpoint is exempt from maintenance mode. It is the deployment's own status, read by the overlay every three seconds, and behind maintenance mode it answered 503 for the entire run — so the overlay could never name the step it was on. Safe to exempt: version, commit and a step name, behind the console's host and network guards, and it writes nothing. Livewire swallows a 503 instead of rendering it. For every request, not only the poll: any action taken in that window did the same thing. And the settings card stops polling for the duration. The overlay's own watcher is a plain fetch with no component state behind it, which is why it survives the restart; this poll cannot, and every attempt was a request that answered 503. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- bootstrap/app.php | 15 +++++++++++ resources/js/app.js | 17 ++++++++++++ .../views/livewire/admin/settings.blade.php | 7 ++++- tests/Feature/Admin/UpdateButtonTest.php | 26 +++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 5bc23cd..98390b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.23 +1.3.24 diff --git a/bootstrap/app.php b/bootstrap/app.php index 5d5d787..175d502 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,6 +21,21 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + // The one endpoint that must answer WHILE the application is down. + // + // It is the deployment's own status, read by the overlay every three + // seconds. Behind maintenance mode it answered 503 for the whole run, + // so the overlay had nothing to show but "still waiting" and could not + // name the step it was on — and any Livewire request that fired in the + // same window swapped the console for Laravel's 503 page. + // + // Safe to exempt: it reports version, commit and a step name, it is + // behind the console's host and network guards, and it writes nothing. + $middleware->preventRequestsDuringMaintenance(except: [ + 'update/state', + '*/update/state', + ]); + $middleware->alias([ 'admin' => EnsureAdmin::class, 'admin.host' => RestrictAdminHost::class, diff --git a/resources/js/app.js b/resources/js/app.js index 033ee5b..5c7b2d5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -134,6 +134,23 @@ function recoverFrom419() { document.addEventListener('livewire:init', () => { window.Livewire.hook('request', ({ fail }) => { fail(({ status, preventDefault }) => { + // 503: the application is in maintenance mode, which during a + // deployment is not a fault — it is the event being watched. + // + // Livewire's default is to replace the document with the response + // body, so a wire:poll that happened to fire mid-update swapped the + // console for Laravel's 503 page: the operator saw the overlay, + // then the error page, then the reload. Reported as "the 503 must + // not appear at all during the update", and it must not. + // + // Swallowed for every Livewire request, not just the poll: any + // action taken in that window would do the same thing. + if (status === 503) { + preventDefault(); + + return; + } + if (status !== 419) return; preventDefault(); diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index f4229f1..df1c13d 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -34,8 +34,13 @@ open. wire:poll below is unrelated and stays: it is Livewire's own refresh of this card's own details (log tail, elapsed time) for as long as a Livewire connection exists. --}} + {{-- The poll stops while a run is in flight. The overlay's own + watcher is what follows a deployment — a plain fetch with no + component state behind it, which is why it survives the + restart. This one cannot, and every attempt during maintenance + mode was a Livewire request answering 503. --}}
+ @if (! $update['running']) wire:poll.{{ $update['checking'] ? '3s' : '30s' }} @endif>
diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 3076494..69aa1d7 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -951,3 +951,29 @@ it('runs the workers as the same user as the web process', function () { "\nnormalise_ownership\n", ))->toBe(2); }); + +it('keeps answering its own status while the application is down', function () { + // The deployment's status is the one thing that must be readable DURING + // the deployment. Behind maintenance mode it answered 503 for the whole + // run, so the overlay had nothing to show but "still waiting" and could + // never name the step it was on. + expect(Illuminate\Support\Facades\File::get(base_path('bootstrap/app.php'))) + ->toContain("preventRequestsDuringMaintenance(except: [") + ->toContain("'update/state'") + ->toContain("'*/update/state'"); +}); + +it('does not let a Livewire request during maintenance replace the page', function () { + // Reported with the network panel open: `update` and `state` both 503, and + // Livewire's default on a failed request is to render the response body — + // so a wire:poll that happened to fire mid-deployment swapped the console + // for Laravel's 503 page. Overlay, then error page, then reload. + // + // 503 is not a fault here. It IS the event being watched. + $app = Illuminate\Support\Facades\File::get(resource_path('js/app.js')); + + expect($app)->toContain('if (status === 503) {') + // And the poll that used to fire it stops for the duration. + ->and(Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php'))) + ->toContain("@if (! \$update['running']) wire:poll"); +});