Stop the 503 page appearing during an update
tests / pest (push) Failing after 8m3s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.24
nexxo 2026-07-29 17:50:15 +02:00
parent 98afd1d7e0
commit 82f95df04c
5 changed files with 65 additions and 2 deletions

View File

@ -1 +1 @@
1.3.23
1.3.24

View File

@ -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,

View File

@ -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();

View File

@ -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. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ ($update['running'] || $update['checking']) ? '3s' : '30s' }}>
@if (! $update['running']) wire:poll.{{ $update['checking'] ? '3s' : '30s' }} @endif>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">

View File

@ -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");
});