diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 0a10a6e4534..f42074944ce 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,6 +18,7 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } + #failure[hidden] { display: none; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 3f4516bda1a..6431ac08230 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -16,6 +16,26 @@ const bridge = window.__TAURI__; const invoke = bridge && bridge.core && bridge.core.invoke; const listen = bridge && bridge.event && bridge.event.listen; +// The shell owns the sequence and its deadline. The page has no deadline of its own: an invoke +// whose command never answers returns a promise that neither settles nor rejects, and the page +// then keeps its initial markup forever - the headline still says the run is starting, the +// checklist is empty, and the only thing on screen is a Retry button with an empty diagnostic. +// That is indistinguishable from a hung product. Bounding the handshake turns the silence into a +// failure the page can report and the user can copy. +const HANDSHAKE_DEADLINE_MS = 5000; + +function withDeadline(work, what) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('the shell did not answer ' + what + ' within ' + HANDSHAKE_DEADLINE_MS + ' ms')); + }, HANDSHAKE_DEADLINE_MS); + Promise.resolve(work).then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + const headline = document.querySelector("#headline"); const detail = document.querySelector("#detail"); const phaseList = document.querySelector("#phases"); @@ -120,12 +140,12 @@ async function start() { return; } try { - phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + phases = (await withDeadline(invoke("startup_phases"), "startup_phases")).filter((phase) => !phase.terminal); render(null); // The listener goes on before the snapshot is read, so a transition landing between the two is // delivered rather than lost. - await listen("startup-phase", (event) => apply(event.payload)); - apply(await invoke("startup_snapshot")); + await withDeadline(listen("startup-phase", (event) => apply(event.payload)), "the startup-phase subscription"); + apply(await withDeadline(invoke("startup_snapshot"), "startup_snapshot")); } catch (error) { reportPageFailure("The startup surface could not reach the shell.", error); } diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 781416398c5..2f1e6876fd6 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -197,3 +197,31 @@ describe("desktop startup surface", () => { expect(page).toContain("clipboard.writeText"); }); }); + +/** + * The surface has to be able to stay silent. + * + * Two defects made it speak when it had nothing to say and stay quiet when it did. An id rule + * with display: grid outranks the user-agent [hidden] { display: none } rule, so the failure + * block - the Retry button and the empty diagnostic box - was painted during every normal start. + * And an invoke whose command never answers returns a promise that neither settles nor rejects, + * so the page kept its initial markup for as long as the shell stayed silent. Together they are + * the screen a user reads as a dead application: a starting headline, no checklist, one Retry. + */ +describe("the bootstrap page reports only what it was told", () => { + const markup = readFileSync(repoPath("desktop/ui/index.html"), "utf8"); + const page = readFileSync(PAGE, "utf8"); + + test("the failure block honours its hidden attribute", () => { + expect(/#failure\[hidden\][^{]*\{[^}]*display:\s*none/.test(markup)).toBe(true); + }); + + test("the handshake with the shell is bounded", () => { + expect(page).toContain("HANDSHAKE_DEADLINE_MS"); + for (const command of ["startup_phases", "startup_snapshot"]) { + const bounded = 'withDeadline(invoke(\"' + command + '\")'; + expect(page.includes(bounded)).toBe(true); + } + expect(page).toContain("withDeadline(listen("); + }); +});