v1.4.3 — faster progressive reveal + fix links vanishing after drag
Worker prewarm, viewport-first stable batches, and adaptive reveal pacing (default on, clamp 6–14ms). On drag settle force OVG rebuild and never drop the last legal path when route/stretch fail.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-linkrouter"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
description = "Object-avoiding orthogonal link routing for ComfyUI — links automatically detour around nodes instead of passing through them."
|
||||
license = "Apache-2.0"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -3,8 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
orderHeldRouteCandidates,
|
||||
orderRouteCandidates,
|
||||
pathBounds,
|
||||
pauseRevealCount,
|
||||
shouldForceGraphRebuildAfterSettle,
|
||||
shouldFreezeHiddenModeLink,
|
||||
shouldQueueIdleCleanup,
|
||||
shouldRacePauseLink,
|
||||
@@ -34,6 +36,24 @@ test("held reroutes prioritize self, viewport, then expand outward", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("stable route candidates prioritize viewport then distance", () => {
|
||||
const viewport = { x: 0, y: 0, x2: 100, y2: 100 };
|
||||
const ordered = orderRouteCandidates(
|
||||
[
|
||||
{ id: "far", bounds: { x: 300, y: 0, x2: 320, y2: 20 } },
|
||||
{ id: "visible-b", bounds: { x: 50, y: 50, x2: 70, y2: 70 } },
|
||||
{ id: "visible-a", bounds: { x: 10, y: 10, x2: 30, y2: 30 } },
|
||||
{ id: "near", bounds: { x: 120, y: 0, x2: 140, y2: 20 } },
|
||||
],
|
||||
viewport,
|
||||
);
|
||||
// visible first (stable original index among visibles), then near, then far
|
||||
assert.deepEqual(
|
||||
ordered.map((item) => item.id),
|
||||
["visible-b", "visible-a", "near", "far"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a held direct link bypasses the unrelated-link freeze branch", () => {
|
||||
assert.equal(shouldFreezeHiddenModeLink(true, true, false), false);
|
||||
assert.equal(shouldFreezeHiddenModeLink(false, true, false), true);
|
||||
@@ -151,3 +171,20 @@ test("held-pause race only takes predicted-cheap links", () => {
|
||||
assert.equal(shouldRacePauseLink(undefined, Infinity, 10), false);
|
||||
assert.equal(shouldRacePauseLink(3, Infinity, 10), true);
|
||||
});
|
||||
|
||||
// Regression guard for "line vanishes after drag release" (profiler capture
|
||||
// LinkRouter_20260730-094654.json: settle frame had routeFailures=7 with
|
||||
// aStarPops=0 — layoutSignature truncates node coords with x|0, so a drag
|
||||
// ending at a sub-pixel offset kept the stale OVG and every endpoint lookup
|
||||
// missed). After a settle that invalidated any path, the next refreshGraph
|
||||
// must rebuild the graph even when the signature is unchanged. Profiler
|
||||
// regression condition: the first post-settle route frame must show
|
||||
// graphRebuilt=true and must NOT show routeFailures>0 together with
|
||||
// aStarPops=0; failed links must keep their last legal pts on screen
|
||||
// (prefer a wrong-looking path over a vanishing link).
|
||||
test("settle with stale paths forces a graph rebuild", () => {
|
||||
assert.equal(shouldForceGraphRebuildAfterSettle(true), true);
|
||||
// Nothing invalidated → keep the cheap signature comparison.
|
||||
assert.equal(shouldForceGraphRebuildAfterSettle(false), false);
|
||||
assert.equal(shouldForceGraphRebuildAfterSettle(undefined), false);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
adaptiveBudgetMs,
|
||||
orderedRouteResults,
|
||||
processRouteSlice,
|
||||
progressiveItemLimit,
|
||||
shouldProgressivelyRoute,
|
||||
} from "../web/progressive.js";
|
||||
import { orderRouteCandidates } from "../web/drag-policy.js";
|
||||
|
||||
const entry = (id) => ({ link: { id } });
|
||||
|
||||
@@ -55,3 +57,67 @@ test("a route slice yields after its time budget", () => {
|
||||
);
|
||||
assert.deepEqual(slice, { processed: 2, done: false, remaining: 2 });
|
||||
});
|
||||
|
||||
test("adaptiveBudgetMs clamps to [6, 14] and falls back without measures", () => {
|
||||
// light draw → headroom hits the 14ms cap
|
||||
assert.equal(
|
||||
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 0.5 }),
|
||||
14,
|
||||
);
|
||||
// heavy draw → floor at 6ms (never starves to 0)
|
||||
assert.equal(
|
||||
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 20 }),
|
||||
6,
|
||||
);
|
||||
// mid value passes through
|
||||
assert.ok(
|
||||
Math.abs(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 4 }) - 10.6) < 1e-9,
|
||||
);
|
||||
// invalid / missing measures → fixed fallback 12
|
||||
assert.equal(adaptiveBudgetMs({ frameIntervalMs: NaN, drawMsP95: 2 }), 12);
|
||||
assert.equal(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: NaN }), 12);
|
||||
assert.equal(
|
||||
adaptiveBudgetMs({ frameIntervalMs: 0, drawMsP95: 1, fallbackMs: 12 }),
|
||||
12,
|
||||
);
|
||||
// never exceeds maxMs even on 30fps headroom
|
||||
assert.equal(
|
||||
adaptiveBudgetMs({ frameIntervalMs: 33, drawMsP95: 2, maxMs: 14 }),
|
||||
14,
|
||||
);
|
||||
});
|
||||
|
||||
test("viewport job reorder does not change orderedRouteResults id sequence", () => {
|
||||
const entries = [entry(1), entry(2), entry(3), entry(4)];
|
||||
const viewport = { x: 0, y: 0, x2: 100, y2: 100 };
|
||||
// Jobs intentionally listed off-screen first; after viewport order, on-screen
|
||||
// jobs are processed first, but results still emit in entry order.
|
||||
const jobsRaw = [
|
||||
{ entry: entries[2], bounds: { x: 500, y: 0, x2: 520, y2: 20 } },
|
||||
{ entry: entries[0], bounds: { x: 10, y: 10, x2: 30, y2: 30 } },
|
||||
{ entry: entries[3], bounds: { x: 200, y: 0, x2: 220, y2: 20 } },
|
||||
{ entry: entries[1], bounds: { x: 40, y: 40, x2: 60, y2: 60 } },
|
||||
];
|
||||
const orderedJobs = orderRouteCandidates(jobsRaw, viewport);
|
||||
// Visible first (ids 1,2), then near off-screen (4), then far (3).
|
||||
assert.deepEqual(
|
||||
orderedJobs.map((j) => j.entry.link.id),
|
||||
[1, 2, 4, 3],
|
||||
);
|
||||
const batch = {
|
||||
entries,
|
||||
jobs: orderedJobs,
|
||||
index: 0,
|
||||
resultsById: new Map(),
|
||||
};
|
||||
processRouteSlice(batch, (job) => ({ entry: job.entry, cached: { pts: [] } }), {
|
||||
maxItems: 99,
|
||||
budgetMs: Infinity,
|
||||
now: () => 0,
|
||||
});
|
||||
// Final emit order follows entries, not job processing order.
|
||||
assert.deepEqual(
|
||||
orderedRouteResults(entries, batch.resultsById).map((r) => r.entry.link.id),
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
WARMUP_JOB_REV,
|
||||
buildWorkerWarmupPayload,
|
||||
watchdogTimeoutAction,
|
||||
workerErrorAction,
|
||||
} from "../web/worker-policy.js";
|
||||
@@ -79,3 +81,17 @@ test("no live batch at timeout: nothing to do", () => {
|
||||
"ignore",
|
||||
);
|
||||
});
|
||||
|
||||
test("worker warmup payload uses reserved negative jobRev and tiny jobs", () => {
|
||||
assert.equal(WARMUP_JOB_REV, -1);
|
||||
assert.ok(WARMUP_JOB_REV < 0);
|
||||
const payload = buildWorkerWarmupPayload();
|
||||
assert.equal(payload.type, "route");
|
||||
assert.equal(payload.jobRev, WARMUP_JOB_REV);
|
||||
assert.ok(payload.jobs.length >= 1 && payload.jobs.length <= 2);
|
||||
assert.equal(payload.rects.length % 4, 0);
|
||||
assert.ok(payload.rects.length / 4 >= 2);
|
||||
// Warmup errors must not kill the worker: rev never matches live (≥0).
|
||||
assert.equal(workerErrorAction(WARMUP_JOB_REV, 0, 0), "ignore");
|
||||
assert.equal(workerErrorAction(WARMUP_JOB_REV, 5, 2), "ignore");
|
||||
});
|
||||
|
||||
+34
-7
@@ -44,6 +44,15 @@ export function shouldQueueIdleCleanup(effectiveMode, affected, primary, hasCach
|
||||
);
|
||||
}
|
||||
|
||||
// After a drag-settle that invalidated paths, the next frame must rebuild
|
||||
// the OVG even when layoutSignature is unchanged: the signature truncates
|
||||
// node coords (x|0), so a drag ending at a sub-pixel offset keeps the old
|
||||
// graph while the fractional endpoints no longer match any terminal (idxOf
|
||||
// misses, A* pops 0, every route fails and the link vanishes).
|
||||
export function shouldForceGraphRebuildAfterSettle(hadStale) {
|
||||
return !!hadStale;
|
||||
}
|
||||
|
||||
export function pathBounds(points) {
|
||||
if (!points?.length) return null;
|
||||
let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||||
@@ -97,16 +106,19 @@ export function shouldRacePauseLink(costMs, fallbackMs, maxMs) {
|
||||
return (costMs === undefined ? fallbackMs : costMs) < maxMs;
|
||||
}
|
||||
|
||||
// Freeze this order when a held pause begins: direct links first, then links
|
||||
// visible in the current viewport, then off-screen links expanding outward.
|
||||
export function orderHeldRouteCandidates(candidates, viewport) {
|
||||
// Shared viewport-first ordering: candidates are bucketed by priority tier,
|
||||
// then by distance from the viewport, with the original index as the final
|
||||
// tiebreak (stable within a tier). directOf marks candidates that always
|
||||
// sort first (held-pause direct links); stable batches pass none.
|
||||
function orderByViewportPriority(candidates, viewport, directOf) {
|
||||
return candidates
|
||||
.map((candidate, index) => {
|
||||
const direct = directOf(candidate);
|
||||
const visible = intersects(candidate.bounds, viewport);
|
||||
return {
|
||||
...candidate,
|
||||
_priority: candidate.direct ? 0 : visible ? 1 : 2,
|
||||
_distance: candidate.direct ? 0 : distanceFromViewport(candidate.bounds, viewport),
|
||||
candidate,
|
||||
_priority: direct ? 0 : visible ? 1 : 2,
|
||||
_distance: direct ? 0 : distanceFromViewport(candidate.bounds, viewport),
|
||||
_index: index,
|
||||
};
|
||||
})
|
||||
@@ -115,5 +127,20 @@ export function orderHeldRouteCandidates(candidates, viewport) {
|
||||
a._distance - b._distance ||
|
||||
a._index - b._index
|
||||
)
|
||||
.map(({ _priority, _distance, _index, ...candidate }) => candidate);
|
||||
.map((item) => item.candidate);
|
||||
}
|
||||
|
||||
// Freeze this order when a held pause begins: direct links first, then links
|
||||
// visible in the current viewport, then off-screen links expanding outward.
|
||||
export function orderHeldRouteCandidates(candidates, viewport) {
|
||||
return orderByViewportPriority(candidates, viewport, (c) => c.direct);
|
||||
}
|
||||
|
||||
// Stable (non-drag) route jobs: links visible in the current viewport first,
|
||||
// then off-screen links expanding outward, so progressive slices and worker
|
||||
// results appear where the user is looking. Final results are rebuilt from
|
||||
// the entries order (orderedRouteResults), so reordering jobs never changes
|
||||
// the emitted id sequence.
|
||||
export function orderRouteCandidates(candidates, viewport) {
|
||||
return orderByViewportPriority(candidates, viewport, () => false);
|
||||
}
|
||||
|
||||
+21
@@ -467,6 +467,13 @@ function hoverNodeId(canvas) {
|
||||
|
||||
// --- main draw hook ---
|
||||
|
||||
// Exponential moving average that ignores invalid samples and seeds itself
|
||||
// from the first valid one (NaN until then).
|
||||
function emaSample(prev, sample, alpha = 0.2) {
|
||||
if (!Number.isFinite(sample) || sample < 0) return prev;
|
||||
return Number.isFinite(prev) ? prev + alpha * (sample - prev) : sample;
|
||||
}
|
||||
|
||||
// The routing failsafe disables the plugin for the rest of the session.
|
||||
// Tell the user visibly (frontend toast when available) and flag the
|
||||
// floating-bar toggle until the plugin is re-enabled — a silent console.warn
|
||||
@@ -525,6 +532,9 @@ export function drawAll(canvas, ctx) {
|
||||
profiler.endFrame(profileFrame, { fallback: true });
|
||||
return false;
|
||||
}
|
||||
// Routing is done for this frame; the remaining drawAll work is the pure
|
||||
// draw cost that the adaptive progressive budget subtracts from the frame.
|
||||
const routeEndedAt = performance.now();
|
||||
|
||||
const selRaw = canvas.selected_nodes;
|
||||
// Frontend 1.47 made NodeId a runtime string ("12"); older builds use
|
||||
@@ -728,5 +738,16 @@ export function drawAll(canvas, ctx) {
|
||||
else clearOverlay();
|
||||
}
|
||||
profiler.endFrame(profileFrame, { links: routed.length, strokeCalls, batched: !!batches });
|
||||
// Frame pacing EMAs for the adaptive progressive budget (routing.js):
|
||||
// updated every frame, independent of the opt-in profiler. Draw time is
|
||||
// measured after routeAll so the routing slice itself is not double
|
||||
// counted as draw cost.
|
||||
const frameEndedAt = performance.now();
|
||||
M._drawMsEma = emaSample(M._drawMsEma, frameEndedAt - routeEndedAt);
|
||||
M._frameIntervalMsEma = emaSample(
|
||||
M._frameIntervalMsEma,
|
||||
M._lastFrameEndedAt ? frameEndedAt - M._lastFrameEndedAt : NaN,
|
||||
);
|
||||
M._lastFrameEndedAt = frameEndedAt;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,33 @@ export function progressiveItemLimit(total, percent) {
|
||||
return Math.max(1, Math.ceil(total * pct / 100));
|
||||
}
|
||||
|
||||
// Spare-time budget for one progressive reveal slice: the frame interval
|
||||
// minus the measured draw cost and a safety margin, clamped to [minMs, maxMs]
|
||||
// so a slice never starves the frame (min) nor blows past it (max) — the
|
||||
// no-dropped-frames constraint is structural, not tuned. Without a valid
|
||||
// draw/frame measurement the fixed fallback applies (first frames behave
|
||||
// exactly like the old fixed budget).
|
||||
export function adaptiveBudgetMs({
|
||||
frameIntervalMs,
|
||||
drawMsP95,
|
||||
minMs = 6,
|
||||
maxMs = 14,
|
||||
safetyMs = 2,
|
||||
fallbackMs = 12,
|
||||
}) {
|
||||
if (
|
||||
!Number.isFinite(frameIntervalMs) ||
|
||||
frameIntervalMs <= 0 ||
|
||||
!Number.isFinite(drawMsP95) ||
|
||||
drawMsP95 < 0
|
||||
)
|
||||
return fallbackMs;
|
||||
return Math.min(
|
||||
maxMs,
|
||||
Math.max(minMs, frameIntervalMs - drawMsP95 - safetyMs),
|
||||
);
|
||||
}
|
||||
|
||||
export function processRouteSlice(batch, routeOne, options = {}) {
|
||||
const now = options.now || (() => performance.now());
|
||||
const maxItems = options.maxItems ?? Infinity;
|
||||
|
||||
+76
-7
@@ -11,8 +11,10 @@ import { stretchPathPure } from "./stretch.js";
|
||||
import { profiler } from "./profiler.js";
|
||||
import {
|
||||
orderHeldRouteCandidates,
|
||||
orderRouteCandidates,
|
||||
pathBounds,
|
||||
pauseRevealCount,
|
||||
shouldForceGraphRebuildAfterSettle,
|
||||
shouldFreezeHiddenModeLink,
|
||||
shouldQueueIdleCleanup,
|
||||
shouldRacePauseLink,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
updateRouteCost,
|
||||
} from "./adaptive-policy.js";
|
||||
import {
|
||||
adaptiveBudgetMs,
|
||||
orderedRouteResults,
|
||||
processRouteSlice,
|
||||
progressiveItemLimit,
|
||||
@@ -469,6 +472,15 @@ function refreshGraph(graph, linkEntries, rectCache, deferBuild = false) {
|
||||
if (hadStale) {
|
||||
M.routeFastSig = "";
|
||||
}
|
||||
if (shouldForceGraphRebuildAfterSettle(hadStale)) {
|
||||
// layoutSignature truncates coords (x|0), so the final sub-pixel
|
||||
// position may leave graphSig unchanged while the OVG terminals no
|
||||
// longer match the fractional endpoints (idxOf misses → A* pops 0 →
|
||||
// every route fails). Force the next refreshGraph to rebuild with
|
||||
// the current endpoints; deferred worker builds stay deferred and
|
||||
// are forced fresh by ensureMainRouterFresh when routing needs it.
|
||||
M.graphSig = "";
|
||||
}
|
||||
// Redraw even when all routes remain valid: unrelated selected links
|
||||
// switch from drag opacity back to the normal selected opacity.
|
||||
app.canvas?.setDirty(false, true);
|
||||
@@ -518,6 +530,20 @@ function predictedRouteCost(linkIds) {
|
||||
|
||||
const PROGRESSIVE_BUDGET_MS = 12;
|
||||
|
||||
// Per-slice budget for main-thread progressive reveal. Adaptive (default):
|
||||
// spare frame time after drawing, clamped to [6, 14]ms by adaptiveBudgetMs;
|
||||
// the draw/frame EMAs come from draw.js every frame, so this works without
|
||||
// the opt-in profiler. Setting off: the fixed 12ms budget. Worker batches
|
||||
// are message-driven and never read this.
|
||||
function progressiveBudgetMs() {
|
||||
if (M.S.adaptiveRevealPacing === false) return PROGRESSIVE_BUDGET_MS;
|
||||
return adaptiveBudgetMs({
|
||||
frameIntervalMs: M._frameIntervalMsEma,
|
||||
drawMsP95: M._drawMsEma,
|
||||
fallbackMs: PROGRESSIVE_BUDGET_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function cancelProgressiveBatch() {
|
||||
clearWorkerRescue();
|
||||
if (M.routeBatch?.worker) cancelWorkerBatch();
|
||||
@@ -863,6 +889,13 @@ initWorkerClient({
|
||||
// The worker already attempted its own sticky stretch before failing.
|
||||
batch.routeFailures++;
|
||||
rememberRouteFailure(msg.id, job.endsKey, job.ep);
|
||||
// Prefer a wrong-looking path over a vanishing link: keep the last
|
||||
// legal pts on screen (old ends key retained so a later frame still
|
||||
// re-routes after the failure backoff).
|
||||
const prev = M.pathCache.get(msg.id);
|
||||
if (prev?.pts) {
|
||||
batch.resultsById.set(msg.id, { entry: job.entry, cached: prev });
|
||||
}
|
||||
}
|
||||
scheduleProgressiveBatch(); // throttled repaint with the new partial set
|
||||
armWorkerRescue(batch); // progress: push the rescue deadline out
|
||||
@@ -1034,16 +1067,20 @@ function executeRouteJob(batch, job) {
|
||||
// Degrade gracefully: keep the last legal path on screen, stretched
|
||||
// to the current endpoints, instead of making the link vanish.
|
||||
// stretchPath validates that no unexpected node body is crossed.
|
||||
const stickyPts = stretchPath(
|
||||
M.pathCache.get(job.entry.link.id)?.pts,
|
||||
job.ep,
|
||||
);
|
||||
const prev = M.pathCache.get(job.entry.link.id);
|
||||
const stickyPts = stretchPath(prev?.pts, job.ep);
|
||||
if (stickyPts) {
|
||||
const sticky = { ends: job.endsKey, sticky: true };
|
||||
setCachedPath(sticky, stickyPts);
|
||||
M.pathCache.set(job.entry.link.id, sticky);
|
||||
return { result: { entry: job.entry, cached: sticky }, elapsed };
|
||||
}
|
||||
// Last resort: prefer a wrong-looking path over a vanishing link. Emit
|
||||
// the previous legal pts untouched, keeping their old ends key so the
|
||||
// link is still re-routed once the failure backoff elapses.
|
||||
if (prev?.pts) {
|
||||
return { result: { entry: job.entry, cached: prev }, elapsed };
|
||||
}
|
||||
return { result: null, elapsed };
|
||||
}
|
||||
M.failedRoutes.delete(job.entry.link.id);
|
||||
@@ -1070,7 +1107,7 @@ function continueStableRoutes(batch, profileStarted) {
|
||||
: Infinity;
|
||||
const slice = processRouteSlice(batch, routeOne, {
|
||||
maxItems,
|
||||
budgetMs: batch.progressive ? PROGRESSIVE_BUDGET_MS : Infinity,
|
||||
budgetMs: batch.progressive ? progressiveBudgetMs() : Infinity,
|
||||
});
|
||||
const results = orderedRouteResults(batch.entries, batch.resultsById);
|
||||
const profile = batch.firstFrame ? batch.profile : { graphRebuilt: false };
|
||||
@@ -1151,6 +1188,9 @@ function prepareStableRoutes(graph, fastSig, entries, rectCache, dirty, profileS
|
||||
setCachedPath(sticky, stickyPts);
|
||||
M.pathCache.set(entry.link.id, sticky);
|
||||
resultsById.set(entry.link.id, { entry, cached: sticky });
|
||||
} else if (cached?.pts) {
|
||||
// Stretch failed too: prefer the stale path over a vanishing link.
|
||||
resultsById.set(entry.link.id, { entry, cached });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1158,6 +1198,25 @@ function prepareStableRoutes(graph, fastSig, entries, rectCache, dirty, profileS
|
||||
} else if (cached?.pts) resultsById.set(entry.link.id, { entry, cached });
|
||||
}
|
||||
|
||||
// Viewport-first job order: progressive slices and streamed worker results
|
||||
// appear where the user is looking first. Bounds come from the cached path
|
||||
// when present, else the endpoint bbox. orderedRouteResults rebuilds the
|
||||
// output from entries, so reordering jobs never changes the emitted order.
|
||||
if (jobs.length > 1) {
|
||||
const viewport = currentViewportRect();
|
||||
const ordered = orderRouteCandidates(
|
||||
jobs.map((job) => ({
|
||||
job,
|
||||
bounds:
|
||||
pathBounds(M.pathCache.get(job.entry.link.id)?.pts) ||
|
||||
failureBounds(job.ep),
|
||||
})),
|
||||
viewport,
|
||||
);
|
||||
jobs.length = 0;
|
||||
for (const item of ordered) jobs.push(item.job);
|
||||
}
|
||||
|
||||
const progressive = shouldProgressivelyRoute(false, forceSync, jobs.length);
|
||||
const batch = {
|
||||
graph,
|
||||
@@ -1689,7 +1748,10 @@ export function routeAll(graph) {
|
||||
!pauseRoute &&
|
||||
shouldSkipFailedRoute(e.link.id, endsKey, collisionRects || dirty._rects)
|
||||
) {
|
||||
finishPauseRoute(false);
|
||||
// Backoff means "do not re-route yet", not "show nothing": keep the
|
||||
// last legal (stale) path on screen if we have one.
|
||||
finishPauseRoute(!!cached?.pts, cached);
|
||||
if (cached?.pts) results.push({ entry: e, cached });
|
||||
continue;
|
||||
}
|
||||
reroutedLinks++;
|
||||
@@ -1733,7 +1795,14 @@ export function routeAll(graph) {
|
||||
// Never replace that with a fallback which cuts through a node.
|
||||
if (!pts) {
|
||||
rememberRouteFailure(e.link.id, endsKey, ep);
|
||||
finishPauseRoute(false);
|
||||
if (cached?.pts) {
|
||||
// Prefer the last legal (now stale) path over a vanishing link;
|
||||
// endsMoved stays true so routing retries on the next frames.
|
||||
finishPauseRoute(true, cached);
|
||||
results.push({ entry: e, cached });
|
||||
} else {
|
||||
finishPauseRoute(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cached = { ends: endsKey, sticky };
|
||||
|
||||
+7
-1
@@ -200,11 +200,17 @@ const SETTINGS = {
|
||||
],
|
||||
routeBatchPercent: [
|
||||
"LinkRouter.Routing.RouteBatchPercent",
|
||||
"Gradual reveal: % of links per frame (lower = smoother, higher = faster)",
|
||||
"Gradual reveal: % of links per frame (main-thread fallback, when background worker is off)",
|
||||
"slider",
|
||||
10,
|
||||
{ min: 2, max: 100, step: 1 },
|
||||
],
|
||||
adaptiveRevealPacing: [
|
||||
"LinkRouter.Routing.AdaptiveRevealPacing",
|
||||
"Adaptive reveal pacing (fit each reveal batch into spare frame time, no dropped frames)",
|
||||
"boolean",
|
||||
true,
|
||||
],
|
||||
workerRouting: [
|
||||
"LinkRouter.Routing.WorkerRouting",
|
||||
"Background worker routing (large batches off the main thread)",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { registerSettings, setRefreshBar } from "./settings.js";
|
||||
import { drawAll } from "./draw.js";
|
||||
import { buildUI, refreshBar, watchHover, linksHidden } from "./ui.js";
|
||||
import { cancelDragPauseWorker } from "./routing.js";
|
||||
import { prewarmWorker } from "./worker-client.js";
|
||||
|
||||
// Wire the refresh-bar callback (settings.js => ui.js without circular import)
|
||||
setRefreshBar(refreshBar);
|
||||
@@ -44,6 +45,10 @@ app.registerExtension({
|
||||
buildUI();
|
||||
watchHover();
|
||||
watchExecution();
|
||||
// Settings are loaded by now: spin up the background worker and warm its
|
||||
// router JIT with a tiny synthetic batch (no-op when worker routing is
|
||||
// off/unavailable). See prewarmWorker in worker-client.js.
|
||||
prewarmWorker();
|
||||
// Capture-phase tracking still sees gestures captured by Nodes 2.0.
|
||||
const beginPointer = () => {
|
||||
// A new gesture orphans the previous gesture's in-flight pause batch:
|
||||
|
||||
@@ -62,6 +62,11 @@ export const M = {
|
||||
routeCostByLink: new Map(),
|
||||
routeCostByLinkMT: new Map(), // main-thread-measured costs only (pause race gate)
|
||||
routeCostAverage: NaN,
|
||||
// Frame pacing EMAs (draw.js): feed the adaptive progressive slice budget.
|
||||
// NaN until measured — the budget helper falls back to the fixed 12ms then.
|
||||
_drawMsEma: NaN,
|
||||
_frameIntervalMsEma: NaN,
|
||||
_lastFrameEndedAt: 0,
|
||||
prevRects: new Map(),
|
||||
pathCache: new Map(), // linkId -> {ends, pts, sticky, segs, total}
|
||||
failedRoutes: new Map(), // linkId -> {ends, fails, retryAt, bounds}
|
||||
|
||||
+28
-1
@@ -11,7 +11,11 @@
|
||||
// never needs to import routing.js (no import cycle).
|
||||
|
||||
import { M } from "./state.js";
|
||||
import { watchdogTimeoutAction, workerErrorAction } from "./worker-policy.js";
|
||||
import {
|
||||
buildWorkerWarmupPayload,
|
||||
watchdogTimeoutAction,
|
||||
workerErrorAction,
|
||||
} from "./worker-policy.js";
|
||||
|
||||
const WATCHDOG_MS = 3000;
|
||||
|
||||
@@ -128,3 +132,26 @@ export function cancelWorkerBatch() {
|
||||
worker?.postMessage({ type: "cancel" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Prewarm the worker at startup: construct it (async module fetch/compile)
|
||||
// and post a tiny synthetic batch so the OrthoRouter JIT is warm before the
|
||||
// first real batch. Warmup messages carry the reserved negative WARMUP_JOB_REV
|
||||
// (worker-policy.js), so handleMessage drops them via the normal stale-rev
|
||||
// check — the watchdog is never armed and pathCache is never touched.
|
||||
// Skipped when a real batch may already be in flight: the worker's
|
||||
// newest-route-wins pump would otherwise supersede it.
|
||||
export function prewarmWorker() {
|
||||
if (!workerRoutingUsable()) return false;
|
||||
if (M.routeBatch?.worker || M._dragPauseWorker) return false;
|
||||
const w = ensureWorker();
|
||||
if (!w) return false;
|
||||
try {
|
||||
w.postMessage(buildWorkerWarmupPayload());
|
||||
return true;
|
||||
} catch (e) {
|
||||
// A failed prewarm must not failWorker (that would disable worker routing
|
||||
// for the whole session); the first real dispatch retries anyway.
|
||||
console.warn("[LinkRouter] worker prewarm skipped:", e?.message || e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,3 +38,38 @@ export function watchdogTimeoutAction({
|
||||
if (pauseWorkerBatch) return "drop-pause";
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
// Worker prewarm (Phase D1). A tiny synthetic batch is posted right after
|
||||
// worker creation so the module load and the OrthoRouter JIT/build path are
|
||||
// warm before the first real batch. It uses a reserved NEGATIVE jobRev:
|
||||
// real revs start at 0 and only increase (dispatch/cancel/resetRouter), so
|
||||
// the existing stale-rev drop in handleMessage discards every warmup
|
||||
// result/done/error — no watchdog, no failWorker, no pathCache writes, and
|
||||
// no risk of staling a user batch.
|
||||
export const WARMUP_JOB_REV = -1;
|
||||
|
||||
// Two nodes with a clear straight corridor, one exact (weight 1) job pair —
|
||||
// the same code path a real stable batch exercises. Kept dependency-free so
|
||||
// node tests can feed it straight into router-worker-core's engine.
|
||||
export function buildWorkerWarmupPayload(margin = 16, bendPenalty = 40) {
|
||||
const pts = [
|
||||
100, 50, // out
|
||||
100, 50, // bodyOut
|
||||
116, 50, // stubOut
|
||||
384, 50, // stubIn
|
||||
400, 50, // bodyIn
|
||||
400, 50, // inp
|
||||
];
|
||||
const job = (id) => ({ id, endsKey: "warmup", opts: null, oldPts: null, pts });
|
||||
return {
|
||||
type: "route",
|
||||
jobRev: WARMUP_JOB_REV,
|
||||
graphRev: "warmup",
|
||||
configKey: "warmup",
|
||||
margin,
|
||||
bendPenalty,
|
||||
rects: new Float64Array([0, 0, 100, 100, 400, 0, 100, 100]),
|
||||
terminals: new Float64Array([116, 50, 384, 50, 100, 50, 400, 50]),
|
||||
jobs: [job(1), job(2)],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user