From 058861cac58ffd4ec400bfedf774f144a0fecf96 Mon Sep 17 00:00:00 2001 From: Alex Mabe Date: Wed, 12 Aug 2026 11:05:14 -0400 Subject: [PATCH] fix(core): allow WASM initialization retry --- packages/core/src/analyzer.ts | 131 ++++++------------------ packages/core/src/wasm-loader.ts | 82 +++++++++------ packages/core/tests/analyzer.test.ts | 51 +++++++++ packages/core/tests/wasm-loader.test.ts | 101 +++++++++++++++--- 4 files changed, 224 insertions(+), 141 deletions(-) diff --git a/packages/core/src/analyzer.ts b/packages/core/src/analyzer.ts index f7ecaf47..e8b7c82a 100644 --- a/packages/core/src/analyzer.ts +++ b/packages/core/src/analyzer.ts @@ -1,4 +1,4 @@ -import { initWasm, isWasmInitialized } from './wasm-loader'; +import { initWasm } from './wasm-loader'; import { VALID_DIALECTS } from './types'; import type { AnalyzeRequest, @@ -15,22 +15,6 @@ import type { // Shared reserved keywords (single source of truth for Rust and TypeScript) import reservedKeywordsJson from './reserved-keywords.json'; -// Import WASM functions (will be available after init) -let analyzeSqlJson: ((request: string) => string) | null = null; -let exportToDuckDbSqlFn: ((resultJson: string) => string) | null = null; -let exportJsonFn: ((requestJson: string) => string) | null = null; -let exportMermaidFn: ((requestJson: string) => string) | null = null; -let exportHtmlFn: ((requestJson: string) => string) | null = null; -let exportCsvBundleFn: ((requestJson: string) => Uint8Array) | null = null; -let exportXlsxFn: ((requestJson: string) => Uint8Array) | null = null; -let exportFilenameFn: ((requestJson: string) => string) | null = null; -let completionItemsJson: ((requestJson: string) => string) | null = null; -let splitStatementsJson: ((requestJson: string) => string) | null = null; -let panicHookInstalled = false; - -// Initialization guard to prevent race conditions -let wasmInitPromise: Promise | null = null; - /** Maximum length for schema identifiers (PostgreSQL/DuckDB limit). */ const MAX_SCHEMA_NAME_LENGTH = 63; @@ -142,67 +126,8 @@ function validateSchemaNameOrThrow(schema: string): void { } } -async function ensureWasmReady(): Promise { - // Use initialization guard to prevent race conditions when called concurrently - if (wasmInitPromise) { - return wasmInitPromise; - } - - wasmInitPromise = (async () => { - const wasmModule = await initWasm(); - - if (!isWasmInitialized()) { - throw new Error('WASM module failed to initialize'); - } - - if (!analyzeSqlJson) { - analyzeSqlJson = wasmModule.analyze_sql_json; - } - - if (!exportToDuckDbSqlFn) { - exportToDuckDbSqlFn = wasmModule.export_to_duckdb_sql; - } - - if (!exportJsonFn && typeof wasmModule.export_json === 'function') { - exportJsonFn = wasmModule.export_json; - } - - if (!exportMermaidFn && typeof wasmModule.export_mermaid === 'function') { - exportMermaidFn = wasmModule.export_mermaid; - } - - if (!exportHtmlFn && typeof wasmModule.export_html === 'function') { - exportHtmlFn = wasmModule.export_html; - } - - if (!exportCsvBundleFn && typeof wasmModule.export_csv_bundle === 'function') { - exportCsvBundleFn = wasmModule.export_csv_bundle; - } - - if (!exportXlsxFn && typeof wasmModule.export_xlsx === 'function') { - exportXlsxFn = wasmModule.export_xlsx; - } - - if (!exportFilenameFn && typeof wasmModule.export_filename === 'function') { - exportFilenameFn = wasmModule.export_filename; - } - - if (!completionItemsJson && typeof wasmModule.completion_items_json === 'function') { - completionItemsJson = wasmModule.completion_items_json; - } - - if (!splitStatementsJson && typeof wasmModule.split_statements_json === 'function') { - splitStatementsJson = wasmModule.split_statements_json; - } - - // Install panic hook for better error messages - if (!panicHookInstalled && wasmModule.set_panic_hook) { - wasmModule.set_panic_hook(); - panicHookInstalled = true; - } - })(); - - return wasmInitPromise; +function ensureWasmReady(): ReturnType { + return initWasm(); } /** @@ -223,9 +148,10 @@ async function ensureWasmReady(): Promise { * ``` */ export async function analyzeSql(request: AnalyzeRequest): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const analyzeSqlJson = wasmModule.analyze_sql_json; - if (!analyzeSqlJson) { + if (typeof analyzeSqlJson !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -260,9 +186,10 @@ export async function analyzeSql(request: AnalyzeRequest): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const completionItemsJson = wasmModule.completion_items_json; - if (!completionItemsJson) { + if (typeof completionItemsJson !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -294,9 +221,10 @@ export async function completionItems(request: CompletionRequest): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const splitStatementsJson = wasmModule.split_statements_json; - if (!splitStatementsJson) { + if (typeof splitStatementsJson !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -362,9 +290,10 @@ export async function exportToDuckDbSql(result: AnalyzeResult, schema?: string): validateSchemaNameOrThrow(schema); } - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportToDuckDbSqlFn = wasmModule.export_to_duckdb_sql; - if (!exportToDuckDbSqlFn) { + if (typeof exportToDuckDbSqlFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -387,9 +316,10 @@ export async function exportJson( result: AnalyzeResult, options: { compact?: boolean } = {} ): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportJsonFn = wasmModule.export_json; - if (!exportJsonFn) { + if (typeof exportJsonFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -401,9 +331,10 @@ export async function exportMermaid( result: AnalyzeResult, view: MermaidView = 'table' ): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportMermaidFn = wasmModule.export_mermaid; - if (!exportMermaidFn) { + if (typeof exportMermaidFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -415,9 +346,10 @@ export async function exportHtml( result: AnalyzeResult, options: { projectName?: string; exportedAt?: Date } = {} ): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportHtmlFn = wasmModule.export_html; - if (!exportHtmlFn) { + if (typeof exportHtmlFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -430,9 +362,10 @@ export async function exportHtml( } export async function exportCsvArchive(result: AnalyzeResult): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportCsvBundleFn = wasmModule.export_csv_bundle; - if (!exportCsvBundleFn) { + if (typeof exportCsvBundleFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -441,9 +374,10 @@ export async function exportCsvArchive(result: AnalyzeResult): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportXlsxFn = wasmModule.export_xlsx; - if (!exportXlsxFn) { + if (typeof exportXlsxFn !== 'function') { throw new Error('WASM module not properly initialized'); } @@ -458,9 +392,10 @@ export async function exportFilename(options: { view?: MermaidView; compact?: boolean; }): Promise { - await ensureWasmReady(); + const wasmModule = await ensureWasmReady(); + const exportFilenameFn = wasmModule.export_filename; - if (!exportFilenameFn) { + if (typeof exportFilenameFn !== 'function') { throw new Error('WASM module not properly initialized'); } diff --git a/packages/core/src/wasm-loader.ts b/packages/core/src/wasm-loader.ts index d9f1cf8c..e9b00e0b 100644 --- a/packages/core/src/wasm-loader.ts +++ b/packages/core/src/wasm-loader.ts @@ -1,5 +1,7 @@ -let wasmModule: typeof import('./wasm/flowscope_wasm') | null = null; -let initPromise: Promise | null = null; +type WasmModule = typeof import('./wasm/flowscope_wasm'); + +let wasmModule: WasmModule | null = null; +let initPromise: Promise | null = null; export interface InitWasmOptions { wasmUrl?: string; @@ -10,12 +12,10 @@ export interface InitWasmOptions { * Initialize the WASM module. Safe to call multiple times (idempotent). * Returns the initialized WASM module. */ -export async function initWasm( - options: InitWasmOptions = {} -): Promise { +export function initWasm(options: InitWasmOptions = {}): Promise { // Return cached module if already initialized if (wasmModule) { - return wasmModule; + return Promise.resolve(wasmModule); } // Return existing promise if initialization is in progress @@ -23,39 +23,61 @@ export async function initWasm( return initPromise; } - initPromise = (async () => { - try { - // Dynamic import of the wasm module - // With vite-plugin-wasm, the module auto-initializes on import - const wasm = await import('./wasm/flowscope_wasm'); - - // Explicitly initialize the WASM module - if (typeof wasm.default === 'function') { - // Pass through custom URL when provided so host apps can control asset location - await wasm.default(options.wasmUrl ?? undefined); - // Allow host apps to enable tracing via init option if supported by the build - const wasmWithTracing = wasm as typeof wasm & { enable_tracing?: () => void }; - if (options.enableTracing && typeof wasmWithTracing.enable_tracing === 'function') { - wasmWithTracing.enable_tracing(); + const attempt: Promise = initializeWasm(options).then( + (wasm) => { + // A reset may have invalidated this attempt while it was in flight. Join a + // replacement attempt when one exists, but do not undo an explicit reset. + if (initPromise !== attempt) { + if (initPromise) { + return initPromise; } - } - - // Verify that the module is actually initialized by checking for required functions - if (!wasm.analyze_sql_json || typeof wasm.analyze_sql_json !== 'function') { - throw new Error('WASM module loaded but analyze_sql_json function is not available'); + throw new Error('WASM initialization was superseded by reset or cleanup'); } wasmModule = wasm; - return wasmModule; - } catch (error) { - initPromise = null; // Allow retry on failure + return wasm; + }, + (error: unknown) => { + // Only the active attempt may clear the shared guard. An older rejected + // attempt must not clobber a retry started after reset. + if (initPromise === attempt) { + initPromise = null; + } throw new Error( `Failed to initialize WASM module: ${error instanceof Error ? error.message : String(error)}` ); } - })(); + ); + initPromise = attempt; + return attempt; +} + +async function initializeWasm(options: InitWasmOptions): Promise { + // Dynamic import of the wasm module + // With vite-plugin-wasm, the module auto-initializes on import + const wasm = await import('./wasm/flowscope_wasm'); + + // Explicitly initialize the WASM module + if (typeof wasm.default === 'function') { + // Pass through custom URL when provided so host apps can control asset location + await wasm.default(options.wasmUrl ?? undefined); + // Allow host apps to enable tracing via init option if supported by the build + const wasmWithTracing = wasm as typeof wasm & { enable_tracing?: () => void }; + if (options.enableTracing && typeof wasmWithTracing.enable_tracing === 'function') { + wasmWithTracing.enable_tracing(); + } + } + + // Verify that the module is actually initialized by checking for required functions + if (!wasm.analyze_sql_json || typeof wasm.analyze_sql_json !== 'function') { + throw new Error('WASM module loaded but analyze_sql_json function is not available'); + } + + if (typeof wasm.set_panic_hook === 'function') { + wasm.set_panic_hook(); + } - return initPromise; + return wasm; } /** diff --git a/packages/core/tests/analyzer.test.ts b/packages/core/tests/analyzer.test.ts index 26e5fcde..bd6bace9 100644 --- a/packages/core/tests/analyzer.test.ts +++ b/packages/core/tests/analyzer.test.ts @@ -67,6 +67,57 @@ describe('analyzer', () => { expect(payload.dialect).toBe('generic'); }); + it('retries initialization for analysis, completion, and export after a transient failure', async () => { + wasmModuleMock.default.mockRejectedValueOnce(new Error('transient load failure')); + const { analyzeSql, completionItems, exportJson } = await loadAnalyzer(); + + await expect(analyzeSql({ sql: 'SELECT 1', dialect: 'generic' })).rejects.toThrow( + /transient load failure/ + ); + + const [analysis, completions, exported] = await Promise.all([ + analyzeSql({ sql: 'SELECT 1', dialect: 'generic' }), + completionItems({ sql: 'SELECT ', dialect: 'generic', cursorOffset: 7 }), + exportJson(baseResult), + ]); + + expect(analysis.summary.hasErrors).toBe(false); + expect(completions.items).toEqual([]); + expect(JSON.parse(exported)).toEqual(baseResult); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + }); + + it('uses current wasm exports after reset instead of cached function references', async () => { + const originalAnalyze = wasmModuleMock.analyze_sql_json; + const originalExportJson = wasmModuleMock.export_json; + const replacementAnalyze = vi.fn(() => + JSON.stringify({ ...baseResult, summary: { ...baseResult.summary, tableCount: 2 } }) + ); + const replacementExportJson = vi.fn(() => '{"lifecycle":"replacement"}'); + const { analyzeSql, exportJson } = await loadAnalyzer(); + const { resetWasm } = await import('../src/wasm-loader'); + + try { + await analyzeSql({ sql: 'SELECT 1', dialect: 'generic' }); + await exportJson(baseResult); + resetWasm(); + wasmModuleMock.analyze_sql_json = replacementAnalyze; + wasmModuleMock.export_json = replacementExportJson; + + const analysis = await analyzeSql({ sql: 'SELECT 2', dialect: 'generic' }); + const exported = await exportJson(baseResult); + + expect(analysis.summary.tableCount).toBe(2); + expect(exported).toBe('{"lifecycle":"replacement"}'); + expect(replacementAnalyze).toHaveBeenCalledTimes(1); + expect(replacementExportJson).toHaveBeenCalledTimes(1); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + } finally { + wasmModuleMock.analyze_sql_json = originalAnalyze; + wasmModuleMock.export_json = originalExportJson; + } + }); + it('validates input SQL and throws for empty strings', async () => { const { analyzeSql } = await loadAnalyzer(); await expect(analyzeSql({ sql: '', dialect: 'generic' })).rejects.toThrow( diff --git a/packages/core/tests/wasm-loader.test.ts b/packages/core/tests/wasm-loader.test.ts index 18f7c291..1f4ee51a 100644 --- a/packages/core/tests/wasm-loader.test.ts +++ b/packages/core/tests/wasm-loader.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; const wasmModuleMock = vi.hoisted(() => ({ default: vi.fn(async () => undefined), analyze_sql_json: vi.fn(), + set_panic_hook: vi.fn(), __wbindgen_free: vi.fn(), })); @@ -24,6 +25,7 @@ describe('wasm-loader', () => { ).analyze_sql_json = vi.fn(); } wasmModuleMock.analyze_sql_json.mockImplementation(() => JSON.stringify({})); + wasmModuleMock.set_panic_hook.mockClear(); wasmModuleMock.__wbindgen_free.mockClear(); }); @@ -32,7 +34,21 @@ describe('wasm-loader', () => { vi.clearAllMocks(); }); - it('initializes wasm exactly once and caches the module', async () => { + it('shares one initialization attempt across concurrent callers', async () => { + const loader = await loadLoader(); + + const initA = loader.initWasm(); + const initB = loader.initWasm(); + const [moduleA, moduleB] = await Promise.all([initA, initB]); + + expect(initA).toBe(initB); + expect(moduleA).toBe(moduleB); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(1); + expect(wasmModuleMock.set_panic_hook).toHaveBeenCalledTimes(1); + expect(loader.isWasmInitialized()).toBe(true); + }); + + it('returns the cached module without initializing again', async () => { const loader = await loadLoader(); const moduleA = await loader.initWasm(); @@ -40,7 +56,7 @@ describe('wasm-loader', () => { expect(moduleA).toBe(moduleB); expect(wasmModuleMock.default).toHaveBeenCalledTimes(1); - expect(loader.isWasmInitialized()).toBe(true); + expect(wasmModuleMock.set_panic_hook).toHaveBeenCalledTimes(1); }); it('forwards wasmUrl option to the wasm initializer', async () => { @@ -63,22 +79,81 @@ describe('wasm-loader', () => { originalAnalyze; }); - it('resetWasm allows reinitialization after failure', async () => { - const originalAnalyze = wasmModuleMock.analyze_sql_json; - (wasmModuleMock as unknown as { analyze_sql_json?: undefined }).analyze_sql_json = undefined; + it('allows a later call to retry after a transient failure', async () => { + wasmModuleMock.default.mockRejectedValueOnce(new Error('transient load failure')); const loader = await loadLoader(); - await expect(loader.initWasm()).rejects.toThrow(); + await expect(loader.initWasm()).rejects.toThrow(/transient load failure/); + expect(loader.isWasmInitialized()).toBe(false); + + await expect(loader.initWasm()).resolves.toBeDefined(); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + expect(loader.isWasmInitialized()).toBe(true); + }); + + it('allows reinitialization after reset', async () => { + const loader = await loadLoader(); + + await loader.initWasm(); + loader.resetWasm(); loader.resetWasm(); - vi.resetModules(); - vi.clearAllMocks(); - (wasmModuleMock as unknown as { analyze_sql_json: typeof originalAnalyze }).analyze_sql_json = - originalAnalyze; - wasmModuleMock.default.mockClear(); - const reloaded = await loadLoader(); - await expect(reloaded.initWasm()).resolves.toBeDefined(); + expect(loader.isWasmInitialized()).toBe(false); + await expect(loader.initWasm()).resolves.toBeDefined(); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + expect(wasmModuleMock.set_panic_hook).toHaveBeenCalledTimes(2); + expect(loader.isWasmInitialized()).toBe(true); + }); + + it('does not let an in-flight initialization undo reset', async () => { + let resolveFirst!: () => void; + wasmModuleMock.default.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = () => resolve(undefined); + }) + ); + const loader = await loadLoader(); + + const firstInit = loader.initWasm(); + await vi.waitFor(() => expect(wasmModuleMock.default).toHaveBeenCalledTimes(1)); + + loader.resetWasm(); + resolveFirst(); + + await expect(firstInit).rejects.toThrow(/superseded by reset or cleanup/); expect(wasmModuleMock.default).toHaveBeenCalledTimes(1); + expect(loader.isWasmInitialized()).toBe(false); + + await expect(loader.initWasm()).resolves.toBeDefined(); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + expect(loader.isWasmInitialized()).toBe(true); + }); + + it('does not let a superseded failure clear a newer initialization', async () => { + let rejectFirst!: (reason: Error) => void; + wasmModuleMock.default.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject; + }) + ); + const loader = await loadLoader(); + + const firstInit = loader.initWasm(); + const firstResult = firstInit.catch((error: unknown) => error); + await vi.waitFor(() => expect(wasmModuleMock.default).toHaveBeenCalledTimes(1)); + + loader.resetWasm(); + await expect(loader.initWasm()).resolves.toBeDefined(); + rejectFirst(new Error('superseded load failure')); + + await expect(firstResult).resolves.toEqual( + expect.objectContaining({ message: expect.stringMatching(/superseded load failure/) }) + ); + await expect(loader.initWasm()).resolves.toBeDefined(); + expect(wasmModuleMock.default).toHaveBeenCalledTimes(2); + expect(loader.isWasmInitialized()).toBe(true); }); it('cleanupWasm frees resources and clears initialization state', async () => {