Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2849_keys-0EA5E9" alt="i18n 19 locales — 2849 keys">
<img src="https://img.shields.io/badge/Tests-5807%2B_%2F_529_files-22C55E" alt="5807+ tests / 529 files">
<img src="https://img.shields.io/badge/Tests-5807%2B_%2F_531_files-22C55E" alt="5807+ tests / 531 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -471,7 +471,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2849 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (5807+ tests / 529 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (5807+ tests / 531 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -508,7 +508,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (5807+ tests, 529 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (5807+ tests, 531 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -667,7 +667,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-07-28):**
- **5807+ unit tests** across **529 test files** — all passing
- **5807+ unit tests** across **531 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2849 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)

Expand Down
2 changes: 1 addition & 1 deletion features/featureCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ const RAW_FEATURE_CATALOG: CatalogEntryInput[] = [
description: 'Conditionally shows DuckDB query tab',
},
],
implementedIn: ['services/duckdb/', 'workers/duckdbWorker.ts', 'hooks/useDuckDb.ts'],
implementedIn: ['services/duckdb/', 'workers/v2/duckdb.worker.ts', 'hooks/useDuckDb.ts'],
drifts: [],
},

Expand Down
39 changes: 39 additions & 0 deletions packages/worker-bus/src/workerBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export class WorkerBus {
private readonly dlq: DeadLetterQueue;
private readonly progress = new ProgressEmitter();
private readonly tokens = new Map<string, CancellationToken>();
// QNBS-v3: [Tracks which pool each in-flight task is running on, so terminatePool() can cancel exactly the affected tasks instead of leaving their result promises hanging forever.]
private readonly taskPools = new Map<string, string>();
private readonly listeners = new Set<BusEventListener>();
private running = false;
private pumpScheduled = false;
Expand All @@ -49,6 +51,11 @@ export class WorkerBus {
this.schedulePump();
}

// QNBS-v3: [Lets callers (e.g. ensureDuckDbPool()) detect a pool removed via terminatePool() and re-register it, instead of assuming a non-null bus always has every pool.]
hasPool(poolId: string): boolean {
return this.pools.has(poolId);
}

registerPool(
poolId: string,
capabilities: readonly WorkerCapability[],
Expand Down Expand Up @@ -195,6 +202,25 @@ export class WorkerBus {
this.progress.clear();
}

/**
* Terminate all workers in a single named pool without shutting down the rest of the bus.
* QNBS-v3: unlike shutdown() (tears down every pool), this only affects `poolId` — added so
* duckdbClient's terminate()/shutdown() can stop the DuckDB worker without also killing
* in-flight inference/webllm/plugin work on the shared bus (docs/adr/0014-worker-generation-
* duplication.md's migration follow-up). The pool is removed from the registry; a later task
* routed to its capabilities fails with NO_POOL until the bus re-registers it.
* QNBS-v3: [Cancels every in-flight task routed to this pool before removing it — otherwise their result promises would await a RESULT message from a worker that's already been terminated and never arrives.]
*/
async terminatePool(poolId: string): Promise<void> {
const pool = this.pools.get(poolId);
if (!pool) return;
for (const [taskId, taskPoolId] of this.taskPools) {
if (taskPoolId === poolId) this.cancel(taskId, 'Pool terminated');
}
await pool.terminateAll();
this.pools.delete(poolId);
}
Comment thread
qnbs marked this conversation as resolved.

// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
Expand All @@ -220,6 +246,19 @@ export class WorkerBus {
};
}

this.taskPools.set(task.taskId, pool.poolId);
try {
return await this.runOnPool(task, token, pool);
} finally {
this.taskPools.delete(task.taskId);
}
}

private async runOnPool<TResult>(
task: WorkerTask,
token: CancellationToken,
pool: WorkerPool,
): Promise<TaskResult<TResult>> {
const worker = await pool.acquire(token.signal);
const startedAt = performance.now();
const queueTimeMs = Math.round(startedAt - task.createdAt);
Expand Down
61 changes: 61 additions & 0 deletions packages/worker-bus/tests/workerBus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,67 @@ describe('WorkerBus', () => {
expect(pool).toBeUndefined();
});

it('terminatePool terminates and removes only the named pool, leaving others intact', async () => {
bus.registerPool('other', ['db.duckdb'], {
maxWorkers: 1,
minWorkers: 1,
idleTimeoutMs: 120_000,
workerScript: '/other.worker.js',
capabilities: ['db.duckdb'],
labels: {},
});
const pools = (bus as unknown as { pools: Map<string, WorkerPool> }).pools;
const terminateAllSpy = vi.spyOn(pools.get('fake')!, 'terminateAll');
vi.spyOn(pools.get('other')!, 'acquire').mockReturnValue(new Promise(() => {}));

await bus.terminatePool('fake');

expect(terminateAllSpy).toHaveBeenCalled();
expect(pools.get('fake')).toBeUndefined();
expect(pools.get('other')).toBeDefined();
});

it('terminatePool is a no-op for an unknown pool id', async () => {
await expect(bus.terminatePool('does-not-exist')).resolves.toBeUndefined();
});

it('terminatePool cancels only the in-flight tasks routed to that pool', async () => {
// QNBS-v3: [beforeEach's mocked acquire() ignores the abort signal entirely (returns a
// promise that never settles); override it here with a signal-aware implementation
// mirroring WorkerPool's real waitForIdle() so cancellation can actually be observed
// unsticking the caller, not just verified as "cancel() was called".]
const fakePool = (bus as unknown as { pools: Map<string, WorkerPool> }).pools.get('fake')!;
vi.spyOn(fakePool, 'acquire').mockImplementation(
(signal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
);
bus.registerPool('other', ['db.duckdb'], {
maxWorkers: 1,
minWorkers: 1,
idleTimeoutMs: 120_000,
workerScript: '/other.worker.js',
capabilities: ['db.duckdb'],
labels: {},
});
const otherPool = (bus as unknown as { pools: Map<string, WorkerPool> }).pools.get('other')!;
vi.spyOn(otherPool, 'acquire').mockReturnValue(new Promise(() => {}));

const fakeHandle = bus.enqueue('test.task', {}, { capabilities: ['inference.text'] });
const otherHandle = bus.enqueue('other.task', {}, { capabilities: ['db.duckdb'] });
otherHandle.result.catch(() => {});

const cancelSpy = vi.spyOn(bus, 'cancel');
await bus.terminatePool('fake');

expect(cancelSpy).toHaveBeenCalledTimes(1);
expect(cancelSpy).toHaveBeenCalledWith(fakeHandle.taskId, 'Pool terminated');
// Without the fix, this task would hang forever at pool.acquire() — terminatePool()
// cancelling it lets the result promise settle instead.
await expect(fakeHandle.result).rejects.toThrow('Aborted');
});

it('progress callback receives emitted progress', async () => {
const progressUpdates: Array<{ stage: string; progress: number }> = [];
let capturedTaskId = '';
Expand Down
174 changes: 108 additions & 66 deletions services/duckdb/duckdbClient.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,119 @@
// QNBS-v3: Singleton proxy for the DuckDB-WASM worker.
// Exposes typed query/exec/init/shutdown helpers with AbortSignal cancellation.
// Worker is instantiated lazily on first call to init().

import type {
DuckDbRequest,
DuckDbRequestType,
DuckDbResponse,
DuckDbWorkerEvent,
} from '../../workers/duckdbWorker';

let worker: Worker | null = null;
const pendingResolvers = new Map<string, (res: DuckDbResponse) => void>();
let messageIdCounter = 0;
// QNBS-v3: Settable by useDuckDb to surface OPFS fallback state in Redux.
let opfsFallbackCb: ((reason: string) => void) | null = null;
// QNBS-v3: Singleton proxy for DuckDB analytics — routes through the shared WorkerBus v2 'duckdb'
// pool (docs/adr/0014-worker-generation-duplication.md migration, superseded by
// ADR-0015). Public API is unchanged from the v1-backed client (init/query/exec/
// shutdown/terminate/setOpfsFallbackHandler) so all 5 consumers (useDuckDb.ts,
// duckdbAnalytics.ts, duckdbMigration.ts, ragVectorMigration.ts, telemetryService.ts)
// need no changes — only the transport underneath swapped.

import { ensureDuckDbPool, getWorkerBus } from '../workerBusManager';

export type DuckDbRequestType = 'INIT' | 'QUERY' | 'EXEC' | 'SHUTDOWN';

export interface DuckDbResponse {
messageId: string;
ok: boolean;
rows?: Record<string, unknown>[];
error?: string;
latencyMs?: number;
}

const TASK_TYPE: Record<DuckDbRequestType, string> = {
INIT: 'db.duckdb.init',
QUERY: 'db.duckdb.query',
EXEC: 'db.duckdb.exec',
SHUTDOWN: 'db.duckdb.shutdown',
};

let messageIdCounter = 0;
function generateMessageId(): string {
return `duckdb-${Date.now()}-${++messageIdCounter}`;
}

function getWorker(): Worker {
if (!worker) {
// QNBS-v3: Vite import.meta.url pattern — same as inference.worker.ts.
worker = new Worker(new URL('../../workers/duckdbWorker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (event: MessageEvent) => {
const data = event.data as DuckDbResponse | DuckDbWorkerEvent;
// QNBS-v3: OPFS_FALLBACK is out-of-band — no messageId resolver to call.
if ((data as DuckDbWorkerEvent).type === 'OPFS_FALLBACK') {
opfsFallbackCb?.((data as DuckDbWorkerEvent).reason);
return;
}
const { messageId } = data as DuckDbResponse;
const resolve = pendingResolvers.get(messageId);
if (resolve) {
pendingResolvers.delete(messageId);
resolve(data as DuckDbResponse);
}
});
worker.addEventListener('error', (event) => {
// Reject all pending promises on fatal worker error
const error = event.message ?? 'DuckDB worker crashed';
for (const [id, resolve] of pendingResolvers) {
resolve({ messageId: id, ok: false, error });
}
pendingResolvers.clear();
worker = null;
});
}
return worker;
// QNBS-v3: Settable by useDuckDb to surface OPFS fallback state in Redux.
let opfsFallbackCb: ((reason: string) => void) | null = null;

function errorMessage(err: unknown): string {
const code = (err as { code?: string } | undefined)?.code;
if (code === 'CIRCUIT_OPEN') return 'DuckDB temporarily unavailable (circuit open)';
return err instanceof Error ? err.message : String(err);
}

function send(
async function send(
type: DuckDbRequestType,
sql?: string,
params?: readonly unknown[],
signal?: AbortSignal,
isReinitRetry = false,
): Promise<DuckDbResponse> {
const messageId = generateMessageId();
const w = getWorker();
const start = Date.now();

return new Promise<DuckDbResponse>((resolve) => {
pendingResolvers.set(messageId, resolve);
// QNBS-v3: attach synchronously, before the `await` below — v1 set up its abort listener
// inside a synchronous Promise executor, so a caller aborting in the same tick as the
// call was always caught. This function is async and awaits ensureDuckDbPool() before
// a handle exists, which opens a gap; a same-tick abort must not be lost inside it.
let abortedEarly = signal?.aborted ?? false;
let handleRef: { cancel: (reason?: string) => void } | undefined;
signal?.addEventListener(
'abort',
() => {
abortedEarly = true;
handleRef?.cancel('Aborted');
},
{ once: true },
);

if (signal) {
signal.addEventListener('abort', () => {
w.postMessage({ type: 'WORKER_CANCEL', messageId });
pendingResolvers.delete(messageId);
resolve({ messageId, ok: false, error: 'Aborted' });
});
}
const bus = await ensureDuckDbPool();
if (!bus) return { messageId, ok: false, error: 'WorkerBus v2 unavailable' };

const req: DuckDbRequest = { messageId, type, sql, params };
w.postMessage(req);
});
// QNBS-v3: v1 never retried a failed QUERY/EXEC (caller decided) — disabling the bus's default
// 2-retry here keeps that behavior instead of silently stacking under
// duckdbAnalytics.ts's own withDuckDbRetry() app-level retry (app/listenerMiddleware.ts).
const handle = bus.enqueue<
{ sql?: string | undefined; params?: readonly unknown[] | undefined },
unknown
>(
TASK_TYPE[type],
{ sql, params },
{
capabilities: ['db.duckdb'],
retryPolicy: { maxRetries: 0 },
onProgress: (p) => {
if (p.stage === 'opfs-fallback') opfsFallbackCb?.(p.message ?? 'OPFS unavailable');
},
},
);
handleRef = handle;
if (abortedEarly) handle.cancel('Aborted');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

try {
const raw = await handle.result;
const latencyMs = Date.now() - start;
if (type === 'QUERY') {
return { messageId, ok: true, rows: raw as Record<string, unknown>[], latencyMs };
}
return { messageId, ok: true, latencyMs };
} catch (err) {
const latencyMs = Date.now() - start;
if (signal?.aborted) return { messageId, ok: false, error: 'Aborted', latencyMs };
const message = errorMessage(err);
// QNBS-v3: [The duckdb pool's worker can idle-terminate (minWorkers:0) or crash-restart; a
// freshly spawned worker's module state has no `connection` until INIT runs again.
// Transparently re-init once and retry, instead of surfacing a confusing "not
// initialized" error for what looks like a routine idle-timeout respawn.]
if (
!isReinitRetry &&
type !== 'INIT' &&
type !== 'SHUTDOWN' &&
message === 'DuckDB not initialized'
) {
const reinit = await send('INIT', undefined, undefined, signal, true);
if (reinit.ok) {
return send(type, sql, params, signal, true);
}
}
return { messageId, ok: false, error: message, latencyMs };
}
}

export const duckdbClient = {
Expand All @@ -98,14 +137,17 @@ export const duckdbClient = {
return send('SHUTDOWN', undefined, undefined, signal);
},

/** Terminate the worker immediately (no flush). */
/**
* Terminate the DuckDB pool immediately (no flush).
* QNBS-v3: scoped via WorkerBus.terminatePool('duckdb') so this doesn't tear down the shared
* bus's other pools (inference/webllm/plugin) — bus.shutdown() would kill all of them.
*/
terminate(): void {
worker?.terminate();
worker = null;
pendingResolvers.clear();
const bus = getWorkerBus();
void bus?.terminatePool('duckdb');
},

/** Register a callback invoked when the worker falls back to in-memory (OPFS unavailable). */
/** Register a callback invoked when DuckDB falls back to in-memory (OPFS unavailable). */
setOpfsFallbackHandler(cb: ((reason: string) => void) | null): void {
opfsFallbackCb = cb;
},
Expand Down
Loading