diff --git a/.changeset/fix-search-worker-init-fallback.md b/.changeset/fix-search-worker-init-fallback.md new file mode 100644 index 000000000..0531303d8 --- /dev/null +++ b/.changeset/fix-search-worker-init-fallback.md @@ -0,0 +1,5 @@ +--- +'charm': patch +--- + +fix: fail fast when search worker startup stalls diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 33ac3e16f..adcc10b9e 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -35,6 +35,7 @@ import type { WorkerInMessage, WorkerOutMessage, } from '$plugins/search-worker/types'; +import { buildSearchWorkerRuntimeErrorMessage } from '$plugins/search-worker/workerLifecycle'; // eslint-disable-next-line import/default, import/no-unresolved -- Vite ?worker suffix returns Worker constructor import SearchWorkerConstructor from '$plugins/search-worker/searchWorker.ts?worker'; @@ -67,6 +68,10 @@ type SearchIndexCtx = { initError: string | null; }; +type FailWorkerOptions = { + settlePendingQueriesWithEmptyResults?: boolean; +}; + // ── Context ────────────────────────────────────────────────────────────────── const SearchIndexContext = createContext(null); @@ -166,6 +171,9 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false); const [isBackfilling, setIsBackfilling] = useState(false); const [initError, setInitError] = useState(null); + const failWorkerRef = useRef<((errorMsg: string, options?: FailWorkerOptions) => void) | null>( + null + ); const workerRef = useRef(null); const pendingQueriesRef = useRef>(new Map()); @@ -613,7 +621,9 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { case 'ERROR': // eslint-disable-next-line no-console console.error('[SearchIndex worker error]', msg.message); - setInitError(`Worker error: ${msg.message}`); + failWorkerRef.current?.(`Worker error: ${msg.message}`, { + settlePendingQueriesWithEmptyResults: true, + }); Sentry.addBreadcrumb({ category: 'search.index', message: 'Worker error', @@ -656,6 +666,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const userId = mx.getUserId(); if (!userId) return () => {}; + setInitError(null); Sentry.addBreadcrumb({ category: 'search.index', message: 'Initializing search worker', @@ -728,15 +739,63 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = worker; worker.addEventListener('message', handleWorkerMessage); + const failWorker = (errorMsg: string, options: FailWorkerOptions = {}) => { + clearTimeout(initTimeout); + setInitError(errorMsg); + setIsReady(false); + setIsBackfilling(false); + + if (backfillStartDelayRef.current !== null) { + clearTimeout(backfillStartDelayRef.current); + backfillStartDelayRef.current = null; + } + backfillReadyRef.current = false; + + cancelIdlesRef.current.forEach((cancel) => cancel()); + cancelIdlesRef.current = []; + backfillingRoomsRef.current.clear(); + headlessSetsRef.current.clear(); + backfillQueueRef.current = []; + + for (const { resolve, reject } of pendingQueriesRef.current.values()) { + if (options.settlePendingQueriesWithEmptyResults) { + resolve([]); + continue; + } + reject(new Error(errorMsg)); + } + pendingQueriesRef.current.clear(); + + if (pendingStatsRef.current) { + pendingStatsRef.current.resolve({ + indexedEventCount: 0, + roomCount: 0, + estimatedBytes: 0, + backfillingRoomCount: 0, + }); + pendingStatsRef.current = null; + } + + if (workerRef.current === worker) { + worker.terminate(); + workerRef.current = null; + } + }; + failWorkerRef.current = failWorker; + // Handle worker runtime errors (e.g., MIME type errors from failed imports) const handleWorkerError = (error: ErrorEvent) => { // Null-check error.message — it may be undefined on ErrorEvent (SABLE-52) const message = error?.message ?? ''; - const errorMsg = `Search worker runtime error: ${message || 'Unknown worker error'}`; + const errorMsg = buildSearchWorkerRuntimeErrorMessage({ + message, + filename: error.filename, + lineno: error.lineno, + colno: error.colno, + }); const isMimeError = message.includes('MIME') && message.includes('text/html'); - setInitError(errorMsg); - setIsReady(false); + failWorker(errorMsg, { settlePendingQueriesWithEmptyResults: true }); Sentry.captureException(error.error || new Error(message || 'Unknown worker error'), { level: isMimeError ? 'warning' : 'error', tags: { @@ -773,11 +832,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { // Set a timeout to detect if the worker never sends READY or ERROR (SABLE-54) const initTimeout = setTimeout(() => { - setInitError('Worker initialization timed out (30s) — READY message never received'); - setIsReady(false); - // Terminate the stuck worker so it doesn't consume resources - worker.terminate(); - workerRef.current = null; + failWorker('Worker initialization timed out (30s) — READY message never received'); Sentry.captureMessage('Search worker INIT timeout — READY message never received', { level: 'error', tags: { component: 'search-index' }, @@ -840,6 +895,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { // Sliding sync starts with an initial window of 100 rooms; additional rooms // are received progressively as the list expands, firing ClientEvent.Room. const handleRoomAdded = (room: Room) => { + if (workerRef.current !== worker) return; if (room.isSpaceRoom()) return; if (backfillingRoomsRef.current.has(room.roomId)) return; if (backfillQueueRef.current.some((e) => e.room.roomId === room.roomId)) return; @@ -869,11 +925,29 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { } return () => { + failWorkerRef.current = null; // Ask the worker to flush before terminating. We wait up to 2 s then // force-terminate regardless so the cleanup never hangs. clearTimeout(initTimeout); worker.removeEventListener('message', wrappedHandler as EventListener); worker.removeEventListener('error', handleWorkerError); + if (workerRef.current !== worker) { + setIsReady(false); + setIsBackfilling(false); + mx.removeListener(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); + mx.removeListener( + RoomEvent.Timeline, + handleTimeline as unknown as (...args: unknown[]) => void + ); + mx.removeListener( + ClientEvent.Room, + handleRoomAdded as unknown as (...args: unknown[]) => void + ); + document.removeEventListener('visibilitychange', handleForegroundFocus); + window.removeEventListener('focus', handleForegroundFocus); + window.removeEventListener('pageshow', handleForegroundFocus); + return; + } postToWorker({ type: 'FLUSH' }); const terminateTimeout = setTimeout(() => { worker.terminate(); diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index 25224f4a9..eabc1f5e1 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -7,39 +7,9 @@ import MiniSearch from 'minisearch'; import type { IndexableEvent, BackfillState, WorkerInMessage, WorkerOutMessage } from './types'; +import { buildSearchWorkerInitErrorMessage, openSearchWorkerDb } from './workerLifecycle'; // ── IDB helpers ───────────────────────────────────────────────────────────── - -function openDb(dbName: string): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open(dbName, 3); - req.onupgradeneeded = (event) => { - const db = req.result; - const oldVersion = event.oldVersion; - if (oldVersion < 1) { - db.createObjectStore('index'); - db.createObjectStore('backfill'); - } - // v2: added msgtype to stored fields - if (oldVersion >= 1 && oldVersion < 2) { - db.deleteObjectStore('index'); - db.deleteObjectStore('backfill'); - db.createObjectStore('index'); - db.createObjectStore('backfill'); - } - // v3: added url/file/info/filename to stored fields for media events - if (oldVersion >= 2 && oldVersion < 3) { - db.deleteObjectStore('index'); - db.deleteObjectStore('backfill'); - db.createObjectStore('index'); - db.createObjectStore('backfill'); - } - }; - req.addEventListener('success', () => resolve(req.result)); - req.addEventListener('error', () => reject(req.error)); - }); -} - function idbGet(db: IDBDatabase, store: string, key: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readonly'); @@ -271,7 +241,27 @@ async function handleInit(userId: string, maxPerRoom: number): Promise { }); try { - db = await openDb(dbName); + db = await openSearchWorkerDb(indexedDB, dbName, undefined, (event, nextDb) => { + const oldVersion = event.oldVersion; + if (oldVersion < 1) { + nextDb.createObjectStore('index'); + nextDb.createObjectStore('backfill'); + } + // v2: added msgtype to stored fields + if (oldVersion >= 1 && oldVersion < 2) { + nextDb.deleteObjectStore('index'); + nextDb.deleteObjectStore('backfill'); + nextDb.createObjectStore('index'); + nextDb.createObjectStore('backfill'); + } + // v3: added url/file/info/filename to stored fields for media events + if (oldVersion >= 2 && oldVersion < 3) { + nextDb.deleteObjectStore('index'); + nextDb.deleteObjectStore('backfill'); + nextDb.createObjectStore('index'); + nextDb.createObjectStore('backfill'); + } + }); } catch (err: unknown) { post({ type: '_sentry_breadcrumb', @@ -592,7 +582,7 @@ self.addEventListener('message', (event: MessageEvent) => { // Send ERROR instead of READY so the UI can show the error state post({ type: 'ERROR', - message: err instanceof Error ? err.message : String(err), + message: buildSearchWorkerInitErrorMessage(err), }); }); break; diff --git a/src/app/plugins/search-worker/workerLifecycle.test.ts b/src/app/plugins/search-worker/workerLifecycle.test.ts new file mode 100644 index 000000000..50673a4c3 --- /dev/null +++ b/src/app/plugins/search-worker/workerLifecycle.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildSearchWorkerInitErrorMessage, + buildSearchWorkerRuntimeErrorMessage, + openSearchWorkerDb, + type IDBOpenRequestLike, +} from './workerLifecycle'; + +function createOpenRequest() { + const listeners: Partial void>> = {}; + const close = vi.fn<() => void>(); + const request: IDBOpenRequestLike = { + result: { close } as unknown as IDBDatabase, + error: null, + onupgradeneeded: null, + onblocked: null, + addEventListener(type, listener) { + listeners[type] = () => listener(); + }, + }; + + return { + request, + close, + fireSuccess: () => listeners.success?.(), + fireError: () => listeners.error?.(), + fireBlocked: () => + request.onblocked?.({ oldVersion: 0, newVersion: 1 } as IDBVersionChangeEvent), + }; +} + +describe('buildSearchWorkerRuntimeErrorMessage', () => { + it('uses the worker error message when available', () => { + expect(buildSearchWorkerRuntimeErrorMessage({ message: 'boom' })).toBe( + 'Search worker runtime error: boom' + ); + }); + + it('falls back to filename and coordinates for empty worker errors', () => { + expect( + buildSearchWorkerRuntimeErrorMessage({ + message: '', + filename: 'worker.js', + lineno: 12, + colno: 8, + }) + ).toBe('Search worker runtime error: Unknown worker error (worker.js:12:8)'); + }); +}); + +describe('buildSearchWorkerInitErrorMessage', () => { + it('normalizes init failures into a stable message', () => { + expect(buildSearchWorkerInitErrorMessage(new Error('IndexedDB open blocked'))).toBe( + 'Search worker initialization failed: IndexedDB open blocked' + ); + }); +}); + +describe('openSearchWorkerDb', () => { + it('resolves when indexedDB open succeeds', async () => { + const { request, fireSuccess } = createOpenRequest(); + const indexedDb = { open: vi.fn<() => IDBOpenRequestLike>(() => request) }; + + const promise = openSearchWorkerDb(indexedDb, 'sable-search-test', 1000); + fireSuccess(); + + await expect(promise).resolves.toBe(request.result); + }); + + it('keeps waiting when indexedDB open is blocked and later succeeds', async () => { + const { request, fireBlocked, fireSuccess } = createOpenRequest(); + const indexedDb = { open: vi.fn<() => IDBOpenRequestLike>(() => request) }; + + const promise = openSearchWorkerDb(indexedDb, 'sable-search-test', 1000); + fireBlocked(); + fireSuccess(); + + await expect(promise).resolves.toBe(request.result); + }); + + it('rejects when indexedDB open never settles', async () => { + vi.useFakeTimers(); + const { request, close, fireSuccess } = createOpenRequest(); + const indexedDb = { open: vi.fn<() => IDBOpenRequestLike>(() => request) }; + + const promise = openSearchWorkerDb(indexedDb, 'sable-search-test', 50); + const rejection = promise.then( + () => { + throw new Error('Expected indexedDB open to time out'); + }, + (error) => error + ); + await vi.advanceTimersByTimeAsync(50); + + await expect(rejection).resolves.toMatchObject({ + message: 'IndexedDB open timed out after 50ms for sable-search-test', + }); + fireSuccess(); + expect(close).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); +}); diff --git a/src/app/plugins/search-worker/workerLifecycle.ts b/src/app/plugins/search-worker/workerLifecycle.ts new file mode 100644 index 000000000..5e38a293a --- /dev/null +++ b/src/app/plugins/search-worker/workerLifecycle.ts @@ -0,0 +1,87 @@ +export const SEARCH_WORKER_DB_VERSION = 3; +export const SEARCH_WORKER_IDB_OPEN_TIMEOUT_MS = 10_000; + +type RequestListener = (event?: Event) => void; + +export type IDBOpenRequestLike = { + result: IDBDatabase; + error: DOMException | null; + onupgradeneeded: ((event: IDBVersionChangeEvent) => void) | null; + onblocked: ((event: IDBVersionChangeEvent) => void) | null; + addEventListener(type: 'success' | 'error', listener: RequestListener): void; +}; + +export type IndexedDBLike = { + open(name: string, version: number): IDBOpenRequestLike; +}; + +function formatCause(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function buildSearchWorkerRuntimeErrorMessage(error: { + message?: string; + filename?: string; + lineno?: number; + colno?: number; +}): string { + const message = error.message?.trim(); + if (message) return `Search worker runtime error: ${message}`; + + const location = + error.filename && error.lineno && error.colno + ? ` (${error.filename}:${error.lineno}:${error.colno})` + : error.filename + ? ` (${error.filename})` + : ''; + return `Search worker runtime error: Unknown worker error${location}`; +} + +export function buildSearchWorkerInitErrorMessage(err: unknown): string { + return `Search worker initialization failed: ${formatCause(err)}`; +} + +export function openSearchWorkerDb( + indexedDb: IndexedDBLike, + dbName: string, + timeoutMs = SEARCH_WORKER_IDB_OPEN_TIMEOUT_MS, + onUpgradeNeeded?: (event: IDBVersionChangeEvent, db: IDBDatabase) => void +): Promise { + return new Promise((resolve, reject) => { + const req = indexedDb.open(dbName, SEARCH_WORKER_DB_VERSION); + let settled = false; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const timeoutId = setTimeout(() => { + settle(() => + reject(new Error(`IndexedDB open timed out after ${timeoutMs}ms for ${dbName}`)) + ); + }, timeoutMs); + + req.onupgradeneeded = (event) => { + onUpgradeNeeded?.(event, req.result); + }; + + req.onblocked = () => { + // `blocked` is advisory: another connection still needs to close, but + // the open request can still succeed once that happens. + }; + + req.addEventListener('success', () => { + if (settled) { + req.result.close(); + return; + } + settle(() => resolve(req.result)); + }); + req.addEventListener('error', () => + settle(() => reject(req.error ?? new Error(`IndexedDB open failed for ${dbName}`))) + ); + }); +}