Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-search-worker-init-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'charm': patch
---

fix: fail fast when search worker startup stalls
91 changes: 82 additions & 9 deletions src/app/hooks/useSearchIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,6 +68,10 @@ type SearchIndexCtx = {
initError: string | null;
};

type FailWorkerOptions = {
settlePendingQueriesWithEmptyResults?: boolean;
};

// ── Context ──────────────────────────────────────────────────────────────────

const SearchIndexContext = createContext<SearchIndexCtx | null>(null);
Expand Down Expand Up @@ -166,6 +171,9 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) {
const [isReady, setIsReady] = useState(false);
const [isBackfilling, setIsBackfilling] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const failWorkerRef = useRef<((errorMsg: string, options?: FailWorkerOptions) => void) | null>(
null
);

const workerRef = useRef<Worker | null>(null);
const pendingQueriesRef = useRef<Map<string, PendingQuery>>(new Map());
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -728,15 +738,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;
}
};
Comment thread
sentry[bot] marked this conversation as resolved.
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: {
Expand Down Expand Up @@ -773,11 +831,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' },
Expand Down Expand Up @@ -869,11 +923,30 @@ 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);
Comment thread
sentry[bot] marked this conversation as resolved.
worker.removeEventListener('message', wrappedHandler as EventListener);
worker.removeEventListener('error', handleWorkerError);
if (workerRef.current !== worker) {
setIsReady(false);
setIsBackfilling(false);
setInitError(null);
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
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;
Comment thread
Just-Insane marked this conversation as resolved.
}
postToWorker({ type: 'FLUSH' });
const terminateTimeout = setTimeout(() => {
worker.terminate();
Expand Down
56 changes: 23 additions & 33 deletions src/app/plugins/search-worker/searchWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDBDatabase> {
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<T>(db: IDBDatabase, store: string, key: string): Promise<T | undefined> {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readonly');
Expand Down Expand Up @@ -271,7 +241,27 @@ async function handleInit(userId: string, maxPerRoom: number): Promise<void> {
});

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',
Expand Down Expand Up @@ -592,7 +582,7 @@ self.addEventListener('message', (event: MessageEvent<WorkerInMessage>) => {
// 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;
Expand Down
102 changes: 102 additions & 0 deletions src/app/plugins/search-worker/workerLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildSearchWorkerInitErrorMessage,
buildSearchWorkerRuntimeErrorMessage,
openSearchWorkerDb,
type IDBOpenRequestLike,
} from './workerLifecycle';

function createOpenRequest() {
const listeners: Partial<Record<'success' | 'error', () => 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();
});
});
Loading
Loading