From 734ce4173f553636208f4f2cd14adee604d9b6f1 Mon Sep 17 00:00:00 2001 From: 90-RED <90-RED@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:44:53 +0800 Subject: [PATCH] =?UTF-8?q?v1.4.3=20=E2=80=94=20faster=20progressive=20rev?= =?UTF-8?q?eal=20+=20fix=20links=20vanishing=20after=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pyproject.toml | 2 +- tests/drag-policy.test.mjs | 37 ++++++++++++++++ tests/progressive.test.mjs | 66 ++++++++++++++++++++++++++++ tests/worker-policy.test.mjs | 16 +++++++ web/drag-policy.js | 41 +++++++++++++++--- web/draw.js | 21 +++++++++ web/progressive.js | 27 ++++++++++++ web/routing.js | 83 +++++++++++++++++++++++++++++++++--- web/settings.js | 8 +++- web/smart-edge.js | 5 +++ web/state.js | 5 +++ web/worker-client.js | 29 ++++++++++++- web/worker-policy.js | 35 +++++++++++++++ 13 files changed, 358 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4107080..37220e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/drag-policy.test.mjs b/tests/drag-policy.test.mjs index 663ab4f..463e90c 100644 --- a/tests/drag-policy.test.mjs +++ b/tests/drag-policy.test.mjs @@ -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); +}); diff --git a/tests/progressive.test.mjs b/tests/progressive.test.mjs index 8bf25e6..d259b0b 100644 --- a/tests/progressive.test.mjs +++ b/tests/progressive.test.mjs @@ -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], + ); +}); diff --git a/tests/worker-policy.test.mjs b/tests/worker-policy.test.mjs index 57d6bf7..d85f02b 100644 --- a/tests/worker-policy.test.mjs +++ b/tests/worker-policy.test.mjs @@ -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"); +}); diff --git a/web/drag-policy.js b/web/drag-policy.js index 7fc3980..7907074 100644 --- a/web/drag-policy.js +++ b/web/drag-policy.js @@ -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); } diff --git a/web/draw.js b/web/draw.js index 7062d3b..889783d 100644 --- a/web/draw.js +++ b/web/draw.js @@ -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; } diff --git a/web/progressive.js b/web/progressive.js index ddcbd9f..c4ed72c 100644 --- a/web/progressive.js +++ b/web/progressive.js @@ -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; diff --git a/web/routing.js b/web/routing.js index 00dc804..c6c4995 100644 --- a/web/routing.js +++ b/web/routing.js @@ -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 }; diff --git a/web/settings.js b/web/settings.js index a2cedc3..817cf4d 100644 --- a/web/settings.js +++ b/web/settings.js @@ -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)", diff --git a/web/smart-edge.js b/web/smart-edge.js index a359974..f0e72b6 100644 --- a/web/smart-edge.js +++ b/web/smart-edge.js @@ -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: diff --git a/web/state.js b/web/state.js index e49bd64..6026c4f 100644 --- a/web/state.js +++ b/web/state.js @@ -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} diff --git a/web/worker-client.js b/web/worker-client.js index 0dea8f6..c16006d 100644 --- a/web/worker-client.js +++ b/web/worker-client.js @@ -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; + } +} diff --git a/web/worker-policy.js b/web/worker-policy.js index 2b6b8bf..7e2edc6 100644 --- a/web/worker-policy.js +++ b/web/worker-policy.js @@ -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)], + }; +}