From 46104aaecc518a0659eebdd28a7c24a964c9dcbe Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 21 Jun 2026 20:37:40 -0400 Subject: [PATCH 1/9] fix: fail fast when search worker startup stalls --- .changeset/fix-search-worker-init-fallback.md | 5 + src/app/hooks/useSearchIndex.tsx | 10 +- src/app/plugins/search-worker/searchWorker.ts | 56 +++++------ .../search-worker/workerLifecycle.test.ts | 97 +++++++++++++++++++ .../plugins/search-worker/workerLifecycle.ts | 80 +++++++++++++++ 5 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-search-worker-init-fallback.md create mode 100644 src/app/plugins/search-worker/workerLifecycle.test.ts create mode 100644 src/app/plugins/search-worker/workerLifecycle.ts diff --git a/.changeset/fix-search-worker-init-fallback.md b/.changeset/fix-search-worker-init-fallback.md new file mode 100644 index 000000000..1f0a1a160 --- /dev/null +++ b/.changeset/fix-search-worker-init-fallback.md @@ -0,0 +1,5 @@ +--- +'@cloudhub-social/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..a450c8ec9 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'; @@ -732,11 +733,18 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { 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); + worker.terminate(); + workerRef.current = null; Sentry.captureException(error.error || new Error(message || 'Unknown worker error'), { level: isMimeError ? 'warning' : 'error', tags: { 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..f3928a257 --- /dev/null +++ b/src/app/plugins/search-worker/workerLifecycle.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildSearchWorkerInitErrorMessage, + buildSearchWorkerRuntimeErrorMessage, + openSearchWorkerDb, + type IDBOpenRequestLike, +} from './workerLifecycle'; + +function createOpenRequest() { + const listeners: Partial void>> = {}; + const request: IDBOpenRequestLike = { + result: {} as IDBDatabase, + error: null, + onupgradeneeded: null, + onblocked: null, + addEventListener(type, listener) { + listeners[type] = () => listener(); + }, + }; + + return { + request, + 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('rejects when indexedDB open is blocked', async () => { + const { request, fireBlocked } = createOpenRequest(); + const indexedDb = { open: vi.fn<() => IDBOpenRequestLike>(() => request) }; + + const promise = openSearchWorkerDb(indexedDb, 'sable-search-test', 1000); + fireBlocked(); + + await expect(promise).rejects.toThrow('IndexedDB open blocked for sable-search-test'); + }); + + it('rejects when indexedDB open never settles', async () => { + vi.useFakeTimers(); + const { request } = 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', + }); + 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..3218df7f5 --- /dev/null +++ b/src/app/plugins/search-worker/workerLifecycle.ts @@ -0,0 +1,80 @@ +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 = () => { + settle(() => reject(new Error(`IndexedDB open blocked for ${dbName}`))); + }; + + req.addEventListener('success', () => settle(() => resolve(req.result))); + req.addEventListener('error', () => + settle(() => reject(req.error ?? new Error(`IndexedDB open failed for ${dbName}`))) + ); + }); +} From 08aab2fa21679ee4fdb1168d83545bcb34dde05f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 21 Jun 2026 22:18:31 -0400 Subject: [PATCH 2/9] Clear search init timeout on worker error --- src/app/hooks/useSearchIndex.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index a450c8ec9..f41d4015c 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -741,6 +741,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }); const isMimeError = message.includes('MIME') && message.includes('text/html'); + clearTimeout(initTimeout); setInitError(errorMsg); setIsReady(false); worker.terminate(); From f272f0cf7c302bb3f91a3b8fbecbfd4c1d64522d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 21 Jun 2026 22:45:08 -0400 Subject: [PATCH 3/9] Wait through IndexedDB blocked opens --- src/app/plugins/search-worker/workerLifecycle.test.ts | 7 ++++--- src/app/plugins/search-worker/workerLifecycle.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/plugins/search-worker/workerLifecycle.test.ts b/src/app/plugins/search-worker/workerLifecycle.test.ts index f3928a257..b22d9d4e8 100644 --- a/src/app/plugins/search-worker/workerLifecycle.test.ts +++ b/src/app/plugins/search-worker/workerLifecycle.test.ts @@ -65,14 +65,15 @@ describe('openSearchWorkerDb', () => { await expect(promise).resolves.toBe(request.result); }); - it('rejects when indexedDB open is blocked', async () => { - const { request, fireBlocked } = createOpenRequest(); + 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).rejects.toThrow('IndexedDB open blocked for sable-search-test'); + await expect(promise).resolves.toBe(request.result); }); it('rejects when indexedDB open never settles', async () => { diff --git a/src/app/plugins/search-worker/workerLifecycle.ts b/src/app/plugins/search-worker/workerLifecycle.ts index 3218df7f5..8fbebc029 100644 --- a/src/app/plugins/search-worker/workerLifecycle.ts +++ b/src/app/plugins/search-worker/workerLifecycle.ts @@ -69,7 +69,8 @@ export function openSearchWorkerDb( }; req.onblocked = () => { - settle(() => reject(new Error(`IndexedDB open blocked for ${dbName}`))); + // `blocked` is advisory: another connection still needs to close, but + // the open request can still succeed once that happens. }; req.addEventListener('success', () => settle(() => resolve(req.result))); From 5e9ea6333e6edea6926e76365069793b49396297 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 21 Jun 2026 22:57:46 -0400 Subject: [PATCH 4/9] Settle search worker failure state --- .changeset/fix-search-worker-init-fallback.md | 2 +- src/app/hooks/useSearchIndex.tsx | 49 +++++++++++++++---- .../search-worker/workerLifecycle.test.ts | 8 ++- .../plugins/search-worker/workerLifecycle.ts | 8 ++- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/.changeset/fix-search-worker-init-fallback.md b/.changeset/fix-search-worker-init-fallback.md index 1f0a1a160..0531303d8 100644 --- a/.changeset/fix-search-worker-init-fallback.md +++ b/.changeset/fix-search-worker-init-fallback.md @@ -1,5 +1,5 @@ --- -'@cloudhub-social/charm': patch +'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 f41d4015c..f5a5f7962 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -729,6 +729,43 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = worker; worker.addEventListener('message', handleWorkerMessage); + const failWorker = (errorMsg: string) => { + 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 { reject } of pendingQueriesRef.current.values()) { + reject(new Error(errorMsg)); + } + pendingQueriesRef.current.clear(); + + if (pendingStatsRef.current) { + pendingStatsRef.current.resolve({ + indexedEventCount: 0, + roomCount: 0, + estimatedBytes: 0, + backfillingRoomCount: 0, + }); + pendingStatsRef.current = null; + } + + worker.terminate(); + workerRef.current = null; + }; + // 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) @@ -741,11 +778,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }); const isMimeError = message.includes('MIME') && message.includes('text/html'); - clearTimeout(initTimeout); - setInitError(errorMsg); - setIsReady(false); - worker.terminate(); - workerRef.current = null; + failWorker(errorMsg); Sentry.captureException(error.error || new Error(message || 'Unknown worker error'), { level: isMimeError ? 'warning' : 'error', tags: { @@ -782,11 +815,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' }, diff --git a/src/app/plugins/search-worker/workerLifecycle.test.ts b/src/app/plugins/search-worker/workerLifecycle.test.ts index b22d9d4e8..50673a4c3 100644 --- a/src/app/plugins/search-worker/workerLifecycle.test.ts +++ b/src/app/plugins/search-worker/workerLifecycle.test.ts @@ -8,8 +8,9 @@ import { function createOpenRequest() { const listeners: Partial void>> = {}; + const close = vi.fn<() => void>(); const request: IDBOpenRequestLike = { - result: {} as IDBDatabase, + result: { close } as unknown as IDBDatabase, error: null, onupgradeneeded: null, onblocked: null, @@ -20,6 +21,7 @@ function createOpenRequest() { return { request, + close, fireSuccess: () => listeners.success?.(), fireError: () => listeners.error?.(), fireBlocked: () => @@ -78,7 +80,7 @@ describe('openSearchWorkerDb', () => { it('rejects when indexedDB open never settles', async () => { vi.useFakeTimers(); - const { request } = createOpenRequest(); + const { request, close, fireSuccess } = createOpenRequest(); const indexedDb = { open: vi.fn<() => IDBOpenRequestLike>(() => request) }; const promise = openSearchWorkerDb(indexedDb, 'sable-search-test', 50); @@ -93,6 +95,8 @@ describe('openSearchWorkerDb', () => { 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 index 8fbebc029..5e38a293a 100644 --- a/src/app/plugins/search-worker/workerLifecycle.ts +++ b/src/app/plugins/search-worker/workerLifecycle.ts @@ -73,7 +73,13 @@ export function openSearchWorkerDb( // the open request can still succeed once that happens. }; - req.addEventListener('success', () => settle(() => resolve(req.result))); + 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}`))) ); From f9f293424e2e77554d9c81cc2c6d1ed9178c597c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 22 Jun 2026 10:01:32 -0400 Subject: [PATCH 5/9] Handle search worker ERROR teardown --- src/app/hooks/useSearchIndex.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index f5a5f7962..008cf8fe7 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -68,6 +68,10 @@ type SearchIndexCtx = { initError: string | null; }; +type FailWorkerOptions = { + settlePendingQueriesWithEmptyResults?: boolean; +}; + // ── Context ────────────────────────────────────────────────────────────────── const SearchIndexContext = createContext(null); @@ -167,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()); @@ -614,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', @@ -729,7 +738,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = worker; worker.addEventListener('message', handleWorkerMessage); - const failWorker = (errorMsg: string) => { + const failWorker = (errorMsg: string, options: FailWorkerOptions = {}) => { clearTimeout(initTimeout); setInitError(errorMsg); setIsReady(false); @@ -747,7 +756,11 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { headlessSetsRef.current.clear(); backfillQueueRef.current = []; - for (const { reject } of pendingQueriesRef.current.values()) { + for (const { resolve, reject } of pendingQueriesRef.current.values()) { + if (options.settlePendingQueriesWithEmptyResults) { + resolve([]); + continue; + } reject(new Error(errorMsg)); } pendingQueriesRef.current.clear(); @@ -765,6 +778,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { worker.terminate(); workerRef.current = null; }; + failWorkerRef.current = failWorker; // Handle worker runtime errors (e.g., MIME type errors from failed imports) const handleWorkerError = (error: ErrorEvent) => { @@ -907,6 +921,7 @@ 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); From 0b84c6c684f92d1328a5d360a9ede3c78a5aaeb3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 22 Jun 2026 10:13:34 -0400 Subject: [PATCH 6/9] Tighten search worker failure cleanup --- src/app/hooks/useSearchIndex.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 008cf8fe7..173f53f97 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -775,8 +775,10 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { pendingStatsRef.current = null; } - worker.terminate(); - workerRef.current = null; + if (workerRef.current === worker) { + worker.terminate(); + workerRef.current = null; + } }; failWorkerRef.current = failWorker; @@ -792,7 +794,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }); const isMimeError = message.includes('MIME') && message.includes('text/html'); - failWorker(errorMsg); + failWorker(errorMsg, { settlePendingQueriesWithEmptyResults: true }); Sentry.captureException(error.error || new Error(message || 'Unknown worker error'), { level: isMimeError ? 'warning' : 'error', tags: { @@ -927,6 +929,24 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { clearTimeout(initTimeout); worker.removeEventListener('message', wrappedHandler as EventListener); worker.removeEventListener('error', handleWorkerError); + if (workerRef.current !== worker) { + setIsReady(false); + setIsBackfilling(false); + setInitError(null); + 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(); From d680faf63caa25a27333940b84f917edc2b28293 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 22 Jun 2026 10:28:31 -0400 Subject: [PATCH 7/9] Preserve search worker init errors --- src/app/hooks/useSearchIndex.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 173f53f97..bf8b19f68 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -932,7 +932,6 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { if (workerRef.current !== worker) { setIsReady(false); setIsBackfilling(false); - setInitError(null); mx.removeListener(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); mx.removeListener( RoomEvent.Timeline, From e921305ae0d4e2de1307a3df8f9d8eba887f79a1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 22 Jun 2026 10:32:52 -0400 Subject: [PATCH 8/9] Ignore room additions after search worker failure --- src/app/hooks/useSearchIndex.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index bf8b19f68..0981f5683 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -894,6 +894,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; From 80b33832c39ca02b170977ce4e35222264350c28 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 22 Jun 2026 10:51:31 -0400 Subject: [PATCH 9/9] Clear stale search worker init errors on restart --- src/app/hooks/useSearchIndex.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 0981f5683..adcc10b9e 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -666,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',