experiments in a post-browser web
10
fork

Configure Feed

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

feat(pubsub): Phase 7 — timeout UX for load-on-dispatch

+409 -7
+260
backend/electron/tile-lazy-timeout.test.ts
··· 1 + /** 2 + * Unit tests for tile-lazy load-on-dispatch failure UX (Phase 7). 3 + * 4 + * Exercises the two user-visible failure paths when a lazy tile can't 5 + * accept a dispatched command: 6 + * 1. Load timeout — waitForTileReady never resolves; the 10s timer 7 + * fires; a `notification:show` publish surfaces the failure and 8 + * the caller's result topic is published with `{error}` so the 9 + * cmd panel proxy promise settles immediately. 10 + * 2. Crashed during load — `tile:state-changed` fires a 11 + * `loading → crashed` transition while a dispatch is still in 12 + * flight; pending dispatches reject immediately (no 10s wait). 13 + * 14 + * See docs/pubsub-state-machine.md §Load-on-dispatch — timeout UX. 15 + */ 16 + 17 + import { describe, it, beforeEach, before, after } from 'node:test'; 18 + import * as assert from 'node:assert'; 19 + 20 + import { 21 + publish, subscribe, unsubscribe, 22 + getSystemAddress, 23 + __clearPrePublishHooksForTest, 24 + } from './pubsub.js'; 25 + import { 26 + registerLazyTile, 27 + installLoadOnDispatchHook, 28 + __setTileLazyLauncherForTest, 29 + __resetTileLazyStateForTest, 30 + } from './tile-lazy.js'; 31 + import type { TileManifestV2 } from './tile-manifest.js'; 32 + import type { TileLaunchOptions, TileLaunchResult } from './tile-launcher.js'; 33 + 34 + // ─── Fake launcher that never reaches ready ────────────────────────── 35 + 36 + interface FakeLauncherState { 37 + launchCalls: TileLaunchOptions[]; 38 + loaded: Set<string>; 39 + /** 40 + * Unresolved ready promises — the whole point is that we never resolve 41 + * these so the load timeout fires. Each `waitForTileReady` call 42 + * returns a fresh pending promise. 43 + */ 44 + pendingReady: Array<(value: void) => void>; 45 + } 46 + 47 + function makeFakeLauncher(state: FakeLauncherState) { 48 + return { 49 + launchTile: (options: TileLaunchOptions): TileLaunchResult | void => { 50 + state.launchCalls.push(options); 51 + const entryId = options.entryId || options.manifest.tiles[0]?.id || 'background'; 52 + queueMicrotask(() => { 53 + state.loaded.add(`${options.manifest.id}:${entryId}`); 54 + }); 55 + }, 56 + waitForTileReady: (_tileId: string, _entryId: string): Promise<void> => { 57 + // Return a promise that intentionally never resolves. The timeout 58 + // inside tile-lazy's `loadLazyTile` is responsible for racing this. 59 + return new Promise<void>((resolve) => { 60 + state.pendingReady.push(resolve); 61 + }); 62 + }, 63 + isTileLoaded: (tileId: string, entryId: string): boolean => { 64 + return state.loaded.has(`${tileId}:${entryId}`); 65 + }, 66 + }; 67 + } 68 + 69 + // ─── Manifest fixture ───────────────────────────────────────────────── 70 + 71 + function makeCommandManifest(id: string, cmdName: string): TileManifestV2 { 72 + return { 73 + manifestVersion: 2, 74 + id, 75 + name: id, 76 + builtin: true, 77 + tiles: [ 78 + { 79 + id: 'background', 80 + type: 'background', 81 + url: 'background.html', 82 + lazy: true, 83 + }, 84 + ], 85 + commands: [ 86 + { 87 + name: cmdName, 88 + description: `${cmdName} description`, 89 + action: { type: 'execute' }, 90 + }, 91 + ], 92 + capabilities: { 93 + pubsub: { scopes: ['self', 'global'] }, 94 + }, 95 + }; 96 + } 97 + 98 + function flushMicrotasks(): Promise<void> { 99 + return new Promise(resolve => setImmediate(resolve)); 100 + } 101 + 102 + // ─── Tests ──────────────────────────────────────────────────────────── 103 + 104 + describe('tile-lazy load-on-dispatch timeout UX (Phase 7)', () => { 105 + let launcherState: FakeLauncherState; 106 + // Collected notification:show publishes. 107 + const notifications: Array<{ 108 + type?: string; 109 + title?: string; 110 + body?: string; 111 + context?: { tileId?: string; cmdName?: string; reason?: string }; 112 + }> = []; 113 + const notifySource = 'peek://tile-lazy-timeout-test/notify-observer'; 114 + 115 + before(() => { 116 + subscribe(notifySource, 'notification:show', (msg: unknown) => { 117 + notifications.push(msg as typeof notifications[number]); 118 + }); 119 + }); 120 + 121 + after(() => { 122 + unsubscribe(notifySource, 'notification:show'); 123 + }); 124 + 125 + beforeEach(() => { 126 + __resetTileLazyStateForTest(); 127 + __clearPrePublishHooksForTest(); 128 + installLoadOnDispatchHook(); 129 + launcherState = { 130 + launchCalls: [], 131 + loaded: new Set(), 132 + pendingReady: [], 133 + }; 134 + __setTileLazyLauncherForTest(makeFakeLauncher(launcherState)); 135 + notifications.length = 0; 136 + }); 137 + 138 + it('publishes a structured notification and resolves the resultTopic with error on load timeout', async () => { 139 + const manifest = makeCommandManifest('tile-timeout-1', 'slow-cmd'); 140 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 141 + 142 + // Collect whatever comes back on the resultTopic so we can assert 143 + // the proxy's promise settles (rather than hanging 30s). 144 + const resultTopic = 'cmd:execute:slow-cmd:result:unit-test-1'; 145 + let resultPayload: unknown = null; 146 + subscribe('peek://test-dispatcher/result', resultTopic, (msg) => { 147 + resultPayload = msg; 148 + }); 149 + 150 + // Use node:test's mock timers so we can jump past LAZY_LOAD_TIMEOUT_MS 151 + // (10000ms) without waiting 10 real seconds. 152 + const { mock } = await import('node:test'); 153 + mock.timers.enable({ apis: ['setTimeout'] }); 154 + 155 + try { 156 + publish('peek://test-dispatcher', 'cmd:execute:slow-cmd', { 157 + expectResult: true, 158 + resultTopic, 159 + }); 160 + 161 + // Let the hook register the pending dispatch and kick off the 162 + // (pending, never-resolving) launch. 163 + for (let i = 0; i < 5; i++) await flushMicrotasks(); 164 + assert.strictEqual(launcherState.launchCalls.length, 1, 165 + 'tile should have been launched'); 166 + 167 + // Fire the load timer. reportDispatchFailure runs in the same tick, 168 + // so both the notification and the resultTopic publish happen 169 + // synchronously before the timeout handler's reject propagates. 170 + mock.timers.tick(11000); 171 + for (let i = 0; i < 10; i++) await flushMicrotasks(); 172 + } finally { 173 + mock.timers.reset(); 174 + } 175 + 176 + // Notification assertions — structured error shape the plan spec'd. 177 + assert.strictEqual(notifications.length, 1, 178 + `expected exactly one notification:show publish, got ${notifications.length}`); 179 + const n = notifications[0]; 180 + assert.strictEqual(n.type, 'error'); 181 + assert.ok(n.title && n.title.toLowerCase().includes("didn't load"), 182 + `notification title should signal load failure, got: ${n.title}`); 183 + assert.ok(n.body && n.body.includes('slow-cmd'), 184 + `notification body should name the command, got: ${n.body}`); 185 + assert.ok(n.body && /10000|10 second/.test(n.body), 186 + `notification body should mention the timeout duration, got: ${n.body}`); 187 + assert.strictEqual(n.context?.tileId, 'tile-timeout-1'); 188 + assert.strictEqual(n.context?.cmdName, 'slow-cmd'); 189 + assert.strictEqual(n.context?.reason, 'timeout'); 190 + 191 + // Proxy promise settlement — resultTopic must have been published 192 + // with a structured error payload, so the cmd panel's subscribe 193 + // resolves immediately instead of hanging on its 30s fallback timer. 194 + assert.ok(resultPayload, 195 + 'resultTopic should have been published (dispatcher promise hung otherwise)'); 196 + const rp = resultPayload as { error?: string }; 197 + assert.ok(typeof rp.error === 'string' && rp.error.length > 0, 198 + `resultTopic payload should carry an error message, got: ${JSON.stringify(rp)}`); 199 + 200 + unsubscribe('peek://test-dispatcher/result', resultTopic); 201 + }); 202 + 203 + it('rejects pending dispatches immediately when the tile transitions loading → crashed', async () => { 204 + const manifest = makeCommandManifest('tile-crash-2', 'crashy-cmd'); 205 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 206 + 207 + const resultTopic = 'cmd:execute:crashy-cmd:result:unit-test-2'; 208 + let resultPayload: unknown = null; 209 + subscribe('peek://test-dispatcher/result', resultTopic, (msg) => { 210 + resultPayload = msg; 211 + }); 212 + 213 + // Kick off the dispatch — the hook will register a pending entry 214 + // against `tile-crash-2` and begin awaiting waitForTileReady (which 215 + // never resolves in this fixture). 216 + publish('peek://test-dispatcher', 'cmd:execute:crashy-cmd', { 217 + expectResult: true, 218 + resultTopic, 219 + }); 220 + for (let i = 0; i < 5; i++) await flushMicrotasks(); 221 + 222 + assert.strictEqual(launcherState.launchCalls.length, 1); 223 + 224 + // Simulate the `loading → crashed` transition. The tile-lifecycle 225 + // engine publishes `tile:state-changed` as System when a render 226 + // process crashes during load; the crash observer in tile-lazy 227 + // subscribes to that topic and drains pending dispatches. Publish 228 + // directly here so we don't depend on wiring a real BrowserWindow. 229 + publish(getSystemAddress(), 'tile:state-changed', { 230 + tileId: 'tile-crash-2', 231 + entryId: 'background', 232 + from: 'loading', 233 + to: 'crashed', 234 + trigger: 'RENDER_GONE', 235 + ts: Date.now(), 236 + }); 237 + 238 + // Observer runs synchronously under the publish; a microtask flush 239 + // covers any async subscribe handlers elsewhere. 240 + for (let i = 0; i < 3; i++) await flushMicrotasks(); 241 + 242 + assert.strictEqual(notifications.length, 1, 243 + `expected one notification, got ${notifications.length}`); 244 + const n = notifications[0]; 245 + assert.strictEqual(n.type, 'error'); 246 + assert.ok(n.body && n.body.toLowerCase().includes('crashed'), 247 + `crashed-path body should mention 'crashed', got: ${n.body}`); 248 + assert.strictEqual(n.context?.reason, 'crashed'); 249 + assert.strictEqual(n.context?.tileId, 'tile-crash-2'); 250 + assert.strictEqual(n.context?.cmdName, 'crashy-cmd'); 251 + 252 + assert.ok(resultPayload, 253 + 'resultTopic should have been published on crash (pending dispatch otherwise still hung)'); 254 + const rp = resultPayload as { error?: string }; 255 + assert.ok(typeof rp.error === 'string' && rp.error.length > 0, 256 + `crashed-path resultTopic payload should carry an error, got: ${JSON.stringify(rp)}`); 257 + 258 + unsubscribe('peek://test-dispatcher/result', resultTopic); 259 + }); 260 + });
+149 -7
backend/electron/tile-lazy.ts
··· 28 28 */ 29 29 30 30 import { 31 - publish, registerPrePublishHook, getSystemAddress, 31 + publish, subscribe, registerPrePublishHook, getSystemAddress, 32 32 type PrePublishHookResult, 33 33 } from './pubsub.js'; 34 34 import { ··· 80 80 lazyTileRegistry.clear(); 81 81 loadingTiles.clear(); 82 82 declaredLazyEventTopics.clear(); 83 + pendingDispatchesByTile.clear(); 83 84 _hookInstalled = false; 85 + _crashObserverInstalled = false; 84 86 } 85 87 86 88 // ─── State ─────────────────────────────────────────────────────────── ··· 105 107 /** Timeout for lazy load (ms) */ 106 108 const LAZY_LOAD_TIMEOUT_MS = 10000; 107 109 110 + /** 111 + * Phase 7 — Timeout UX for load-on-dispatch. 112 + * 113 + * Each entry in this map tracks a `cmd:execute:{name}` publish that was 114 + * routed through the hook while its target tile was in `registered`. If 115 + * the tile never reaches `ready` — either because the load timer fires 116 + * or because the render process crashes during boot — we reject every 117 + * pending dispatch for that tile with a user-visible notification 118 + * instead of letting the cmd panel's 30s proxy timeout absorb the 119 + * failure silently. 120 + * 121 + * Keyed by tileId. A tile can have multiple concurrent dispatches 122 + * (user fires command, navigates, fires another while the first is 123 + * still loading); each one is a separate `PendingDispatch` entry. 124 + */ 125 + interface PendingDispatch { 126 + cmdName: string; 127 + resultTopic: string | null; 128 + } 129 + const pendingDispatchesByTile = new Map<string, Set<PendingDispatch>>(); 130 + 131 + function registerPendingDispatch(tileId: string, entry: PendingDispatch): void { 132 + let set = pendingDispatchesByTile.get(tileId); 133 + if (!set) { 134 + set = new Set(); 135 + pendingDispatchesByTile.set(tileId, set); 136 + } 137 + set.add(entry); 138 + } 139 + 140 + function clearPendingDispatch(tileId: string, entry: PendingDispatch): void { 141 + const set = pendingDispatchesByTile.get(tileId); 142 + if (!set) return; 143 + set.delete(entry); 144 + if (set.size === 0) pendingDispatchesByTile.delete(tileId); 145 + } 146 + 147 + function drainPendingDispatches(tileId: string): PendingDispatch[] { 148 + const set = pendingDispatchesByTile.get(tileId); 149 + if (!set) return []; 150 + const entries = Array.from(set); 151 + pendingDispatchesByTile.delete(tileId); 152 + return entries; 153 + } 154 + 155 + /** 156 + * Publish a user-visible error notification and resolve the caller's 157 + * result topic (if any) with a structured error — so the cmd panel's 158 + * proxy promise settles immediately and the state machine returns to 159 + * IDLE instead of hanging on the 30s proxy fallback timeout. 160 + * 161 + * The notification topic is `notification:show` per docs/pubsub-state-machine.md 162 + * §Load-on-dispatch — timeout UX. No dedicated notification subscriber 163 + * exists yet in Peek; any future notification surface (HUD overlay, 164 + * toast, OS notification) listens to this topic. 165 + */ 166 + function reportDispatchFailure( 167 + tileId: string, 168 + entry: PendingDispatch, 169 + reason: 'timeout' | 'crashed', 170 + timeoutMs: number, 171 + ): void { 172 + const body = 173 + reason === 'timeout' 174 + ? `Command "${entry.cmdName}" couldn't run because its tile failed to load within ${timeoutMs}ms.` 175 + : `Command "${entry.cmdName}" couldn't run because its tile crashed while loading.`; 176 + 177 + try { 178 + publish(getSystemAddress(), 'notification:show', { 179 + type: 'error', 180 + title: "Tile didn't load", 181 + body, 182 + context: { tileId, cmdName: entry.cmdName, reason }, 183 + }); 184 + } catch (err) { 185 + console.error('[tile-lazy] notification:show publish threw:', err); 186 + } 187 + 188 + // Resolve the proxy's result topic with an error shape so the cmd 189 + // panel returns to IDLE immediately. This mirrors the "error result" 190 + // contract other handlers use (see app/cmd/commands.js proxy 191 + // subscribe of `cmd:execute:{name}:result`). 192 + if (entry.resultTopic) { 193 + try { 194 + publish(getSystemAddress(), entry.resultTopic, { error: body }); 195 + } catch (err) { 196 + console.error( 197 + `[tile-lazy] resultTopic publish threw for ${entry.resultTopic}:`, 198 + err, 199 + ); 200 + } 201 + } 202 + } 203 + 108 204 // ─── Registration ──────────────────────────────────────────────────── 109 205 110 206 /** ··· 181 277 * Call once at app init, before any command can fire. Idempotent. 182 278 */ 183 279 let _hookInstalled = false; 280 + let _crashObserverInstalled = false; 184 281 export function installLoadOnDispatchHook(): void { 185 - if (_hookInstalled) return; 186 - registerPrePublishHook(dispatchHookPredicate, dispatchHookBody); 187 - _hookInstalled = true; 282 + if (!_hookInstalled) { 283 + registerPrePublishHook(dispatchHookPredicate, dispatchHookBody); 284 + _hookInstalled = true; 285 + } 286 + installCrashObserver(); 287 + } 288 + 289 + /** 290 + * Subscribe to the lifecycle observer topic `tile:state-changed` and 291 + * reject any pending dispatches when a tile transitions `loading → 292 + * crashed`. Rejecting eagerly (not waiting for the 10s timeout) is 293 + * the whole point of Phase 7's crashed-during-load path. 294 + */ 295 + function installCrashObserver(): void { 296 + if (_crashObserverInstalled) return; 297 + _crashObserverInstalled = true; 298 + subscribe('peek://tile-lazy/crash-observer', 'tile:state-changed', (msg: unknown) => { 299 + const t = msg as { tileId?: string; from?: string; to?: string } | null; 300 + if (!t || typeof t.tileId !== 'string') return; 301 + // Only the `loading → crashed` edge matters here. A crash from 302 + // ready/visible is not a load failure — those are regular runtime 303 + // crashes and aren't the concern of this path. 304 + if (t.from !== 'loading' || t.to !== 'crashed') return; 305 + const pending = drainPendingDispatches(t.tileId); 306 + for (const entry of pending) { 307 + reportDispatchFailure(t.tileId, entry, 'crashed', LAZY_LOAD_TIMEOUT_MS); 308 + } 309 + }); 188 310 } 189 311 190 312 /** ··· 266 388 // Programmatic command: load the tile (if needed), then let the 267 389 // publish continue — by which point the tile's background has 268 390 // subscribed its real handler. 391 + const typedMsg = msg as { resultTopic?: string } | null; 392 + const pending: PendingDispatch = { 393 + cmdName: name, 394 + resultTopic: typedMsg?.resultTopic ?? null, 395 + }; 396 + registerPendingDispatch(tileId, pending); 269 397 try { 270 398 await ensureTileLoaded(tileId); 399 + // Load succeeded — drop the pending entry so a later crash or 400 + // timeout on a sibling dispatch doesn't fire a stale notification 401 + // for this one. 402 + clearPendingDispatch(tileId, pending); 271 403 } catch (err) { 404 + // If the crash observer already drained this entry (loading → 405 + // crashed fired before the load timer), don't double-report. The 406 + // observer removes drained entries from the map. 407 + const set = pendingDispatchesByTile.get(tileId); 408 + const stillPending = set?.has(pending) === true; 409 + if (stillPending) { 410 + clearPendingDispatch(tileId, pending); 411 + reportDispatchFailure(tileId, pending, 'timeout', LAZY_LOAD_TIMEOUT_MS); 412 + } 272 413 console.error(`[tile-lazy] Failed to load tile ${tileId} for command ${name}:`, err); 273 - // Delivery continues anyway so that any already-registered 274 - // fallback subscriber sees the message. If none exist, the cmd 275 - // panel's result-timeout will eventually surface the failure. 414 + // Skip delivery of the original cmd:execute publish. No handler 415 + // is alive to receive it, and the notification + resultTopic 416 + // error publish above have already settled the caller's promise. 417 + return 'skip'; 276 418 } 277 419 return 'continue'; 278 420 }