LearnNewsExamplesServices
Frontmatter
id19110
titleA sparkline's data update goes unpainted for 10 s on the dense Workstation control
stateClosed
labels
bugaitestingdashboard
assigneesneo-fable
createdAtSep 23, 2026, 12:47 PM
updatedAtSep 23, 2026, 1:30 PM
githubUrlhttps://github.com/neomjs/neo/issues/19110
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 23, 2026, 1:30 PM

A sparkline's data update goes unpainted for 10 s on the dense Workstation control

Closed v13.2.0/archive-v13-2-0-chunk-22 bugaitestingdashboard
neo-fable
neo-fable commented on Sep 23, 2026, 12:47 PM

Context

Promoted from the 2026-09-23 09:14Z defect-note after it became the dense Workstation control's dominant red: test/playwright/e2e/workstation/WorkstationNL.spec.mjs — "the real tour keeps density, data, Canvas output, themes, rails, and identities live" — reds at its Canvas receipt (the exact scale Canvas changes after its worker receives new data, expect.poll(readCanvasPixels …).not.toBe(beforePixels), 10 s) in 12 of 16 runs today, the last 9 in a row (10:2x–10:45Z), on dev@5424760c03…231e4e64a8 plus PR #19109's six-line reconciler change, headless Chromium 1280×800 on the seat's own Neural Link bridge. The first three runs of the day (08:50–08:57Z) passed it. With that one poll softened, the same tour runs green end to end (3/3), so the red is confined to this receipt.

The Problem

The receipt asks the Canvas Worker's paint to change after the App Worker receives new data. Measured in instrumented runs (10:03–10:35Z): pulseScaleSparkline(target) returns its receipt and the target sparkline's App-worker values differ within ~80 ms of the call, every run; the on-glass canvas.screenshot() stays byte-identical for the full 10 s in the failing runs. The canvas element is the same DOM node throughout the test (a Set tagged at boot), getContext('2d') throws InvalidStateError on it (its control is transferred, as expected), it is connected, visible, 139 px wide; no console error or pageerror. So the data path is live and the paint path is not — for that sparkline, for at least 10 s.

Observation vs inference: the unpainted update is observed; which side stops — the renderer never scheduling a frame for that item, or a frame drawn into a bitmap the compositor no longer presents — is not classified.

The Architectural Reality

  • src/canvas/Sparkline.mjs is the Canvas Worker's singleton renderer: one requestAnimationFrame loop (renderLoop, :302) drives every registered item; sparse animation pulses one visible chart at a time, and adaptive backpressure snaps a data update instead of morphing it under load (Vega's 2026-06-04 read of the file; the operator's 2026-02-10 note on cell sparklines animating during scroll).
  • The receipt's target is the first scale sparkline whose App-side offscreenRegistered is true (readScaleSparklines, :276); right before the pulse the test sets usePulse: false, useTransition: false on it and waits 150 ms (:1495–1499). A snap update on an item that neither pulses nor transitions depends on the loop still visiting it — a lead, not a cause.
  • The witness is a Neural Link spec (deselected in CI), so the red is seen only on seats running it locally — every seat running the dense control today.
  • Falsified: a 400 ms held press on the dock splitter does not stop the sparklines painting (four pulses of neo-sparkline-1 repainted in 246–264 ms, 09:11Z), so the #19086 test-side settle reads are not the cause; those receipts landed before the failure rate rose.

Classification (2026-09-23, instrumented dense-tour runs 10:53–11:04Z on the seat's own bridge)

The update was painted — it was the same picture. Receipts from inside the Canvas Worker for the exact pulse the receipt makes: updateData found the target item (139×45 bitmap, activeItems no, stress 1.04), took the snap branch and drew; the component received that receipt in 2 ms; the canvas stayed the same node in the same row (a MutationObserver on the pane saw zero removals or insertions around the pulse), connected and in view. The bitmap's own checksum (a sampled getImageData sum) read 12500 before and 12500 after the draw — the new values produced the identical picture — and a second pulse changed it (→ 11661) and the glass at once.

Why the picture repeats: pulseScaleSparkline (apps/workstation/view/Workspace.mjs) built the trend from ((feed.sequence + point * 5) % 9) - 4, and the seed (apps/workstation/store/Scale.mjs) from ((index * 3 + point * 5) % 9) - 4 — one family of nine cyclic delta patterns (each a twelve-point window of the same nine-cycle). The sparkline normalises its values to the cell, so a picture is its deltas: record 1 (index 0, the receipt's usual target) repaints identically whenever the feed sequence is a multiple of nine at the pulse. The pulse lands at a near-constant sequence after boot (10 records/s), so the collision is timing-locked — #19086's press and settle fixes shifted the test onto the colliding sequence, which is why the rate rose from 0 of 3 to 12 of 16 in one morning. The title's "unpainted" was the receipt's reading, not the mechanism.

Falsified on the way: a missing worker item (found every time); a remount after transfer (offscreenRegistered true, no DOM churn); the loop not visiting a non-animating item (the snap branch draws synchronously); the #19086 settle reads and a held splitter press (09:11Z: four pulses repainted in ~250 ms while usePulse was still on). Found on the way, real and unit-proven, but not this receipt's cause: the renderer's loop overwrote a snap update that arrived while a transition was in flight — renderLoop recomputes a transitioning item's values from its old start and target every tick and lands on the old target (src/canvas/Sparkline.mjs, transition block). Noted for a future witness: DomAccess.transferCanvasToWorker swallows a remounted canvas's second transferControlToOffscreen() (:1433) and worker/Canvas.mjs#unregisterCanvas deletes the retrievable window map, so a remounted sparkline could never paint again.

The Fix

pulseScaleSparkline advances its pattern offset until the new trend's deltas differ from the record's current trend (Workspace.sameSparklineShape: same length, same deltas) — the nine shifts are distinct windows, so at most one can collide. The renderer's snap branch retires an in-flight transition before it draws (and drops a pulse-less item from the loop's active set). Two unit arms: a borrowed-prototype pulse with a feed sequence that collides with record 1's seed on purpose, and the renderer's loop driven by hand against a stand-in canvas. The dense witness is the runtime receipt: the Canvas assertion green in three consecutive runs of the tracked spec.

Acceptance Criteria

  • AC-1 The cause is classified from Canvas-Worker evidence (item found / drawn / bitmap changed) and recorded here. Restated 2026-09-23: not a stall — the bitmap was drawn and unchanged because the picture repeated.
  • AC-2 WorkstationNL.spec.mjs "the real tour keeps density…" passes its Canvas receipt in three consecutive runs on dev, unmodified apart from what the classification requires.
  • AC-3 A pulse never repaints the record's current shape, and a snap update on a sparkline with usePulse: false, useTransition: false survives the next loop tick — unit arms on apps/workstation/view/Workspace.mjs and src/canvas/Sparkline.mjs, both red before their fix. Restated 2026-09-23 from "painted within one loop tick", which the classification showed was never the gap.

Out of Scope

  • #19094 / PR #19109 (the overflow control frame) and #19086 / PR #19095 (the press and monitor fixes) — the tour reaches this receipt because of them, and they do not touch the renderer.
  • #19103 (a canvas worker per window group) — a different problem on the same worker.

Related

#19086 · #19094 · #15252 (cornerstone 4: the dense control is the film's control) · #19103 · #19045

Sweeps: live latest-20 open at 2026-09-23T10:46:42Z — none equivalent (#19103 is the per-window-group slice, not a paint stall); A2A claims in the last 60 min — none on this surface (Ada #19103/#19105/#19108, Grace pages#7, Clio Institution #182/#183); query_raw_memories on the symptom — the renderer's design (sparse animation, adaptive backpressure) and Ada's presenter fallback (#19045), no prior decision on an unpainted update; own open assignments (#19094, #15252) — no overlap.

unowned-rationale: the Canvas Worker renderer's owners classify a paint stall faster than I would; I carry the receipts and the reproduction (the dense control on any seat's own bridge, NEO_NL_PORT per #19066). Claim by self-assigning.

Origin Session ID: 522f6841-f568-4800-bff8-7ee8e1f99dd3 Retrieval Hint: query_raw_memories("dense Workstation control Canvas receipt sparkline values change pixels unchanged renderLoop snap update")

🪢 Mnemosyne (Claude Fable 5.1 · Claude Code) · session 522f6841-f568-4800-bff8-7ee8e1f99dd3

tobiu referenced in commit 6bfb02f - "fix(workstation): a scale sparkline pulse always changes the drawn shape (#19110) (#19111) on Sep 23, 2026, 1:30 PM
tobiu closed this issue on Sep 23, 2026, 1:30 PM