mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-03 07:24:52 +08:00
fix(demo): stop the loading page reloading forever
Two faults, both surfacing as a page that refreshes without end. The Worker checked container readiness and then proxied, which are not atomic: the idle timeout can reap the container in between, leaving the Durable Object reporting a state that is no longer true. The platform then answers with its own "not listening in the TCP address" error, which reached the visitor verbatim instead of the loading page. Detecting that is awkward — it arrives as a plain-text 5xx body rather than a throw, so there is nothing to catch and no status or header that separates it from an application error. Match the message, treat it as not-ready, and fall through to the loading path. The first attempt at this also called recycle() on detection, which was worse than the bug: recycle() stops the container, and by then ready() had already started a new boot, so every request killed the container that was trying to start. Nothing could ever reach healthy and the page refreshed indefinitely. Re-arm the boot, never stop it. Independently, harden the page itself. It reloads when the container reports ready, so a container that flaps made it reload endlessly. Cap reloads within a two-minute window and show an explanation with a manual retry instead. The window matters: spaced-out reloads are ordinary cold starts across a long session and must not accumulate into a false positive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,30 @@ function isWebSocketUpgrade(request: Request): boolean {
|
||||
return (request.headers.get('upgrade') ?? '').toLowerCase() === 'websocket'
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognise the platform's own "the container went away" response.
|
||||
*
|
||||
* When the container is reaped between the readiness check and the proxy, the
|
||||
* runtime answers with a plain-text error naming the unreachable address rather
|
||||
* than throwing, so it cannot be caught — it has to be detected. Matching on
|
||||
* the message is unpleasant but there is no status code or header that
|
||||
* distinguishes it from an error the application itself produced.
|
||||
*/
|
||||
async function isStaleContainerError(response: Response): Promise<boolean> {
|
||||
if (response.status < 500 || response.webSocket) {
|
||||
return false
|
||||
}
|
||||
if (!(response.headers.get('content-type') ?? '').startsWith('text/plain')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Peek without consuming: the body is still needed if this turns out to be a
|
||||
// genuine application error.
|
||||
const body = await response.clone().text().catch(() => '')
|
||||
return body.includes('Error proxying request to container')
|
||||
|| body.includes('is not listening in the TCP address')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the public scheme and host onto a request before it reaches the
|
||||
* container.
|
||||
@@ -214,10 +238,32 @@ export default {
|
||||
return container.fetch(withForwardedHeaders(request, url))
|
||||
}
|
||||
|
||||
if (await container.ready()) {
|
||||
let ready = await container.ready()
|
||||
|
||||
if (ready) {
|
||||
// fetch(), not containerFetch(): only fetch() carries WebSocket upgrades,
|
||||
// which the terminal, log stream and cluster monitor all rely on.
|
||||
return container.fetch(withForwardedHeaders(request, url))
|
||||
const response = await container.fetch(withForwardedHeaders(request, url))
|
||||
|
||||
// The readiness check and the proxy are not atomic. The idle timeout can
|
||||
// reap the container in between, leaving the Durable Object reporting a
|
||||
// state that is no longer true; the platform then answers with its own
|
||||
// "not listening on ..." error, which used to reach the visitor verbatim.
|
||||
// Treat that as not-ready, ask for a fresh container, and fall through to
|
||||
// the loading page the rest of this handler already knows how to serve.
|
||||
if (await isStaleContainerError(response)) {
|
||||
// Do NOT stop the container here. It is already gone, and a stop lands
|
||||
// on whatever boot ready() has since kicked off — every subsequent
|
||||
// request would kill the container that is trying to start, and the
|
||||
// loading page would refresh forever. Just re-arm the boot and fall
|
||||
// through.
|
||||
console.log('container went away mid-request; waiting for the restart')
|
||||
await container.ready()
|
||||
ready = false
|
||||
}
|
||||
else {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsDocument(request) && !isWebSocketUpgrade(request)) {
|
||||
|
||||
@@ -66,12 +66,60 @@ export function loadingPage(): string {
|
||||
<p class="hint">This instance sleeps when nobody is using it. First request takes a few seconds.</p>
|
||||
<div class="bar"><span></span></div>
|
||||
<p class="slow" id="slow">Still starting. This can take up to a minute after a new deploy.</p>
|
||||
<p class="slow" id="stuck">
|
||||
The demo keeps restarting rather than settling. Reloading has been stopped
|
||||
so this page does not loop; use the button below to try again.
|
||||
<br><br>
|
||||
<button type="button" id="retry">Try again</button>
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var started = Date.now();
|
||||
var delay = 700;
|
||||
|
||||
// Reloading on ready is what gets the visitor into the app, but a container
|
||||
// that flaps would have this page reload endlessly. Count reloads across
|
||||
// navigations and stop after a few, so the worst case is a stalled page with
|
||||
// an explanation rather than a loop nobody can read or escape.
|
||||
var KEY = 'nginx-ui-demo-reloads';
|
||||
var MAX_RELOADS = 3;
|
||||
// Only reloads bunched together indicate a flap. Spaced-out ones are just
|
||||
// ordinary cold starts across a long session and must not accumulate into a
|
||||
// false positive.
|
||||
var WINDOW_MS = 120000;
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
var raw = JSON.parse(sessionStorage.getItem(KEY) || '{}');
|
||||
if (!raw || typeof raw.count !== 'number') return { count: 0, at: 0 };
|
||||
if (Date.now() - (raw.at || 0) > WINDOW_MS) return { count: 0, at: 0 };
|
||||
return raw;
|
||||
} catch (e) { return { count: 0, at: 0 }; }
|
||||
}
|
||||
|
||||
function reloadCount() {
|
||||
return readState().count;
|
||||
}
|
||||
|
||||
function noteReload() {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, JSON.stringify({ count: reloadCount() + 1, at: Date.now() }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
var el = document.getElementById('stuck');
|
||||
if (el) el.style.display = 'block';
|
||||
}
|
||||
|
||||
var retry = document.getElementById('retry');
|
||||
if (retry) {
|
||||
retry.addEventListener('click', function () {
|
||||
try { sessionStorage.removeItem(KEY); } catch (e) {}
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
var el = document.getElementById('slow');
|
||||
if (el) el.style.display = 'block';
|
||||
@@ -82,6 +130,8 @@ export function loadingPage(): string {
|
||||
.then(function (r) { return r.ok ? r.json() : { ready: false }; })
|
||||
.then(function (s) {
|
||||
if (s && s.ready) {
|
||||
if (reloadCount() >= MAX_RELOADS) { giveUp(); return; }
|
||||
noteReload();
|
||||
// Reload rather than navigate, so the deep link the visitor arrived
|
||||
// on is preserved.
|
||||
location.reload();
|
||||
|
||||
Reference in New Issue
Block a user