[
https://issues.apache.org/jira/browse/NIFI-16025?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Rob Fellows updated NIFI-16025:
-------------------------------
Status: Patch Available (was: In Progress)
> UI - Canvas SVG transform produces scale(Infinity) and components overlap at
> origin when component positions or persisted viewport exceed safe rendering
> bounds
> ---------------------------------------------------------------------------------------------------------------------------------------------------------------
>
> Key: NIFI-16025
> URL: https://issues.apache.org/jira/browse/NIFI-16025
> Project: Apache NiFi
> Issue Type: Task
> Components: Core UI
> Reporter: Rob Fellows
> Assignee: Rob Fellows
> Priority: Major
> Time Spent: 10m
> Remaining Estimate: 0h
>
> h2. Symptom
> Under certain conditions, the NiFi flow canvas becomes visually unusable in
> the
> browser: every component is rendered stacked at the canvas origin, the
> birdseye
> (minimap) is blank or shows {{scale(Infinity)}} errors in the console, drag /
> zoom / pan stops working, and dragging a component to a new location persists
> it at {{(0, 0)}}. The flow itself continues to run correctly -- this is a
> UI-only failure mode -- but the canvas is unrecoverable for the rest of the
> session, and refreshing the page does not fix it (the bad state is replayed
> from {{localStorage}}).
> In the browser console, the failure surfaces as either:
> * {{Error: <g> attribute transform: Expected number,
> "translate(-8.98...e+307, ...)"}}
> * {{Error: <g> attribute transform: Expected number, "scale(Infinity)"}}
> h2. Root cause
> {{Number.isFinite(value)}} (and the weaker {{!isNaN(value)}}) is a *necessary
> but not sufficient* check for any value used in an SVG attribute or in
> multiplication-heavy canvas math. A double like {{Number.MAX_VALUE / 2}}
> (~{{8.99e307}}) is finite in JavaScript but:
> * The browser's SVG parser stores transform coordinates as {{float32}}
> internally (max {{~3.4e38}}). Anything beyond that is silently rejected --
> the attribute write fails and the {{<g>}} sticks at the previous transform
> (typically the identity), so every component visually collapses to the
> origin.
> * Downstream arithmetic in the canvas math
> ({{(rect.x - this.x) / this.k}}, bbox normalization, birdseye brush math)
> overflows to {{Infinity}} on the first multiplication, which then poisons
> d3's internal {{__zoom}} datum for the remainder of the session.
> The canvas pipeline relies on {{isFinite}} / {{!isNaN}} at every transform
> mutation site, so any catastrophic-but-finite value -- whether produced by
> canvas math itself, replayed from {{localStorage}}, or returned from the REST
> API -- flows straight through.
> A few specific failure modes contribute:
> * The zoom handler ({{canvas-view.service.ts}}) gates each axis with
> {{!isNaN(...)}}, which is {{true}} for {{Infinity}}.
> * {{CanvasView.transform()}} has no validation at all, so a corrupted caller
> pushes the bad value straight into d3's {{__zoom}} datum.
> * {{CanvasView.getSelectionBoundingClientRect()}} seeds its reduce sentinel
> with {{Number.MAX_VALUE}} / {{Number.MIN_VALUE}}; on an empty selection
> those seeds leak through unchanged and the normalized result is the
> {{~7.49e307}} fingerprint that surfaces in corrupted flows. (As a separate
> correctness bug, {{Number.MIN_VALUE}} is the smallest *positive* double
> ({{~5e-324}}), not the most-negative value, so non-empty selections in
> negative canvas space aggregate {{right}} / {{bottom}} incorrectly.)
> * {{CanvasView.getCanvasPosition()}} divides by the current scale with no
> output bound, so a corrupted upstream transform produces drop coordinates
> that the next component-create POST persists into the flow.
> * {{TransformEffects.restoreViewport$}} reads the last-known viewport from
> {{localStorage}} with an {{isFinite}}-only check; a once-corrupted entry
> re-poisons the canvas on every subsequent load and is never evicted. This
> is what makes the failure mode survive a hard refresh.
> * The reusable {{<canvas>}} component in {{ui/common/canvas/}} has the same
> {{isFinite}}-only gates, so any host that mounts it (flow-designer, the
> read-only connector-canvas troubleshooting view) is equally exposed.
> h2. Scope
> The fix touches the flow-designer canvas surface and the shared reusable
> {{<canvas>}} / {{<birdseye>}} components. Both currently-known hosts of the
> reusable canvas (flow-designer and the read-only connector-canvas
> troubleshooting view) must be hardened: the read-only host cannot produce a
> bad coordinate, but it displays them through the same components and triggers
> the same overflow.
> h2. Proposed solution (summary)
> In broad strokes:
> # Introduce shared magnitude-bounded numeric-safety helpers in
> {{@nifi/shared}} (replacing ad-hoc {{isFinite}} / {{!isNaN}} checks with
> a finite-AND-magnitude-bounded type guard, plus a scale-range check and a
> NaN-safe scale clamp). Pair the helpers with a small set of named
> constants for the coordinate / translate / scale bounds.
> # Sanitize positions at the data-ingestion boundary, before they are bound
> to a d3 datum or stored in NgRx state. Apply this in flow-designer's
> per-entity-type mappers and in the connector-canvas load effect. The
> reusable {{<canvas>}} itself remains a trusted consumer; the obligation
> to clean data lives with each host page. Emit a deduped console warning
> that names the affected component id so users can drag-and-save to
> repair the persisted value.
> # Harden every transform mutation site (zoom handler, programmatic
> {{transform}}, {{fit}} / {{actualSize}}, drop-coordinate emission,
> bounding-box aggregation, birdseye refresh) with the new
> magnitude-bounded guards. Where the existing code uses an in-band
> sentinel value (the {{MAX_VALUE}} / {{MIN_VALUE}} seeds in the reduce),
> prefer guarding at the source -- e.g. return {{null}} on an empty
> selection -- rather than patching every caller.
> # Evict corrupted {{localStorage}} viewport entries on read and fall back
> to a fit-to-content reset, so the failure mode cannot survive a refresh.
> # Mirror the same guards in the reusable {{<canvas>}} / {{<birdseye>}}
> components so every host page is protected without per-host effort.
> The detailed call sites, constants, and helper signatures are best decided
> during implementation review; the above outlines the approach rather than
> dictating the exact shape.
> h2. Reproduction (manual)
> The fastest path that does not require a corrupted flow on disk:
> # Open any flow.
> # In DevTools, Application -> Local Storage -> find a {{nifi-view-<pg-id>}}
> entry (one is written on the first pan / zoom). Edit its JSON in place,
> setting {{translateX}} to {{8.99e307}}. Reload.
> # Observe: every component renders stacked at the origin, the minimap is
> blank, pan / zoom / drag does nothing, and the console shows the
> {{translate(-X, -Y)}} / {{scale(Infinity)}} errors above. The corruption
> replays on every reload because the viewport-restore effect re-applies
> the {{localStorage}} entry unchanged.
> To reproduce the write-side leak (without preconfiguring localStorage):
> trigger {{actualSize()}} on a process group with no rendered components yet
> (initial-load race) and then drag-and-drop a new component. The
> {{getSelectionBoundingClientRect}} sentinel leaks into the zoom math, and the
> resulting POST carries the {{~7.49e307}} fingerprint.
> h2. Acceptance criteria
> # Under no input -- corrupted {{localStorage}}, corrupted server data, a
> forged zoom event, a fuzzed drop coordinate, an empty / racy selection,
> or division by a near-zero scale -- can the canvas state reach a
> configuration that produces {{translate(<finite-but-huge>, ...)}} or
> {{scale(Infinity)}} on a {{<g>}} attribute.
> # The drop-coordinate emission path returns {{null}} rather than emit a
> coordinate that the rest of the UI would later reject.
> # A corrupted {{localStorage}} viewport entry is evicted on read; the user
> does not need to manually clear browser storage to recover the canvas.
> # The connector-canvas (read-only display) is equally protected against
> bad server data, even though it cannot produce it.
> # A flow containing pre-existing bad positions renders with the affected
> components at {{(0, 0)}} (not at the corrupted coordinate, and not
> collapsing the entire canvas to the origin) and emits a deduped console
> warning identifying each affected component id, so users can drag-and-save
> to repair the persisted value.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)