Answer the review: send the worker one request at a time, keep only the latest, and never wait for a dead one

The adversarial review confirmed three defects in this PR, all reproduced in
the browser.

1. Superseded graphs queued up in front of routes. The worker answers
   messages one at a time and cannot drop one it has started. With the
   jump-link layer on, every pause on the range slider posted a full graph
   build, seconds of work at 6-8 pc. Answers no longer wanted were thrown
   away only once built. A route asked for afterwards waited behind every
   one of them: a one-jump route took 44 s.

   RoutingClient now holds requests and sends them one at a time. While one
   is out, only the latest of each kind waits: a newer graph replaces an
   older one before it is ever built, and the older promise is rejected with
   SupersededRequest. Routes go ahead of graphs. The same question asked
   again while outstanding shares the answer rather than being worked twice,
   as when the layer is turned off and on during a build.

   The same scenario in the browser (layer on, range stepped 5 -> 8 pc with
   400 ms pauses, then Sol to Proxima): the route came back in 110 ms. The
   worker was sent "links 3, links 5, route, links 8"; 6 and 7 were never
   built.

2. A worker that failed left the panel stuck. With no error handling, a
   worker that failed to load (a 404 on its chunk after a redeploy) or
   threw left "Plotting…" and a disabled button for good, and a graph at a
   range could not be asked for again.

   The worker now answers an exception with a 'failed' message, which
   rejects that request. A worker that fails to load or dies is abandoned,
   and what it left outstanding, and everything asked afterwards, is
   answered in place. The scene releases the panel when a route fails, and
   forgets a graph range that was never drawn so it can be asked for again.

3. Nothing type-checked the worker. The application builder never reads
   webWorkerTsConfig, and bundles the worker with esbuild, which strips
   types without checking them. tsconfig.app.json leaves the file out. A
   type error in the worker shipped.

   `npm run worker:typecheck` (tsc -p tsconfig.worker.json) now runs in CI
   beside the other project checks. webWorkerTsConfig is removed from
   angular.json, since it only suggested that something checked the worker.

Tests with a fake worker cover one request at a time, a waiting graph
replaced and a route sent ahead of it, a question shared, a failure rejected
and the next request sent, and a failed worker's requests answered in place.
A scene test covers the panel released after a failed route. Negative
controls, each caught: several requests sent at once, a waiting graph kept,
graphs ahead of routes, a question asked twice, a failure answered as a
success, a failed worker waited on, the panel left pending, and a type error
in the worker (caught by worker:typecheck).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 15:27:50 +02:00
co-authored by Claude Opus 5
parent 965739e99e
commit f156e03822
9 changed files with 319 additions and 44 deletions
+120 -18
View File
@@ -8,27 +8,75 @@ export interface RouteAnswer {
readonly neededRangePc: number | null;
}
type Pending = (response: RoutingResponse) => void;
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
export class SupersededRequest extends Error {
constructor() {
super('Superseded by a newer request');
}
}
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
interface Outstanding {
readonly request: RoutingRequest;
/** The question without its id, so the same question asked twice can be recognised. */
readonly question: string;
readonly promise: Promise<RoutingResponse>;
readonly resolve: (response: RoutingResponse) => void;
readonly reject: (error: Error) => void;
}
function outstanding(request: RoutingRequest): Outstanding {
let resolve!: (response: RoutingResponse) => void;
let reject!: (error: Error) => void;
const promise = new Promise<RoutingResponse>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { requestId, ...question } = request;
return { request, question: JSON.stringify(question), promise, resolve, reject };
}
/** The routing worker, where this environment has one. */
function startRoutingWorker(): Worker | undefined {
return typeof Worker === 'undefined' ? undefined : new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
}
/**
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
* promises. Where there is no `Worker` — the unit tests' DOM has none — the same answers are
* worked out in place, from the index the scene already holds.
* promises.
*
* The worker answers one request at a time and cannot drop one it has started: a jump-link graph
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out,
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of
* graphs, being quick and asked for by a click. The same question asked again while it is still
* outstanding shares the answer rather than being worked out twice.
*
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
* the same answers are worked out in place, from the index the scene already holds.
*/
export class RoutingClient {
private readonly worker?: Worker;
private readonly pending = new Map<number, Pending>();
private worker?: Worker;
private inFlight?: Outstanding;
private readonly waiting: Partial<Record<RoutingRequest['kind'], Outstanding>> = {};
private nextRequestId = 0;
constructor(stars: readonly StarRecord[], positions: Float32Array, private readonly localIndex: StarNeighbourhood) {
if (typeof Worker === 'undefined') {
constructor(
stars: readonly StarRecord[],
positions: Float32Array,
private readonly localIndex: StarNeighbourhood,
startWorker: () => Worker | undefined = startRoutingWorker
) {
this.worker = startWorker();
if (!this.worker) {
return;
}
this.worker = new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => {
this.pending.get(data.requestId)?.(data);
this.pending.delete(data.requestId);
});
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => this.settle(data));
// A worker that fails to load, or dies, answers nothing further: everything outstanding, and
// everything asked from here on, is worked out in place instead of waiting for good.
this.worker.addEventListener('error', () => this.abandonWorker());
this.worker.addEventListener('messageerror', () => this.abandonWorker());
// Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again.
const ids = Int32Array.from(stars, (star) => star.id);
const copy = positions.slice();
@@ -50,16 +98,70 @@ export class RoutingClient {
dispose(): void {
this.worker?.terminate();
this.pending.clear();
this.worker = undefined;
this.inFlight = undefined;
delete this.waiting.route;
delete this.waiting.links;
}
private ask(request: RoutingRequest): Promise<RoutingResponse> {
if (!this.worker) {
return Promise.resolve(answerRouting(this.localIndex, request));
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
}
const asked = outstanding(request);
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question);
if (same) {
return same.promise;
}
this.waiting[request.kind]?.reject(new SupersededRequest());
this.waiting[request.kind] = asked;
this.sendNext();
return asked.promise;
}
private sendNext(): void {
if (this.inFlight || !this.worker) {
return;
}
const next = this.waiting.route ?? this.waiting.links;
if (!next) {
return;
}
delete this.waiting[next.request.kind];
this.inFlight = next;
this.worker.postMessage(next.request);
}
private settle(response: RoutingResponse): void {
const answered = this.inFlight;
if (!answered || answered.request.requestId !== response.requestId) {
return;
}
this.inFlight = undefined;
if (response.kind === 'failed') {
answered.reject(new Error(response.message));
} else {
answered.resolve(response);
}
this.sendNext();
}
private abandonWorker(): void {
this.worker?.terminate();
this.worker = undefined;
const stranded = [this.inFlight, this.waiting.route, this.waiting.links];
this.inFlight = undefined;
delete this.waiting.route;
delete this.waiting.links;
for (const request of stranded) {
if (!request) {
continue;
}
try {
request.resolve(answerRouting(this.localIndex, request.request));
} catch (error) {
request.reject(error instanceof Error ? error : new Error(String(error)));
}
}
return new Promise((resolve) => {
this.pending.set(request.requestId, resolve);
this.worker!.postMessage(request);
});
}
}