Rob Fellows created NIFI-16025:
----------------------------------

             Summary: 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


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)

Reply via email to