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
64 changes: 64 additions & 0 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,67 @@ test.describe('Modeler: Export', () => {
await expect(stl).toBeEnabled();
});
});

test.describe('Modeler: Worker concurrency', () => {
test.beforeEach(async ({ page }) => {
await enterModeler(page);
});

// The property #51 is actually about: an export must not block viewport
// evaluation. The worker runs each message to completion with no yield
// point, so with a single shared worker every evaluation queued behind a
// 256³ export and the viewport froze for its duration.
//
// The unit tests cannot show this — their fake worker never blocks. This
// one exercises real workers doing real work, and fails against a
// single-worker bridge.
test('viewport keeps evaluating while an export runs', async ({ page }) => {
// Unlike the rest of the suite this waits on a real 256-cubed export, so it
// runs 25-27s on a fast machine against the 30s default. CI is slower —
// on the run that first exposed the bug below, the 30s test budget expired
// before the inner 15s wait had even started. Triple it rather than leave
// a genuine pass hostage to the runner's speed.
test.slow();

await addShape(page, 'Box');

// Wait for the first evaluation to settle so we have a baseline.
await page.waitForFunction(() => {
const s = (window as any).__MODELER_STORE__;
return s?.sdfDisplay && !s.evaluating;
}, null, { timeout: 15000 });

const baseline = await page.evaluate(() =>
JSON.stringify((window as any).__MODELER_STORE__.sdfDisplay.paramValues));

// Kick off an export and wait until it is genuinely in flight.
await page.locator('[title="Export STL"]').click();
const progress = page.locator('[data-testid="export-progress"]');
await expect(progress).toBeVisible({ timeout: 15000 });

// Mutate the model while the export is running. This changes paramValues,
// so a completed evaluation is observable without a structural edit.
//
// It has to be a parameter this shape actually has: the node here is a Box,
// whose params are width/height/depth (NODE_DEFAULTS.box). Setting an
// unrelated key leaves the emitted uniforms byte-identical — convert.ts
// reads only width/height/depth for a box — so the wait below could never
// observe a change, whatever the workers were doing.
await page.evaluate(() => {
const store = (window as any).__MODELER_STORE__;
store.updateNodeParams(store.tree.id, { width: 7 });
});

// The evaluation must complete...
await page.waitForFunction((prev) => {
const s = (window as any).__MODELER_STORE__;
return s?.sdfDisplay && !s.evaluating
&& JSON.stringify(s.sdfDisplay.paramValues) !== prev;
}, baseline, { timeout: 15000 });

// ...while the export is still running. This ordering is the whole point:
// on a single worker the evaluation could not land until the export
// finished and the progress bar had already gone.
await expect(progress).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion src/components/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export function Toolbar({ onMobileTree, onMobileProps }: { onMobileTree?: () =>
</div>

{exportProgress && (
<div className="h-1 w-full shrink-0" style={{ background: 'var(--bg-elevated)' }}>
<div data-testid="export-progress" className="h-1 w-full shrink-0" style={{ background: 'var(--bg-elevated)' }}>
<div
className="h-full transition-all duration-200"
style={{ width: `${Math.round(exportProgress.percent)}%`, background: 'var(--accent)' }}
Expand Down
134 changes: 95 additions & 39 deletions src/engine/workerBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { WorkerRequest } from '../types/geometry';

/**
* Regression tests for the WorkerBridge correlation-id protocol.
* Regression tests for the WorkerBridge protocol.
*
* The first two cases are the counterexamples TLC produced against the old
* single-handler design (specs/WorkerBridge.tla), replayed against the real
* bridge. Both fail on that design and pass on the current one.
* The correlation-id cases replay counterexamples TLC produced against the
* original single-handler design (specs/WorkerBridge.tla). The channel cases
* cover the export/evaluate split: exports get their own worker so viewport
* evaluations never queue behind a 256³ export.
*/

class FakeWorker {
static latest: FakeWorker;
/** Every worker constructed, in order: [0] evaluates, [1] exports. */
static instances: FakeWorker[] = [];

onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: any) => void) | null = null;
sent: WorkerRequest[] = [];

constructor() { FakeWorker.latest = this; }
constructor() { FakeWorker.instances.push(this); }
postMessage(msg: WorkerRequest) { this.sent.push(msg); }
terminate() {}

Expand Down Expand Up @@ -44,29 +47,98 @@ function sdfResponse(rid: number, glsl: string) {
};
}

const exportResult = (rid: number) => ({
type: 'exportResult', rid, format: 'stl', data: new ArrayBuffer(8),
});

let bridge: typeof import('./workerBridge').workerBridge;
let worker: FakeWorker;
let evalWorker: FakeWorker;
let exportWorker: FakeWorker;

beforeEach(async () => {
vi.resetModules();
FakeWorker.instances = [];
vi.stubGlobal('Worker', FakeWorker);
bridge = (await import('./workerBridge')).workerBridge;
worker = FakeWorker.latest;
worker.emit({ type: 'ready' });

expect(FakeWorker.instances).toHaveLength(2);
[evalWorker, exportWorker] = FakeWorker.instances;
evalWorker.emit({ type: 'ready' });
exportWorker.emit({ type: 'ready' });
await flush();
});

describe('WorkerBridge channels', () => {
it('sends evaluates and exports to different workers', async () => {
void bridge.evaluate(null);
void bridge.exportSTL(null);
await flush();

expect(evalWorker.sent.map((r) => r.type)).toEqual(['evaluate']);
expect(exportWorker.sent.map((r) => r.type)).toEqual(['exportSTL']);
});

it('settles an evaluate while an export is still running', async () => {
// The point of the split. With one worker the export occupied it and the
// evaluate could not be answered until the export finished, freezing the
// viewport for the duration.
const exported = track(bridge.exportSTL(null));
await flush();
const evaluated = track(bridge.evaluate(null));
await flush();

evalWorker.emit(sdfResponse(evalWorker.sent[0].rid, 'GLSL'));
await flush();

expect(evaluated.settled).toBe(true);
expect(evaluated.value?.glsl).toBe('GLSL');
expect(exported.settled).toBe(false);

// And the export still completes afterwards.
exportWorker.emit(exportResult(exportWorker.sent[0].rid));
await flush();
expect(exported.value).toBeInstanceOf(Blob);
});

it('routes both export formats to the export worker', async () => {
void bridge.exportSTL(null);
await flush();
void bridge.export3MF(null);
await flush();

expect(exportWorker.sent.map((r) => r.type)).toEqual(['exportSTL', 'export3MF']);
expect(evalWorker.sent).toHaveLength(0);
});

it('rejects only the crashed worker\'s requests', async () => {
const exported = track(bridge.exportSTL(null));
const evaluated = track(bridge.evaluate(null));
await flush();

evalWorker.fail('eval worker died');
await flush();

expect(evaluated.error?.message).toBe('eval worker died');
// The export lives on a different worker and must be untouched.
expect(exported.settled).toBe(false);

exportWorker.emit(exportResult(exportWorker.sent[0].rid));
await flush();
expect(exported.value).toBeInstanceOf(Blob);
});
});

describe('WorkerBridge correlation ids', () => {
it('does not settle one evaluate with another evaluate\'s response', async () => {
// TLC counterexample for NoCrossTalk (specs/WorkerBridge.tla).
const first = track(bridge.evaluate(null));
const second = track(bridge.evaluate(null));
await flush();
expect(worker.sent).toHaveLength(2);
expect(evalWorker.sent).toHaveLength(2);

// The worker answers the FIRST request while the second is still in flight.
worker.emit(sdfResponse(worker.sent[0].rid, 'FIRST'));
worker.emit(sdfResponse(worker.sent[1].rid, 'SECOND'));
evalWorker.emit(sdfResponse(evalWorker.sent[0].rid, 'FIRST'));
evalWorker.emit(sdfResponse(evalWorker.sent[1].rid, 'SECOND'));
await flush();

// The old design resolved `second` with FIRST's geometry here.
Expand All @@ -77,25 +149,21 @@ describe('WorkerBridge correlation ids', () => {
});

it('settles an export whose handler would have been displaced by an evaluate', async () => {
// TLC counterexample for NoOrphan: the export's handler is overwritten by
// the evaluate, and its exportResult then matches neither branch of the
// evaluate handler.
const data = new ArrayBuffer(8);
// TLC counterexample for NoOrphan. The single handler slot is gone, and so
// is the shared worker, but the settlement guarantee is what matters.
const exported = track(bridge.exportSTL(null));
await flush();
const exportRid = worker.sent[0].rid;

const evaluated = track(bridge.evaluate(null));
await flush();

worker.emit({ type: 'exportResult', rid: exportRid, format: 'stl', data });
exportWorker.emit(exportResult(exportWorker.sent[0].rid));
await flush();

// The old design left this pending forever — Toolbar's await never returned.
expect(exported.settled).toBe(true);
expect(exported.value).toBeInstanceOf(Blob);

worker.emit(sdfResponse(worker.sent[1].rid, 'GLSL'));
evalWorker.emit(sdfResponse(evalWorker.sent[0].rid, 'GLSL'));
await flush();
expect(evaluated.settled).toBe(true);
});
Expand All @@ -108,8 +176,8 @@ describe('WorkerBridge correlation ids', () => {
void bridge.export3MF(null, (stage) => mf.push(stage));
await flush();

worker.emit({ type: 'progress', rid: worker.sent[0].rid, stage: 'stl-stage', percent: 10 });
worker.emit({ type: 'progress', rid: worker.sent[1].rid, stage: '3mf-stage', percent: 20 });
exportWorker.emit({ type: 'progress', rid: exportWorker.sent[0].rid, stage: 'stl-stage', percent: 10 });
exportWorker.emit({ type: 'progress', rid: exportWorker.sent[1].rid, stage: '3mf-stage', percent: 20 });
await flush();

expect(stl).toEqual(['stl-stage']);
Expand All @@ -122,40 +190,28 @@ describe('WorkerBridge correlation ids', () => {
const evaluated = track(bridge.evaluate(null));
await flush();

worker.emit({ type: 'error', rid: worker.sent[0].rid, message: 'No geometry to export' });
exportWorker.emit({ type: 'error', rid: exportWorker.sent[0].rid, message: 'No geometry to export' });
await flush();

expect(exported.error?.message).toBe('No geometry to export');
expect(evaluated.settled).toBe(false);

worker.emit(sdfResponse(worker.sent[1].rid, 'GLSL'));
evalWorker.emit(sdfResponse(evalWorker.sent[0].rid, 'GLSL'));
await flush();
expect(evaluated.value?.glsl).toBe('GLSL');
});

it('rejects every outstanding request when the worker itself fails', async () => {
const exported = track(bridge.exportSTL(null));
const evaluated = track(bridge.evaluate(null));
await flush();

worker.fail('boom');
await flush();

expect(exported.error?.message).toBe('boom');
expect(evaluated.error?.message).toBe('boom');
});

it('ignores a duplicate response for an already-settled request', async () => {
const evaluated = track(bridge.evaluate(null));
await flush();
const rid = worker.sent[0].rid;
const rid = evalWorker.sent[0].rid;

worker.emit(sdfResponse(rid, 'GLSL'));
evalWorker.emit(sdfResponse(rid, 'GLSL'));
await flush();
expect(evaluated.value?.glsl).toBe('GLSL');

// Must not throw, and must not disturb any later request's entry.
expect(() => worker.emit(sdfResponse(rid, 'AGAIN'))).not.toThrow();
expect(() => evalWorker.emit(sdfResponse(rid, 'AGAIN'))).not.toThrow();
await flush();
expect(evaluated.value?.glsl).toBe('GLSL');
});
Expand Down
Loading
Loading