Skip to content

fix(joint-router-avoid): cancel a queued one-shot pass superseded by start() - #3490

Merged
Geliogabalus merged 2 commits into
clientIO:masterfrom
kumilingus:fix/router-avoid-serialize-start
Aug 28, 2026
Merged

fix(joint-router-avoid): cancel a queued one-shot pass superseded by start()#3490
Geliogabalus merged 2 commits into
clientIO:masterfrom
kumilingus:fix/router-avoid-serialize-start

Conversation

@kumilingus

Copy link
Copy Markdown
Contributor

Description

Implements the §3.3 fix from the sync-routing analysis: routeAll() / routeSubgraph() defer their pass by a microtask (the pass queue), but start()'s full-graph sync ran outside the queue. A pass still queued when start() was called therefore ran after the full sync and reset the engine to just that pass's cells while the graph listener was live:

const p = avoidRouter.routeSubgraph(cells);  // queued
avoidRouter.start();                          // syncs the whole graph immediately
await p;                                      // subgraph sync runs last — engine now holds only `cells`

A live listener referencing an element the engine does not hold aborts the WASM module irrecoverably (per start()'s own doc), so this is a crash path, not just wrong routes.

Fix

The queued pass re-checks isStarted when it actually runs and resolves with { status: 'cancelled' } — the same supersession contract destroy() already has. start()'s own sync has already produced the correct engine state; the final routes are identical to running the pass first and full-syncing after it, so cancelling is both safer and honest to the caller.

Considered and rejected: serializing backgroundSync() through the pass queue (the analysis's literal option A). Chaining always defers by a microtask, which breaks start()'s synchronous main-thread behaviour (existing tests assert routes are applied when start() returns); an eager-when-idle counter cannot be made race-free because promise settlement is never observable synchronously.

Tests

TDD — regression written first and watched fail (the moved element's link outside the subgraph never routed):

  • a queued routeSubgraph() pass is superseded by start(), leaving the engine holding the full graph — asserts the cancelled status and that a link outside the one-shot subset still routes while started
  • start() with no pass in flight still applies routes synchronously (main thread) — pins the timing the rejected approach would have broken

30/30 passing; lint clean. Changeset: @joint/router-avoid patch.

Out of scope, tracked separately: the explicit synchronous API (routeAllSync(), option C of the analysis) and the worker-mode stale-route generation race.

🤖 Generated with Claude Code

…start()

routeAll()/routeSubgraph() defer their pass by a microtask (the pass
queue), but start()'s full-graph sync ran outside the queue - so a pass
still queued when start() was called ran after the full sync and reset
the engine to just that pass's cells while the graph listener was live.
A live listener referencing an element the engine no longer holds
aborts the WASM module irrecoverably.

The queued pass now checks isStarted when it runs and resolves with
{ status: 'cancelled' } - start()'s own sync has already produced the
correct engine state, so there is nothing left for the pass to add.
start()'s synchronous main-thread timing is unchanged and now pinned
by a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Geliogabalus
Geliogabalus merged commit f66524f into clientIO:master Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants