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:
0xJacky
2026-08-02 10:20:33 +08:00
parent 392683a684
commit 6dd234f146
2 changed files with 99 additions and 3 deletions

View File

@@ -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)) {

View File

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