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