Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 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,103 @@ 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('hasPool reports true for a registered pool and false for an unknown one', () => {
expect(bus.hasPool('fake')).toBe(true);
expect(bus.hasPool('does-not-exist')).toBe(false);
});

it('hasPool returns false after terminatePool removes the pool', async () => {
await bus.terminatePool('fake');
expect(bus.hasPool('fake')).toBe(false);
});

it('isolates a throwing subscriber so a later subscriber still receives the same event', async () => {
// QNBS-v3: [emit() is only reached by 'circuit-breaker-open'/'backpressure-rejected' events
// (grep-verified — regular enqueue/completion never calls this.emit()); reuse the
// same circuit-breaker trigger as 'rejects when circuit breaker open' above, adding a
// throwing listener ahead of a recording one to prove the try/catch isolates it.]
const events: Array<{ kind: string }> = [];
bus.subscribe(() => {
throw new Error('listener boom');
});
bus.subscribe((ev: unknown) => events.push(ev as { kind: string }));

const cb = (
bus as unknown as { getCircuitBreaker: (t: string) => { recordFailure: () => void } }
).getCircuitBreaker('fragile.task');
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();

const handle = bus.enqueue('fragile.task', {});
await expect(handle.result).rejects.toThrow('Circuit breaker is open');

expect(events).toContainEqual(
expect.objectContaining({ kind: 'circuit-breaker-open', taskType: 'fragile.task' }),
);
});

it('progress callback receives emitted progress', async () => {
const progressUpdates: Array<{ stage: string; progress: number }> = [];
let capturedTaskId = '';
Expand Down
Loading
Loading