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
17 changes: 16 additions & 1 deletion src/core/layout-strategies.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type {GeneratorParams, LayoutType, Point} from '../types';
import {createPointCollector, getSafeRadius, validateLayoutParams} from './geometry-utils';
import {
createPointCollector,
getSafeRadius,
isWithinCutoffZone,
validateLayoutParams,
} from './geometry-utils';

export interface LayoutStrategy {
calculatePoints(params: GeneratorParams): Point[];
Expand Down Expand Up @@ -135,3 +140,13 @@ const strategies: Record<LayoutType, LayoutStrategy> = {
};

export const getLayoutStrategy = (layout: LayoutType) => strategies[layout];

/**
* Full tube-point layout for the given params: the strategy points minus the
* impingement cut-off zones. Shared by the layout worker and its synchronous
* fallback so both paths produce identical results.
*/
export const computeLayoutPoints = (params: GeneratorParams): Point[] =>
getLayoutStrategy(params.tubeLayout)
.calculatePoints(params)
.filter((point) => !isWithinCutoffZone(point, params));
31 changes: 22 additions & 9 deletions src/hooks/useGeneratorState.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {useCallback, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
import type React from 'react';
import {DEFAULT_PARAMS, MAX_TUBE_POINTS} from '../constants';
import {getLayoutStrategy} from '../core/layout-strategies';
import {estimateLayoutPointCount, isWithinCutoffZone} from '../core/geometry-utils';
import {computeLayoutPoints} from '../core/layout-strategies';
import {estimateLayoutPointCount} from '../core/geometry-utils';
import type {GeneratorParams, ModifiedHole, Point} from '../types';
import {generateStepInWorker, warmupCadWorker} from '../services/cad-worker-client';
import {requestLayout} from '../services/layout-worker-client';
import type {CadWorkerProgressMessage} from '../services/cad-worker-protocol';

type WorkerStatus = 'idle' | 'warming' | 'ready' | 'error';
Expand Down Expand Up @@ -47,17 +48,29 @@ export default function useGeneratorState(): UseGeneratorStateResult {
[deferredParams],
);

// Guard against pathological inputs (huge diameter + tiny pitch) that would
// otherwise spin an O((D/pitch)^2) loop on the main thread and freeze the tab.
// Guard against pathological inputs (huge diameter + tiny pitch). The compute
// itself runs off-thread, but the canvas render and RBush index are still
// main-thread, so this cap protects those.
const layoutTooLarge = estimatedPointCount > MAX_TUBE_POINTS;

const tubeCoords = useMemo<Point[]>(() => {
// Layout points are generated in a Web Worker so the UI thread stays free.
// Seed synchronously with the initial params to avoid a first-paint flash,
// then let the worker update on every (deferred) change, ignoring stale
// responses (latest request wins).
const [tubeCoords, setTubeCoords] = useState<Point[]>(() => computeLayoutPoints(DEFAULT_PARAMS));
const layoutRequestSeq = useRef(0);

useEffect(() => {
if (layoutTooLarge) {
return [];
setTubeCoords([]);
return;
Comment on lines 64 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate pending layout requests when over the cap

If a valid layout request is still in flight and the user changes to parameters whose estimate exceeds MAX_TUBE_POINTS, this branch clears the coordinates but leaves layoutRequestSeq unchanged. The old promise still has the current sequence value and can later call setTubeCoords(points), so the preview and hole counts can repopulate with an out-of-date layout while layoutTooLarge is true. Increment the sequence or otherwise cancel/ignore pending requests before returning here.

Useful? React with 👍 / 👎.

}
return getLayoutStrategy(deferredParams.tubeLayout)
.calculatePoints(deferredParams)
.filter((point) => !isWithinCutoffZone(point, deferredParams));
const seq = ++layoutRequestSeq.current;
void requestLayout(deferredParams).then((points) => {
if (seq === layoutRequestSeq.current) {
setTubeCoords(points);
}
});
}, [deferredParams, layoutTooLarge]);
Comment on lines +61 to 74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a critical race condition in the useEffect hook when layoutTooLarge becomes true.\n\nIf a layout request is currently pending and the user quickly changes parameters such that layoutTooLarge becomes true:\n1. The effect runs again, enters the if (layoutTooLarge) block, calls setTubeCoords([]), and returns early.\n2. Crucially, layoutRequestSeq.current is NOT incremented because the early return happens before the increment.\n3. When the pending request eventually resolves, the condition seq === layoutRequestSeq.current will evaluate to true (since layoutRequestSeq.current was never incremented).\n4. The stale points will then be set via setTubeCoords(points), completely overwriting the empty array and bypassing the layoutTooLarge guard. This can freeze the main thread/tab when rendering the massive layout.\n\nUsing the standard React active flag pattern in the effect cleanup solves this race condition elegantly and eliminates the need for the layoutRequestSeq ref entirely.

  useEffect(() => {\n    let active = true;\n    if (layoutTooLarge) {\n      setTubeCoords([]);\n      return;\n    }\n    void requestLayout(deferredParams).then((points) => {\n      if (active) {\n        setTubeCoords(points);\n      }\n    });\n    return () => {\n      active = false;\n    };\n  }, [deferredParams, layoutTooLarge]);


const warmupWorker = useCallback(async () => {
Expand Down
74 changes: 74 additions & 0 deletions src/services/layout-worker-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type {GeneratorParams, Point} from '../types';
import {computeLayoutPoints} from '../core/layout-strategies';
import {decodePoints} from './layout-worker-protocol';
import type {LayoutWorkerRequest, LayoutWorkerResult} from './layout-worker-protocol';

const createRequestId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
};

type Pending = {
resolve: (points: Point[]) => void;
reject: (error: Error) => void;
};

let worker: Worker | null = null;
const pending = new Map<string, Pending>();

const destroyWorker = (error: Error) => {
const current = worker;
worker = null;
const handlers = Array.from(pending.values());
pending.clear();
handlers.forEach((handler) => handler.reject(error));
current?.terminate();
};

const getWorker = (): Worker | null => {
if (worker) return worker;
if (typeof Worker === 'undefined') return null;

const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle worker construction failures before falling back

When Worker exists but this constructor throws, for example because a deployed CSP blocks worker-src or module workers are unavailable, the exception escapes before requestLayout reaches its Promise .catch(). In that environment the documented synchronous fallback is skipped and the React effect fails instead of computing the layout on the main thread. Wrap worker creation in try/catch and return null (or fall back directly) on construction failure.

Useful? React with 👍 / 👎.

instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => {
const message = event.data;
if (!message || typeof message !== 'object') return;
const handler = pending.get(message.requestId);
if (!handler) return;
pending.delete(message.requestId);
if (message.ok) {
handler.resolve(decodePoints(message.buffer));
} else {
handler.reject(new Error(message.message));
}
});
instance.addEventListener('error', (event) => {
const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.');
if (worker === instance) {
destroyWorker(error);
}
});

worker = instance;
return instance;
};
Comment on lines +30 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instantiating a new Worker can throw a synchronous exception in environments with strict Content Security Policies (CSP) (e.g., SecurityError: Failed to construct 'Worker') or if the browser doesn't support module workers.\n\nSince getWorker() is called synchronously inside requestLayout before the promise is created, any synchronous throw will propagate up and crash the React render/effect cycle (triggering an Error Boundary).\n\nWrapping the worker instantiation in a try...catch block and returning null on failure ensures that the application safely falls back to the synchronous layout computation without crashing.

const getWorker = (): Worker | null => {\n  if (worker) return worker;\n  if (typeof Worker === 'undefined') return null;\n\n  try {\n    const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});\n    instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => {\n      const message = event.data;\n      if (!message || typeof message !== 'object') return;\n      const handler = pending.get(message.requestId);\n      if (!handler) return;\n      pending.delete(message.requestId);\n      if (message.ok) {\n        handler.resolve(decodePoints(message.buffer));\n      } else {\n        handler.reject(new Error(message.message));\n      }\n    });\n    instance.addEventListener('error', (event) => {\n      const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.');\n      if (worker === instance) {\n        destroyWorker(error);\n      }\n    });\n\n    worker = instance;\n    return instance;\n  } catch (error) {\n    console.error('Failed to initialize layout worker:', error);\n    return null;\n  }\n};


/**
* Compute a layout off the main thread. Falls back to a synchronous compute if
* a worker can't be created or the worker rejects, so callers always get points.
*/
export const requestLayout = (params: GeneratorParams): Promise<Point[]> => {
const w = getWorker();
if (!w) {
return Promise.resolve(computeLayoutPoints(params));
}

const requestId = createRequestId();
return new Promise<Point[]>((resolve, reject) => {
pending.set(requestId, {resolve, reject});
const request: LayoutWorkerRequest = {requestId, params};
w.postMessage(request);
}).catch(() => computeLayoutPoints(params));
Comment on lines +69 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If w.postMessage(request) throws an error synchronously (for example, a DataCloneError if non-serializable parameters are introduced in the future, or if the worker is in an invalid state), the promise is rejected, but the requestId is never removed from the pending map. This can lead to a memory leak.\n\nWrapping w.postMessage in a try...catch block inside the promise executor and deleting the pending request on failure prevents this leak.

  return new Promise<Point[]>((resolve, reject) => {\n    pending.set(requestId, {resolve, reject});\n    const request: LayoutWorkerRequest = {requestId, params};\n    try {\n      w.postMessage(request);\n    } catch (error) {\n      pending.delete(requestId);\n      reject(error);\n    }\n  }).catch(() => computeLayoutPoints(params));

};
24 changes: 24 additions & 0 deletions src/services/layout-worker-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {describe, expect, it} from 'vitest';
import {decodePoints, encodePoints} from './layout-worker-protocol';
import type {Point} from '../types';

describe('layout point codec', () => {
it('round-trips points through the Float64 buffer', () => {
const points: Point[] = [
{x: 0, y: 0},
{x: 12.5, y: -3.25},
{x: -240.125, y: 199.5},
];
expect(decodePoints(encodePoints(points))).toEqual(points);
});

it('handles an empty layout', () => {
const buffer = encodePoints([]);
expect(buffer.byteLength).toBe(0);
expect(decodePoints(buffer)).toEqual([]);
});

it('produces a buffer of two float64s per point', () => {
expect(encodePoints([{x: 1, y: 2}, {x: 3, y: 4}]).byteLength).toBe(2 * 2 * 8);
});
});
30 changes: 30 additions & 0 deletions src/services/layout-worker-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {GeneratorParams, Point} from '../types';

export type LayoutWorkerRequest = {
requestId: string;
params: GeneratorParams;
};

export type LayoutWorkerResult =
| {requestId: string; ok: true; buffer: ArrayBuffer}
| {requestId: string; ok: false; message: string};

/** Pack points into an interleaved [x0,y0,x1,y1,...] Float64Array buffer. */
export const encodePoints = (points: Point[]): ArrayBuffer => {
const array = new Float64Array(points.length * 2);
for (let i = 0; i < points.length; i++) {
array[i * 2] = points[i].x;
array[i * 2 + 1] = points[i].y;
}
return array.buffer;
};

/** Reverse of encodePoints. */
export const decodePoints = (buffer: ArrayBuffer): Point[] => {
const array = new Float64Array(buffer);
const points: Point[] = new Array(array.length / 2);
for (let i = 0; i < points.length; i++) {
points[i] = {x: array[i * 2], y: array[i * 2 + 1]};
}
return points;
};
28 changes: 28 additions & 0 deletions src/services/layout-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/// <reference lib="webworker" />

import {computeLayoutPoints} from '../core/layout-strategies';
import {encodePoints} from './layout-worker-protocol';
import type {LayoutWorkerRequest, LayoutWorkerResult} from './layout-worker-protocol';

const ctx = self as unknown as DedicatedWorkerGlobalScope;

ctx.onmessage = (event: MessageEvent<LayoutWorkerRequest>) => {
const request = event.data;
if (!request || typeof request !== 'object' || typeof request.requestId !== 'string') {
return;
}

try {
const points = computeLayoutPoints(request.params);
const buffer = encodePoints(points);
const message: LayoutWorkerResult = {requestId: request.requestId, ok: true, buffer};
ctx.postMessage(message, [buffer]);
} catch (error) {
const message: LayoutWorkerResult = {
requestId: request.requestId,
ok: false,
message: error instanceof Error ? error.message : String(error),
};
ctx.postMessage(message);
}
};