Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ jobs:
- name: Test
run: dotnet test SignsOfAI.slnx -c Release --no-build --nologo

# The web app's startup path is JavaScript, so no .NET test can execute it. These run the real
# boot.js against a fake network: a 503 that recovers, one that does not, a connection that
# hangs, and the integrity hash it must keep handing to fetch. Node's own runner, no packages.
- name: Test boot script
run: node --test tests/boot/boot.test.mjs

# The desktop app is WPF, so it needs a Windows runner and it is not in SignsOfAI.slnx — see the
# comment at the top of that file. Its own job keeps the build above on Linux, where it stays fast
# for the translation PRs that are the reason this workflow exists.
Expand Down
136 changes: 134 additions & 2 deletions src/SignsOfAI.Web/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,140 @@
</div>
<script src="_content/SignsOfAI.UI/js/lang.js"></script>
<script src="_content/SignsOfAI.UI/js/share-card.js"></script>
<!-- autostart="false" so boot.js can start Blazor itself: it retries a boot file the
server fails to hand over, and explains the failure if it still will not come. -->

<!-- The panel of last resort, and the watchdog that decides to show it.
Both live inline, on purpose. Everything below this point is a separate request that a
server can fail to answer — including boot.js, which holds the retry. If the explanation
lived there too, the one failure it could never explain would be its own. Inline code is
part of this document: if the visitor can read anything at all, they can read this. -->
<script>
window.signsofaiBoot = (function () {
// Long, because the wrong error is worse than a slow one. A cheap phone on a bad
// connection is still legitimately loading; this fires only when nothing has *advanced*
// for this long, never merely because loading took a while.
var STALLED_MS = 45000;
var CHECK_MS = 5000;
var DESKTOP = 'https://github.com/peopleworks/SignsofAI/releases?q=desktop&expanded=true';

var state = { started: false, shown: false, lastProgress: Date.now() };

function spanish() {
// The reader's own choice wins where there is one. This renders before Blazor, so it
// cannot ask Loc — but the switch persists here and outlives the failed startup.
try {
var saved = window.localStorage.getItem('signsofai.ui.lang');
if (saved) return saved.toLowerCase().indexOf('es') === 0;
} catch (e) { /* localStorage throws outright in a locked-down browser. */ }
return (navigator.language || '').toLowerCase().indexOf('es') === 0;
}

function text(el, value) { el.textContent = value; return el; }

function render(detail) {
var copy = spanish() ? {
title: 'No se pudo completar la carga',
body: 'Uno de los archivos de la aplicación no llegó. Puede ser un problema ' +
'momentáneo del servidor o de tu conexión. Tu texto no tiene nada que ver ' +
'con esto — la aplicación ni siquiera llegó a arrancar.',
retry: 'Reintentar',
escalate: 'Si vuelve a fallar, inténtalo más tarde o comprueba tu conexión. ',
desktop: 'La app de escritorio no depende de este servidor.',
report: 'Si vas a avisar del problema, copia el detalle técnico de abajo.',
details: 'Detalle técnico'
} : {
title: 'Couldn’t load',
body: 'One of the application files did not arrive. It may be a momentary server ' +
'problem or an issue with your connection. Your text has nothing to do ' +
'with this — the application never even started.',
retry: 'Try again',
escalate: 'If it still fails, try again later or check your connection. ',
desktop: 'The desktop app does not depend on this server.',
report: 'If you report this, include the technical detail below.',
details: 'Technical detail'
};

var app = document.getElementById('app');
if (!app) return;
app.innerHTML = '';

var panel = document.createElement('div');
panel.setAttribute('role', 'alert');
// Styles itself from the app's tokens where they loaded and from their light-theme
// values where they did not: a page explaining a failed download must not need one.
panel.style.cssText =
'max-width:34rem;margin:12vh auto;padding:1.5rem;border-radius:14px;' +
'border:1px solid var(--border,#e2e6ea);background:var(--surface,#fff);' +
'color:var(--text,#1a1d21);font:1rem/1.5 system-ui,-apple-system,Segoe UI,sans-serif;';

var title = text(document.createElement('h1'), copy.title);
title.style.cssText = 'margin:0 0 .6rem;font-size:1.25rem;';

var body = text(document.createElement('p'), copy.body);
body.style.cssText = 'margin:0 0 1.2rem;color:var(--text-muted,#5b6470);';

var retry = text(document.createElement('button'), copy.retry);
retry.type = 'button';
retry.style.cssText =
'padding:.55rem 1.1rem;border:0;border-radius:8px;cursor:pointer;font:inherit;' +
'background:var(--brand,#2563eb);color:var(--brand-ink,#fff);';
retry.addEventListener('click', function () { window.location.reload(); });

var next = document.createElement('p');
next.style.cssText = 'margin:1.2rem 0 0;font-size:.9rem;color:var(--text-muted,#5b6470);';
next.appendChild(document.createTextNode(copy.escalate));
var link = text(document.createElement('a'), copy.desktop);
// An absolute GitHub URL, never an in-app route: every route on this site is served
// by this same page, so a link to /download would fail to start all over again.
link.href = DESKTOP;
link.style.color = 'var(--brand,#2563eb)';
next.appendChild(link);

panel.appendChild(title);
panel.appendChild(body);
panel.appendChild(retry);
panel.appendChild(next);

if (detail) {
var report = text(document.createElement('p'), copy.report);
report.style.cssText = 'margin:.9rem 0 0;font-size:.82rem;color:var(--text-muted,#5b6470);';
var box = document.createElement('details');
box.style.cssText = 'margin-top:.4rem;font-size:.82rem;color:var(--text-muted,#5b6470);';
var summary = text(document.createElement('summary'), copy.details);
summary.style.cursor = 'pointer';
// textContent, never innerHTML: this string comes back from the network.
var pre = text(document.createElement('pre'), detail);
pre.style.cssText = 'white-space:pre-wrap;word-break:break-all;margin:.5rem 0 0;';
box.appendChild(summary);
box.appendChild(pre);
panel.appendChild(report);
panel.appendChild(box);
}

app.appendChild(panel);
}

state.progress = function () { state.lastProgress = Date.now(); };
state.ok = function () { state.started = true; };
state.fail = function (detail) {
if (state.started || state.shown) return;
state.shown = true;
render(detail || null);
};

// Catches every failure the retry cannot reach: a 503 on boot.js itself, one on the
// runtime's ES modules (which must be left to the default loader and so get no retry),
// and a connection that accepts and then hangs without ever erroring.
setInterval(function () {
if (state.started || state.shown) return;
if (Date.now() - state.lastProgress > STALLED_MS) state.fail(null);
}, CHECK_MS);

return state;
})();
</script>

<!-- autostart="false" so boot.js can start Blazor itself: it retries a boot file the server
fails to hand over, and reports the one it could not get. -->
<script src="_framework/blazor.webassembly#[.{fingerprint}].js" autostart="false"></script>
<script src="js/boot.js"></script>
</body>
Expand Down
144 changes: 56 additions & 88 deletions src/SignsOfAI.Web/wwwroot/js/boot.js
Original file line number Diff line number Diff line change
@@ -1,129 +1,97 @@
// Starting the app, out loud when it fails.
//
// The runtime arrives as ~50 separate files, each one pinned by a SHA-256 in the boot manifest.
// The runtime arrives as ~54 separate files, each one pinned by a SHA-256 in the boot manifest.
// GitHub Pages answers one of them with a 503 now and then. The browser hashes whatever body did
// arrive — an error page, not the assembly — the integrity check fails, and Blazor gives up for
// good. What the visitor sees is the loading circle, forever, with no message: the failure happens
// good. What the visitor saw was the loading circle, forever, with no message: the failure happens
// before Blazor has an error UI to show. That is how this reached us, as "the app is down", when
// every file on the server was intact.
//
// So: retry the download before believing it, and if it still will not come, say so.
// So: retry the download before believing it. The panel that speaks when the retry does not help
// lives inline in index.html, because it also has to survive this file never arriving.
(function () {
const ATTEMPTS = 3;
const BACKOFF_MS = [400, 1200];

// The .NET runtime's own scripts are ES modules — they have to be handed back as a URL for the
// import to work, so a Response would break them. Those keep the default loader.
// A fetch has no timeout of its own. A server that accepts the connection and then never
// answers would otherwise consume no attempt, reach no panel, and hand back the same eternal
// spinner this file exists to kill — the failure mode measured and missed on the first pass.
const ATTEMPT_TIMEOUT_MS = 25000;

// The .NET runtime's own scripts are ES modules: the loader asserts they come back as a URL, so
// a Response would break the import outright. Those keep the default loader — and therefore get
// no retry, which is one of the holes the inline watchdog covers.
const NOT_OURS = 'dotnetjs';

let lastFailure = null;
const boot = window.signsofaiBoot;

async function fetchWithRetry(url, integrity) {
// Local to this download, never shared. A module-level "last failure" would let a file that
// failed once and then recovered be named as the cause of somebody else's error later.
let failure = null;

for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
if (attempt > 0) {
await new Promise(done => setTimeout(done, BACKOFF_MS[attempt - 1] ?? 1200));
}

const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), ATTEMPT_TIMEOUT_MS);
try {
const response = await fetch(url, {
// Handing fetch the manifest's own hash keeps the guarantee we would otherwise
// be dropping: returning a Response from loadBootResource takes the integrity
// check away from Blazor, so the browser has to do it here instead.
// be dropping. Returning a Response takes the check away from Blazor entirely:
// the runtime short-circuits on a returned promise before it sets integrity of
// its own, so this line is not hygiene, it is the whole verification.
integrity: integrity || undefined,
// A first try may legitimately come from the browser cache. A retry may not —
// if what we hold is a truncated or stale body, asking for it again is useless.
cache: attempt === 0 ? 'default' : 'reload',
signal: abort.signal,
});
if (response.ok) return response;
lastFailure = { url, reason: `HTTP ${response.status}` };
if (response.ok) {
if (attempt > 0) {
// The only trace a rescued startup leaves. Without it, a recovered blip is
// indistinguishable from a clean load, and the next person to ask "was the
// site flaky?" has nothing to look at.
console.info(`[signsofai] ${url} recovered after ${attempt + 1} attempts`);
}
boot.progress();
return response;
}
failure = { url, reason: `HTTP ${response.status}` };
} catch (error) {
// An integrity mismatch rejects here too, which is the case we are actually chasing:
// a 503 body hashes to something else, and retrying is exactly the right answer.
lastFailure = { url, reason: String(error && error.message || error) };
// a 503 body hashes to something else. So does an abort. Both are worth retrying.
failure = { url, reason: String(error && error.message || error) };
} finally {
clearTimeout(timer);
}
}

// Blazor does not reject Blazor.start() when a boot file will not come — the failure
// surfaces as an unhandled rejection deep in mono_download_assets, which is why the
// spinner used to spin for good. We are the ones who know the download is out of tries,
// so the explanation is written from here rather than from a .catch that never runs.
reportFailure();
throw new Error(`${lastFailure.reason} for ${lastFailure.url}`);
// surfaces as an unhandled rejection inside mono_download_assets, which is why the spinner
// used to spin for good. We are the ones who know this download is out of tries, so the
// explanation is asked for from here rather than from a .catch that never runs.
boot.fail(`${failure.reason}\n${failure.url}`);
throw new Error(`${failure.reason} for ${failure.url}`);
}

// The app has not started, so Blazor cannot render this and its own error UI is not wired yet.
// It also styles itself: app.css is a separate request, and a page that is explaining a failed
// download should not depend on one more download having worked. The custom properties are used
// where they exist and fall back to their light-theme values where they do not.
let reported = false;

function reportFailure(error) {
// Several assets can fail in the same run; the first explanation is the one that stands.
if (reported) return;
reported = true;

const spanish = (navigator.language || '').toLowerCase().startsWith('es');
const copy = spanish ? {
title: 'No se pudo terminar de cargar',
body: 'Un archivo de la aplicación no llegó. Casi siempre es un fallo pasajero del ' +
'servidor, no un problema de tu texto ni de tu navegador. Nada de lo que ' +
'escribiste salió de tu equipo — la aplicación ni siquiera llegó a arrancar.',
retry: 'Reintentar',
details: 'Detalle técnico',
} : {
title: "Couldn't finish loading",
body: 'One of the application files did not arrive. This is almost always a passing ' +
'server fault, not a problem with your text or your browser. Nothing you typed ' +
'left your machine — the application never got as far as starting.',
retry: 'Try again',
details: 'Technical detail',
};

const app = document.getElementById('app');
if (!app) return;

app.innerHTML = '';
const panel = document.createElement('div');
panel.setAttribute('role', 'alert');
panel.style.cssText =
'max-width:34rem;margin:12vh auto;padding:1.5rem;border-radius:14px;' +
'border:1px solid var(--border,#e2e6ea);background:var(--surface,#fff);' +
'color:var(--text,#1a1d21);font:1rem/1.5 system-ui,-apple-system,Segoe UI,sans-serif;';

const title = document.createElement('h1');
title.textContent = copy.title;
title.style.cssText = 'margin:0 0 .6rem;font-size:1.25rem;';

const body = document.createElement('p');
body.textContent = copy.body;
body.style.cssText = 'margin:0 0 1.2rem;color:var(--text-muted,#5b6470);';

const retry = document.createElement('button');
retry.type = 'button';
retry.textContent = copy.retry;
retry.style.cssText =
'padding:.55rem 1.1rem;border:0;border-radius:8px;cursor:pointer;font:inherit;' +
'background:var(--brand,#2563eb);color:var(--brand-ink,#fff);';
retry.addEventListener('click', () => window.location.reload());

const details = document.createElement('details');
details.style.cssText = 'margin-top:1.2rem;font-size:.82rem;color:var(--text-muted,#5b6470);';
const summary = document.createElement('summary');
summary.textContent = copy.details;
summary.style.cssText = 'cursor:pointer;';
const pre = document.createElement('pre');
// textContent, not innerHTML: the URL comes back from the network and is never markup here.
pre.textContent = lastFailure
? `${lastFailure.reason}\n${lastFailure.url}`
: String(error && error.message || error);
pre.style.cssText = 'white-space:pre-wrap;word-break:break-all;margin:.5rem 0 0;';
details.append(summary, pre);

panel.append(title, body, retry, details);
app.append(panel);
// A 503 on the Blazor script leaves this file running with nothing to call. Saying so now beats
// waiting for the watchdog: we already know it is never going to start.
if (typeof Blazor === 'undefined') {
boot.fail('Blazor.start is unavailable — _framework/blazor.webassembly.js did not load.');
return;
}

Blazor.start({
loadBootResource(type, name, defaultUri, integrity) {
return type === NOT_OURS ? undefined : fetchWithRetry(defaultUri, integrity);
},
}).catch(reportFailure);
}).then(
() => boot.ok(),
// Reached by startup failures that are not downloads at all. Those carry their own error,
// and it is the one to show — the retry's own failures have already spoken for themselves.
error => boot.fail(String(error && error.message || error))
);
})();
Loading
Loading