Skip to content

Commit f66524f

Browse files
kumilingusclaudeGeliogabalus
authored
fix(joint-router-avoid): cancel a queued one-shot pass superseded by start() (#3490)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Arthur Khakhlou <arthur.khokhlov@gmail.com>
1 parent 9651d0d commit f66524f

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

‎.changeset/calm-passes-yield.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@joint/router-avoid": patch
3+
---
4+
5+
RouterService - a `routeAll()`/`routeSubgraph()` pass still queued when `start()` is called now resolves as `cancelled` instead of running after `start()`'s full sync and resetting the engine to the pass's subset under the live graph listener - a crash path, since the listener referencing an element the engine no longer held aborted the WASM module irrecoverably

‎packages/joint-router-avoid/src/RouterService.mts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export class RouterService {
335335
* normal teardown does not surface as an unhandled promise rejection.
336336
* Unexpected errors are re-thrown and stay unhandled - there is no
337337
* caller to propagate them to.
338+
*
338339
*/
339340
private backgroundSync(): void {
340341
this.sync(this.graph.getCells()).catch((error) => {
@@ -373,7 +374,7 @@ export class RouterService {
373374
* graph's current cells. Unlike {@link start}, this attaches no graph
374375
* listener - nothing keeps the graph routed as it changes afterwards.
375376
*
376-
* @returns The {@link RoutingResult} of the pass.
377+
* @returns The {@link RoutingResult} of the pass - `{ status: 'cancelled' }` if {@link start} or {@link destroy} is called before the queued pass runs.
377378
* @throws If the router is currently started (see {@link isStarted}) - call {@link stop} first.
378379
*/
379380
routeAll(): Promise<RoutingResult> {
@@ -395,7 +396,7 @@ export class RouterService {
395396
* graph listener.
396397
*
397398
* @param cells - The cells to route; only the elements and links in this array are considered.
398-
* @returns The {@link RoutingResult} of the pass.
399+
* @returns The {@link RoutingResult} of the pass - `{ status: 'cancelled' }` if {@link start} or {@link destroy} is called before the queued pass runs.
399400
* @throws If the router is currently started (see {@link isStarted}) - call {@link stop} first.
400401
*/
401402
routeSubgraph(cells: dia.Cell[]): Promise<RoutingResult> {
@@ -412,6 +413,9 @@ export class RouterService {
412413
* provider - resolves with `{ status: 'cancelled' }` instead of
413414
* rejecting, so fire-and-forget callers are not left with unhandled
414415
* rejections; provider errors unrelated to destruction still reject.
416+
* A pass superseded by {@link start} while still queued resolves with
417+
* `{ status: 'cancelled' }` too - `start()`'s own full-graph sync
418+
* replaces whatever the pass would have produced.
415419
*
416420
* Passes run strictly one after another: a pass invoked while another
417421
* is still in flight waits for it, since each pass replaces the
@@ -428,6 +432,13 @@ export class RouterService {
428432

429433
private async performRoute(cells: dia.Cell[]): Promise<RoutingResult> {
430434
if (this.destroyed) return { status: 'cancelled' };
435+
// Superseded by `start()`: the pass was queued while stopped, but by
436+
// the time it runs a graph listener is live and `start()`'s own full
437+
// sync has already replaced the engine's content. Running this pass
438+
// now would reset the engine to just `cells` under the live
439+
// listener - and the listener then referencing an element the
440+
// engine no longer holds aborts the WASM module irrecoverably.
441+
if (this.isStarted) return { status: 'cancelled' };
431442

432443
try {
433444
await this.sync(cells);

‎packages/joint-router-avoid/test/index.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,64 @@ QUnit.module('destroy()', () => {
655655
});
656656
});
657657

658+
QUnit.module('start() during an in-flight one-shot pass', () => {
659+
// Regression: a queued `routeSubgraph()` pass used to run AFTER
660+
// `start()`'s full-graph sync and replaced the engine's content with
661+
// only the subgraph - leaving the live graph listener working against
662+
// an engine that no longer held the rest of the graph (a listener
663+
// referencing an element the engine does not hold aborts the WASM
664+
// module irrecoverably). The queued pass is now superseded: it resolves
665+
// as cancelled and the engine keeps holding the full graph.
666+
QUnit.test('a queued routeSubgraph() pass is superseded by start(), leaving the engine holding the full graph', async assert => {
667+
const graph = new joint.dia.Graph();
668+
const size = { width: 100, height: 100 };
669+
const a = new joint.shapes.standard.Rectangle({ position: { x: 0, y: 0 }, size });
670+
const b = new joint.shapes.standard.Rectangle({ position: { x: 300, y: 0 }, size });
671+
const linkAB = new joint.shapes.standard.Link({ source: { id: a.id }, target: { id: b.id }});
672+
const c = new joint.shapes.standard.Rectangle({ position: { x: 0, y: 400 }, size });
673+
const d = new joint.shapes.standard.Rectangle({ position: { x: 300, y: 400 }, size });
674+
const linkCD = new joint.shapes.standard.Link({ source: { id: c.id }, target: { id: d.id }});
675+
graph.resetCells([a, b, linkAB, c, d, linkCD]);
676+
677+
const routerService = await joint.routers.avoid.initAvoidRouter(graph, {});
678+
679+
const subgraphPass = routerService.routeSubgraph([a, b, linkAB]);
680+
routerService.start();
681+
const result = await subgraphPass;
682+
assert.equal(result.status, 'cancelled', 'the queued one-shot pass reports it was superseded');
683+
684+
const routedLinks = [];
685+
routerService.on('link:routed', (l) => routedLinks.push(l));
686+
687+
c.position(0, 800);
688+
689+
assert.ok(routedLinks.includes(linkCD), 'a link outside the one-shot subset is still routed while started');
690+
assert.ok(isOrthogonalPath(linkCD), 'and its route is a real avoid route');
691+
692+
routerService.destroy();
693+
});
694+
695+
QUnit.test('start() with no pass in flight still applies routes synchronously (main thread)', async assert => {
696+
const graph = new joint.dia.Graph();
697+
const size = { width: 100, height: 100 };
698+
const source = new joint.shapes.standard.Rectangle({ position: { x: 0, y: 0 }, size });
699+
const target = new joint.shapes.standard.Rectangle({ position: { x: 300, y: 0 }, size });
700+
const link = new joint.shapes.standard.Link({ source: { id: source.id }, target: { id: target.id }});
701+
graph.resetCells([source, target, link]);
702+
703+
const routerService = await joint.routers.avoid.initAvoidRouter(graph, {});
704+
705+
const routedLinks = [];
706+
routerService.on('link:routed', (l) => routedLinks.push(l));
707+
708+
routerService.start();
709+
710+
assert.equal(routedLinks.length, 1, 'the initial sync ran during start(), not a microtask later');
711+
712+
routerService.destroy();
713+
});
714+
});
715+
658716
QUnit.module('idle after incremental changes (main thread)', () => {
659717
// `MainThreadProvider` routes synchronously, so `idle` (driven by the
660718
// provider's `processed`) must fire during the originating graph change,

0 commit comments

Comments
 (0)