experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(page): hung-load timeout surfaces error overlay (no more blank-white-page-forever)

User-reported regression with http://www.metikmusic.com/ — a server that
accepts the connection but never sends a response. Before this fix the
page-host's loading-timeout safety net silently force-cleared the
spinner after 30s and left the user staring at a blank white page with
no actionable feedback.

Root cause: `loadingLifecycle.startLoading()` in app/page/page.js had a
30s setTimeout whose only action was `this.stopLoading()`. did-fail-load
never fired (the server keeps the socket open) so the existing
`showLoadErrorOverlay` path was never taken.

Fix: the timeout callback now also calls `showLoadErrorOverlay` with
the in-flight URL, code 'TIMEOUT', and a "page took too long to load"
description. Same overlay shape as did-fail-load — Retry + Close
buttons, Peek-branded card.

URL attribution: the timeout reads `latestNavigationUrl` first
(updated by did-start-navigation, holds the URL we're trying to load),
falling back to `webview.getURL()` (which returns the previously-
displayed URL during a pending navigation — using it would attribute
the timeout to the wrong page) and finally the initial `targetUrl`.
The first-pass implementation got this wrong and the test caught it.

Test hook: `LOADING_TIMEOUT_MS` is now `getLoadingTimeoutMs()`, a
function that consults `window.__loadingTimeoutMs` first. Playwright
overrides to 1.5s so the test fires in <5s instead of >30s. Production
runs always use the 30s default (the override is unset).

Tests:
* `tests/desktop/page-load-failure.spec.ts` "Hung load: …" (NEW) —
test server gains a /hang endpoint that accepts the connection but
never responds; the spec opens page-host on /ok, waits for that to
finish, sets the timeout override to 1.5s, navigates to /hang,
asserts the overlay appears with the /hang URL + "took too long"
reason + Retry button, and the webview is no longer in 'loading'
state (the original "endless glow border" symptom).
* Suite total: 6/6 (was 5/5).

Tasks doc updated; bug entry marked done with the actual repro path
documented for future archaeology.

+100 -5
+29 -4
app/page/page.js
··· 758 758 // Simple state machine: 'loading' or 'loaded'. 759 759 // Visual effect (CSS class) subscribes to state changes. 760 760 761 - const LOADING_TIMEOUT_MS = 30000; // 30 second safety net 761 + // Default safety-net timeout for hung loads. Overridable via 762 + // `window.__loadingTimeoutMs` so Playwright specs can drive the timeout 763 + // path in <30 seconds. Production runs always use the default. 764 + const DEFAULT_LOADING_TIMEOUT_MS = 30000; 765 + function getLoadingTimeoutMs() { 766 + const override = window.__loadingTimeoutMs; 767 + return typeof override === 'number' && override > 0 ? override : DEFAULT_LOADING_TIMEOUT_MS; 768 + } 762 769 763 770 const loadingLifecycle = { 764 771 _state: 'idle', ··· 773 780 this._notify(); 774 781 DEBUG && console.log('[page] Loading started'); 775 782 776 - // Safety net: auto-clear loading state after timeout to prevent permanently frozen windows 783 + // Safety net: if loading hasn't completed within the timeout window, 784 + // force-clear the loading state AND surface an error overlay so the 785 + // user sees something actionable instead of an indefinite spinner. 786 + // Repro: http://www.metikmusic.com/ (DNS resolves but server hangs). 777 787 this._clearTimeout(); 788 + const timeoutMs = getLoadingTimeoutMs(); 778 789 this._timeoutId = setTimeout(() => { 779 790 if (this._state === 'loading') { 780 - console.warn('[page] Loading timeout after', LOADING_TIMEOUT_MS, 'ms — force clearing loading state'); 791 + console.warn('[page] Loading timeout after', timeoutMs, 'ms — surfacing error overlay'); 781 792 this.stopLoading(); 793 + // Render the same overlay shown for did-fail-load. Prefer the 794 + // tracked navigation URL — webview.getURL() returns the previously- 795 + // displayed URL during a pending navigation, which would attribute 796 + // the timeout to the wrong page. 797 + let url = latestNavigationUrl; 798 + if (!url) { 799 + try { url = webview.getURL(); } catch { /* ignore */ } 800 + } 801 + if (!url) url = targetUrl || ''; 802 + showLoadErrorOverlay( 803 + url, 804 + 'TIMEOUT', 805 + 'The page took too long to load.', 806 + ); 782 807 } 783 - }, LOADING_TIMEOUT_MS); 808 + }, timeoutMs); 784 809 }, 785 810 786 811 stopLoading() {
+1 -1
docs/tasks.md
··· 25 25 26 26 - [ ] **Web permission requests — Phase 3: persist per-origin decisions.** Phases 1 + 2 shipped 2026-04-27. Phase 1: `backend/electron/permission-handler.ts` replaces the blanket-allow `chrome-extensions.ts` had installed on the entire profile session. Phase 2: risky permissions (`geolocation`, `media`, `midi`, `midiSysex`, `display-capture`) now resolve to `'prompt'`; backend stores the deferred callback in `pendingRequests`, looks up the host page-host BrowserWindow via the new `webview-guest-registry.ts` (populated on `did-attach-webview` in `windows.ts`), and publishes `page:permission-request` `{requestId, windowId, permission, origin, label}` to the host renderer. Page-host renders a Peek-branded `.permission-prompt` overlay near the top of the page; click Allow/Deny publishes `page:permission-response` `{requestId, allowed}`; backend resolves the Chromium callback. Window-close cleanup denies any pending requests. Pure policy lives in `permission-policy.ts` (no electron imports — unit-testable under plain node). Phase 3: persist per-origin decisions in `feature_settings` (`featureId='page-permissions'`, key=`{permission}:{origin}`, value=`{allowed, timestamp}`) so the user isn't re-prompted on every page load; add settings UI to query/revoke. Phase 4: prompt UI polish — remember-this-decision toggle, favicon next to origin, multi-prompt queueing UX (today they stack but each fires in arrival order). 27 27 28 - - [ ] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/` (DNS resolves but server returns nothing useful, or DNS fails). The page-host webview just sits on white with no feedback. We should handle the full lifecycle of a failed page open: DNS failure, connection refused, TLS errors, HTTP 4xx/5xx, hung loads, ERR_NAME_NOT_RESOLVED, ERR_INTERNET_DISCONNECTED. Show a Peek-branded error UI inside the canvas with: the URL that failed, the underlying error reason, an obvious Retry button, and a "Go back" / "Close" affordance. Likely hooks: `did-fail-load` and `did-fail-provisional-load` on the webview's webContents in `app/page/page.js`, plus `certificate-error` on the session. Audit other entry points too (cmd web search, external URL handler, address bar) to ensure they all funnel into the same error UI. 28 + - [x] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/`. DNS-failure / connection-refused paths were covered earlier via `did-fail-load` + `.load-error-overlay`. The remaining gap (and the actual repro) was hung loads — server accepts the connection, never responds. The 30s loading-timeout safety net silently flipped the spinner off and left the user staring at a blank page. Fixed 2026-04-27: timeout now surfaces the same `.load-error-overlay` ("The page took too long to load.") with the in-flight URL (read from `latestNavigationUrl`, not `webview.getURL()` which returns the previously-displayed URL during a pending navigation). Loading timeout is now overridable via `window.__loadingTimeoutMs` so the Playwright spec can fire the path in 1.5s instead of 30s. Test: `tests/desktop/page-load-failure.spec.ts` "Hung load: …" (6/6, was 5/5). Future polish (HTTP 4xx/5xx, TLS errors, certificate-error on session) tracked separately if/when reported. 29 29 30 30 - [ ] **Tauri: rename `pubsub:ext:*` startup topics to `pubsub:feature:*`.** `backend/tauri/src-tauri/src/lib.rs` still emits `pubsub:ext:startup:phase` and `pubsub:ext:all-loaded`. Electron side renamed 2026-04-24; Tauri/mobile is on hold per the v1-removal plan, but track this so it lands when Tauri is unfrozen. 31 31 - [ ] **Migrate bootstrap IPC channels to tile:lifecycle:* namespace.** `session-restore-pending` and `frontend-ready` in `backend/electron/entry.ts` are bare `ipcMain.handle()` registrations from before the tile system initializes. They're only reachable from trustedBuiltin renderers via `api.invoke()`, so the security gap is theoretical, but they're the last bare main-process IPC handlers outside `tile-ipc-gate.ts`. Decision skipped 2026-04-24: leaving them bare for now since they exist *before* any tile loads — the indirection through tile-ipc-gate would require either bootstrapping the gate earlier or having two IPC modes (bootstrap vs. post-init). Worth revisiting if tile-ipc-gate ever gains a "no token required for these specific channels" mode, or if entry.ts gets folded into a tile lifecycle.
+70
tests/desktop/page-load-failure.spec.ts
··· 30 30 if (req.url === '/ok') { 31 31 res.writeHead(200, { 'Content-Type': 'text/html' }); 32 32 res.end('<!DOCTYPE html><html><body><h1 id="title">OK</h1></body></html>'); 33 + } else if (req.url === '/hang') { 34 + // Accept the connection but never respond — drives the page-host's 35 + // loading-timeout safety net. Repro for the user-reported bug 36 + // ("metikmusic.com — endless glow border, blank white page"). 37 + // No-op handler keeps the socket open until the test closes. 33 38 } else { 34 39 res.writeHead(404); 35 40 res.end('Not found'); ··· 223 228 expect(stuckOnLoading).toBe(false); 224 229 } finally { 225 230 await closeWindow(sharedBgWindow, second.windowId); 231 + } 232 + }); 233 + 234 + test('Hung load: loading-timeout safety net surfaces the error overlay (regression: blank-white-page-forever)', async () => { 235 + // User-reported: http://www.metikmusic.com/ — DNS resolves, server 236 + // accepts the connection, but never responds. Before this fix, the 237 + // page-host's 30s loading timeout silently flipped the spinner off 238 + // and left the user staring at a blank white page with no feedback. 239 + // Now the timeout surfaces the same overlay as did-fail-load. 240 + const goodUrl = `http://127.0.0.1:${serverPort}/ok`; 241 + const hangUrl = `http://127.0.0.1:${serverPort}/hang`; 242 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, goodUrl); 243 + 244 + try { 245 + // Wait for the initial load to complete so we don't race the 246 + // loading-timeout override with the first navigation's setTimeout. 247 + await pageWindow.waitForFunction( 248 + () => { 249 + const webview = document.getElementById('content'); 250 + return webview && webview.classList.contains('loaded'); 251 + }, 252 + undefined, 253 + { timeout: 15000 }, 254 + ); 255 + 256 + // Override the loading timeout to 1.5s so the hung-load path fires 257 + // before the test wall-clock budget. Production stays at 30s. 258 + await pageWindow.evaluate(() => { 259 + (window as any).__loadingTimeoutMs = 1500; 260 + }); 261 + 262 + // Navigate the webview to the hung endpoint. Server accepts the 263 + // connection but never sends a response → loading state stays 264 + // 'loading' until the safety-net timeout fires. 265 + await pageWindow.evaluate((u: string) => { 266 + const webview = document.getElementById('content') as any; 267 + webview.loadURL(u); 268 + }, hangUrl); 269 + 270 + // Overlay must appear within ~3s (1.5s timeout + headroom). 271 + await waitForErrorOverlay(pageWindow, 5000); 272 + 273 + const overlay = await pageWindow.evaluate(() => { 274 + const el = document.querySelector('.load-error-overlay'); 275 + if (!el) return null; 276 + return { 277 + urlText: el.querySelector('.load-error-url')?.textContent || '', 278 + reasonText: el.querySelector('.load-error-reason')?.textContent || '', 279 + hasRetry: !!el.querySelector('.load-error-retry'), 280 + }; 281 + }); 282 + expect(overlay).toBeTruthy(); 283 + expect(overlay!.urlText).toContain('/hang'); 284 + expect(overlay!.reasonText.toLowerCase()).toContain('took too long'); 285 + expect(overlay!.hasRetry).toBe(true); 286 + 287 + // The webview must NOT still be in 'loading' state — that was the 288 + // original "endless glow border" symptom. 289 + const stuckOnLoading = await pageWindow.evaluate(() => { 290 + const webview = document.getElementById('content'); 291 + return !!(webview && webview.classList.contains('loading')); 292 + }); 293 + expect(stuckOnLoading).toBe(false); 294 + } finally { 295 + await closeWindow(sharedBgWindow, windowId); 226 296 } 227 297 }); 228 298 });