Repository navigation
AXIS v2.26.0
[2.26.0] β 2026-10-31
Changed
-
While ranging, chart paints stay tip-
updateonly. Overlay/bgcolor/OHLC smart-apply skips full-seriessetData, markersetMarkersdefers until the gesture ends,PaneManager.setDataand non-fitsetDataToChartno-op, and the existing slide-replace deferral stays. Tip updates continue. Cuts the post-slide-deferpanFramemax spike (~133 ms at 15k bars) from engine plot/marker writes during pan. -
Live forming-tip apply skips O(n) align / overlay rebuild and store sync. When
executionContext === 'tip',applyPrecomputedChartreuses the last aligned overlay payloads (tip-patch only), skipssetLastRun/updateIndicatorStore/ drawings when unchanged, and reusesstore.barsfor the cached OHLCV axis. Append and snapshot still take the full path. Bridge tip deltas reuse cached input descriptors. Aimed at G1 apply β€ 8 ms on a 5k-bar tip. -
Live window-slide
setDatadefers while the chart is ranging. Tipseries.updatestays on; a full-window replace during pan/zoom was the ~84 mspanFramespike under live ticks. The pending slide flushes on the next frame after the gesture.PaneManager.isRanging()exposes the gesture for the paint path. -
chart.lagv1 latency rings are a recent window of 120 samples and skip the first 16 after connect/reset. Cold-start / one-off spikes no longer pin live p95 forever in the old 240-cap ring. Field names (queueWait,tickToApply, β¦) stay stable. Pan and long-task rings share the 120 window but not the warmup skip. -
Rolling-window slides defer the full-series
setDatato one animation frame. Forming ticks stay onseries.update. A slide still needs a replace (LWCupdatecannot drop the rolled-off head), but the replace is coalesced to one rAF and no longer flushes a wasted tipupdateimmediately beforesetData, so the per-minute 5k-bar hit leaves the tick/apply path. WithoutrequestAnimationFramethe replace stays synchronous.
Added
-
MCP
logs.debug.get/logs.debug.clear. Reads or clears the rotatingaxis.debug.log.*localStorage ring written when AXIS debug mode is on (VITE_AXIS_DEBUGorlocalStorage.axis.debug). -
chart.lagv1.queueWaitis recv βapply:start. On the worker path that isbatch:postβ bridge entry, so a live G1 read can separate main-thread queueing from the apply span. Live ticks also skip the O(n) internal-hole scan and debug-pin clear when pins are off. -
CLI 0.3.6.
@hoox-sh/axis-clidefault PYNE URL is the hosted Pro API (https://pynescript.online);AXIS_PYNE_URLstill overrides it. The staledefaultPyneUrltest now matches the shipped default. -
Pine hover/autocomplete refreshed against PYNE 0.6.25.
src/editor/data/pyne-builtins.jsonre-synced frompyne(builtin_metadata.json, 88 improved docs incl.line.fill/linefill.new). Library annotation cards now render the description verbatim as Markdown (headings, code fences, blockquotes, tables, links, images, italic/strikethrough and two-space line breaks), matching PYNE's hover.//@descriptiononlibraryshows a library card. Card sections use PYNE's**Fields:**/**Parameters:**/**Returns:**shape with- \name`: desc` bullets.
Fixed
-
Shortcuts help no longer steals typing keys.
app.show-shortcutswas bound to bareShift-?/Shift-/, so typing/(Shift+7 on many layouts) or?opened the shortcuts overlay β the editor could not type//. The binding now requires a real modifier (Alt-Shift-?/Alt-Shift-/) and is skipped while focus is inside an editor/input/dialog. -
Series live no longer storms
fullsnapshots (~6/s) into chartsetData. Debug ring showed 228wire.series.full+ 200setDatain ~40s: gap recovery cleared its backoff on every full, then unsubscribe+subscribed again. Series gaps now resume (scripts still fresh-subscribe); sliding fulls after the first snapshot tip-merge only; backoff clears only after a continuous delta. -
Live candle paint no longer clamps or repairs OHLC.
PaneManager.appendBarno longer callsrepairOhlcon forming ticks β exchange OHLC is clean ~99.9% of the time, and clamping could alter real values. Non-finite ticks are dropped instead. -
sanitizeSeriesDataForSetDataremoved entirely. No donor fill, no OHLC fill-from-neighbor, no clamping β bad rows are simply dropped beforesetData. A minimalfilterValidCandleRowsguard drops rows with non-finite time/OHLC or duplicate times (last write wins) so LWC never throwsValue is null.repairSeriesPayload,setCandleData, andsetVolumeDatapass data straight to Lightweight Charts. -
Live script input-change resubscribe no longer stays
idle/released. An input edit (e.g. Momentum Length 10β100) unsubscribes then re-subscribes on the same id; the PYNEidle/releasedack arriving duringawaitSnapshotused todropScriptLocalthe half-open replacement (wipingappliers/ ctx), so the next eval re-subscribed forever and never rejoined while Bollinger/SMA stayed live. In-flight script subscribe now ignores that ack (same idea as gap-resyncholdUntilFull), clears the inbox on release, and re-binds applier state when the new full lands. -
Venue live owns candle paint β engine series snapshots/deltas no longer
setDataor overwrite forming bars. Worker tips were painting stub OHLC (tiny volume, disconnected opens) on top of Binance candles β visual gaps β and 5ksetDataon every engine full stalled scripts (websocket Β· cooldown/ stop updating). Replica stays on the wire for indicators; chart tail stays venue. -
Engine-owned mode no longer repaints the full window from PYNE on every tick. The
fulldiff branch calledsetDataToChart(proBars), replacing every candle β including venue-closed bars whose OHLC PYNE could report collapsed or stale. Now engine bars are merged into the venue window viamergeEngineSnapshot: existing times keep venue's OHLC, only new times from the engine are appended, and the chart is only repainted when the merged window actually changed. Closed candles no longer get collapsed on a new candle. -
Live start / scheduleRerun re-subscribes every visible non-library script, including
idle/released. PYNE unsubscribe acks (idle+ messagereleasedvia live-wirereleaseβunsubscribe) no longer leave EMA/Momentum blank while Bollinger stays on the wire: multiplex bypasses stream-eval backoff for released scripts, clears their retry state, and on stream-route live start kicks an immediate (0-debounce) scheduleRerun.scriptHistoryBarsuses the stable chart window so a 1-bar length flicker cannot changehistory_barsand bounce subs. -
Price chart printing pipeline refactored.
store.appendBarsplits intoapplyCandlePoint/applyPriceLineTint/updateVolumeBarfor a simple O(1) live-tick path;setCandleData/setVolumeDataextract full-history setData out ofmanager-access.setDataToChartso both full loads and live slides share one code path. Test doubles / managers without the new methods fall back to inline painting.appendBarno longer bumpschartDataGenon a live slide β ChartHost'schartDataGeneffect responded withfit:trueβfitContent(), resetting zoom/position on every new candle.appendLiveBaralready paints slides withfit:false, so ChartHost does not need to repaint. -
Live candle open no longer jumps when historical data arrives.
store.appendBarpreserves the candle's open once it has started forming: a historical close for the same bar updates high/low/close only, so the open set by the venue's first tick is not overwritten. -
store.appendBarnow writes live-tick OHLC tostore.barsinstead of a discardedunwrap()copy.Object.assign(unwrap(tip), payload)mutated a deep copy returned by Solid'sunwrap(), so live ticks never persisted updated OHLC/high/low/close intostore.bars. A fullsetData(window slide at capacity, enginemergeEngineSnapshot, etc.) then painted stale OHLC from the store β old candles collapsed/lost their OHLC when a new candle arrived. Both the OHLC write and theclosed = trueflag now go throughsetStore('bars', index, ...)so the store is the source of truth for bothseries.update(live tip) andsetData(full window). -
Engine deltas no longer overwrite venue OHLC on forming candles.
store.appendBarnow accepts aviaparameter ('worker'|'venue'). When the engine (via: 'worker') updates a forming tip (same bar time, tip not closed), venue's OHLC values are preserved β engine deltas may have slightly different values due to computation/rounding, and overwriting them caused candles to collapse/change OHLC on every engine tick. Venue ticks (via: 'venue'or undefined) always overwrite β they are the source of truth for forming candles. Threaded throughappendLiveBarβappendBarβstore.appendBar. -
Indicator live pushes no longer shift right on a new bar.
alignApplySerieshead-padded shorter series with nulls when the engine evaluated the previous window, shifting every indicator value right by one position instead of extending onto the new bar. On venue live pushes (non-session, no engine clamp), shorter series now tail-extend by holding the last value forward (extendSeriesToWindow), so plots like MA ribbons extend onto the new candle instead of shifting. Session runs and free-tierwindowBarsclamps keep the existing head-pad / tail-pin behavior. -
Chart view no longer resets (zoom/position) on each new candle.
appendBarno longer bumpschartDataGenon a window slide. ChartHost'schartDataGeneffect previously responded withfit:trueβfitContent(), which reset the viewport on every new candle.appendLiveBaralready paints slides withfit:falseviascheduleSlideReplace, preserving the view. -
Candle rendering no longer merges bars or hides bodies. LWC conflation (
enableConflation) andminBarSpacing: 0.5were merging sub-pixel bars at 2500+ bar histories, rendering candles as dojis with gaps between them. Conflation is now disabled (enableConflation: false,minBarSpacing: 0.001) in bothseries-factory.tsbase config andheavy-data.ts, and time scale options are re-applied on every live tick so existing charts pick up the change without a reload. Viewport auto-scrolls to follow new candles (shiftVisibleRangeOnNewBar: true). -
Live candle debug logs include full OHLC.
live.appendentries now recordopen/high/lowalongsideclose, so MCPlogs.debug.getshows each candle's complete OHLCV for verifying forming-tick and historical-bar values. -
Default rolling window lowered to 5,000 bars to match PYNE free tier limit.
ROLLING_WINDOW_DEFAULTwas 10,000 β the free tier engine refuses windows above 5,000 with HTTP 413, surfacing βLive re-run failed: OHLCV exceeds free-tier limit (5000 bars)β on boot. Pro tier users can still raise the window in Settings β Chart β Rolling window. -
Adding a second indicator no longer supersedes the first.
runAndApplyreused the current epoch when an interactive run was already in flight, so rapidly added indicators no longer mark each other βRun supersededβ before either engine evaluation completes. -
Comprehensive debug logging added to chart paint path.
chartDebugentries added tostore.appendBar,normalizeBarPayload,repairOhlc,mapBarsToPriceData,toOhlcData,isFinitePriceBar,mapBarUpdate,mapHeikinAshiUpdate,sanitizeSeriesDataForSetData,findOhlcDonor,setCandleData,PaneManager.appendBar,applyCandlePoint, andupdateVolumeBar. Use MCPlogs.debug.getto trace OHLC values through the entire paint pipeline and identify where candles collapse to lines or lose their OHLC values. -
sanitizeSeriesDataForSetDatano longer overwrites all four OHLC fields from a donor. Previously, when any OHLC field was missing/invalid, all four values were replaced from a single donor neighbor β making multiple bars identical (collapse to lines). Now only the fields that are actually non-finite are repaired from the donor; valid fields are preserved. Look forsanitizeSeriesDataForSetData { verdict: 'filled-fields-from-donor', repaired: [...] }in MCPlogs.debug.get.
\n\n-repairOhlclogging reduced to actual clamping only. Previously loggedopen-eq-closefor every doji candle (thousands of noise entries). Now only logs when high/low are actually clamped. Raw venue OHLC is logged before repair viasanitizeStreamBar { verdict: 'pre-repair' }andsanitizeBar { verdict: 'pre-repair-array|object' }, so you can compare venue OHLC against stored OHLC to find where corruption happens. -
Indicator overlays update on the live forming candle again.
alignApplySeriesstaleNoAxisskipped every stream live push whose engine series was shorter than the chart window β which is the steady state, because script snapshots are capped atLIVE_SNAPSHOT_BARS(500) while the rolling window is typically 5000. Tail-clamp shorter series onto the chart tip (head-padded withna, newest values on newest bars) instead of skipping, so indicators repaint every tick instead of freezing until the snapshot outgrows the window (skipped: 'stale-no-axis'). -
Live-wire seq-gap thrash no longer blanks indicators. A script/series gap now does one backoff-gated unsubscribe+subscribe for a fresh full (instead of resume-loop forever), honors
seqAckFromas continuous, paints when that full lands, and clearsholdUntilFull.history_barsshrink/flicker no longer bounces the sub; engineidle/releaseddrops local state so the next eval rejoins; script HUD live/connecting clears a stale βdelta gap β resubscribingβ message. -
Live stream overlays paint when the engine series is longer than the chart window without a bar axis. A 20k replica vs 5k rolling window used to hit
staleNoAxisand skip forever (tip cache never seeded β blank indicators). Live pushes now tail-clamp onto the chart tip; wire snapshots synthesizebar_timesfrom the subscription window when the engine omits them. Shorter-than-chart axis-less pushes still skip. -
Main-thread live WS shares the soft
UNAUTHORIZEDauthBlock policy with the ingest worker.shouldAuthBlocklives inframes.ts; JSON and worker transports only permanently block on real bad-key errors, and otherwise close to remint/re-auth. -
Stream script eval retries with backoff after
StreamFallbackErrorinstead of stayingdegraded Β· websocketforever. Multiplex records a per-script retry-after (1s β 2s β 5s β¦ cap 30s), skips mid-backoff ticks (still no REST full-window fallback), and clears backoff whenstreamEvalScriptsucceeds so status returns to live. -
Live-wire auth-race subscribe and reconnect cooldown. Series/script subscribe errors with
UNAUTHORIZED(or "Authenticate first") retry once after a short delay instead of immediate fail/cooldown; a successful reconnect (resumeAll) clearscoolUntilso a transient fail cannot keep rejectingensureSeries; a failed script subscribe fullyrelease()s the applier so the nextstreamEvalScriptcan try again. -
Ingest worker no longer permanently
authBlocks on PYNEUNAUTHORIZED. Connection-levelUNAUTHORIZED/ "Authenticate first" means not-yet-authed (pre-auth race), not a bad API key: while waiting forauth.okthe worker resendsauth; otherwise it clears the auth wait and reconnects. Permanent blocks stay for other non-retryable connection errors (e.g. invalid/missing API key). -
Tickβpaint p95 no longer absorbs hidden-tab stalls or same-frame slide
setData.paint:rafdrops bursts armed while hidden or suspended mid-flight (rAF occlusion was pinning live p95 ~177 ms / max ~300 ms while apply stayed ~30 ms). A window slide tip-updates the new bar immediately and yields the head-dropsetDatato a microtask after rAF so siblingpaint:rafcallbacks in that frame are not queued behind a 5k-bar replace. Snapshot field names unchanged. -
Live-wire same-slot series tips close
queueWaitsynchronously. Metrics-onlyapply:start/endno longer waits on a kick microtask (the ~16 ms p50 after the prior microtask move). A new tip time still coalesces one cached-script paint on a microtask so a real script frame in the same turn can cancel it.holdUntilFull, seq, and one-tip inbox unchanged. -
Live tip-cache now hits on forming deltas that lacked a tip stamp.
stream-deltacoerces a leftoversnapshotcontext (seriesβscript kick afterapplyFull) andforming: truewithoutexecutionContexttotip, soapplyPrecomputedCharttip-patches instead of re-aligning ~5k bars every tick (live apply was p50 ~6.4 ms / p95 ~10.6 ms; cached tip microbench ~0.03β0.3 ms). OHLC overlays tip-patch the last candle; lean tip hits sync lines/OHLC only. Append/snapshot full path unchanged. -
Live-wire inbox and series kick cut residual
droppedTip/ panqueueWait. Live forming tips (already applied viarouteFrame) skip the waiter inbox once the sub has an applied seq, so same-slot replaces no longer inflatedroppedTip(was 85β239 under load). The seriesβscript kick closesbatch:postβapply:starton a microtask instead ofrAF, so pan/zoom at ~15k bars cannot stall queueWait behind a delayed animation frame (~430 ms p95). TipCoalescer forwards contiguousUNCHANGEDkeepalives (after flushing a pending tip) so the applier advances seq withoutseqAckFromholes thatclient-applycounted as drops; gap keepalives stay observe-only. Snapshot-waiter inbox still keeps β€1 forming tip; same-slot kick stays metrics-only. -
Live-wire seriesβscript kick closes
queueWaitwithout flooding reapply. Series-only tips still endbatch:postβapply:startonce per frame, but same-slot forming OHLC only stamps metrics (no script reapply); a new tip time paints at most one cached script; a real script paint cancels a pending kick. Fixes the post-4df26790queueWait p50 regression (~426 ms β ~16β30 ms) from reapplying every live script on every tip. Mixed batches,holdUntilFull, seq gates, and one-forming-tip inbox stay unchanged. -
Ingest host subscribe/resume omit
encodingunless the caller asks, and misaligned AXB1 views no longer decode as malformed.IngestHostClienthad hardcodedencoding: 'axb1'on every subscribe, so live-wire's JSON fallback (snapshot timeout /jsonSubs) still negotiated AXB1 and could not leave the binary path β matching livechart.lagjsonFallback: 3withunknownOpcode: 0. Resume now matches the JSON client (encoding only when set). A sliced AXB1 frame whose bufferbyteOffsetis not a multiple of 8 is copied before decode so absolute i64 alignment checks cannot drop a valid snapshot. -
Single-chart layout arms
chart.lagpanFrameon pan/zoom.createSharedPanenever setrangingor wired wheel βv1NotePanActivity, so the default one-chart host leftpanFrame.n = 0after user gestures. The shared price host now mirrors the multi-pane listeners (pointer ranging + wheel). -
chart.lagv1 is counters only. The per-tick latency rings (tickβapply / tickβpaint / apply / queueWait / panFrame / longTask), the rAF paint arming, the pan-sampling loop, theconflation/visibleBarsgauges and the long-taskPerformanceObserverwere removed from the hot path β they sampled every tick and kept 120-sample windows just to report p95.v1Countcounters (seqGap,resync,droppedTip,jsonFallback,unknownOpcode,longTask) stay;chart.lagv1 now reportscounts+transport+layout.chart.v1/invariants.ts1-in-32 sampling andv1BudgetRegressedare gone. -
Live-proxy buffers client frames until the engine upstream is open, and the ingest worker resends
authonlive-relay. Browseronopencould sendauthbefore the Workerβs upstream socket was ready; the frame was dropped and subscribe then failed withUNAUTHORIZED. The proxy queues early clientβengine frames; the ingest worker also resends auth when it seeslive-relayand never signals hostopenwithoutauth.ok. -
Ingest tip coalesce and send-buffer backpressure cut tip delay under load. A second forming tip for the same subscription folds and releases immediately instead of waiting out the 16 ms coalesce window, so
batch:postis not stuck behind a quiet-period timer. Send-buffer hold uses hysteresis (enter above 1 MB, clear only below 256 KiB) so a buffer that jitters around 1 MB cannot resume every live sub in a loop. UNCHANGED keepalives are no longer treated as droppable forming tips under send hold, so their seq still reaches the coalescer and is acked. Closes and snapshots stay immediate; the pending queue remains one tip per subscription. -
Ingest worker waits for
auth.okbefore signaling hostopen. With the Worker ticket proxy, subscribe was racing ahead of PYNE auth (UNAUTHORIZEDβsubscribe-errorcooldown β venue+REST).INGEST_AUTH_WAIT_MS(5s) still opens on timeout so a slow key hash cannot wedge the socket.
Added
-
P6 flag flip proven in unit tests. The test switches worker+single to json and back: the live client follows both flags together, but the chart stays the shared single layout β the
axis.chart.layout=multirollback is gone (see Removed below). -
Inactive chart slots paint without stealing the focused manager.
setDataToCharttakes an optionalmanager/slotIdso a 2h/2v/4 cell cansetDataon its own PaneManager. Drawings and selection stay on the focused slot. -
chart.lagv1names the live transport and chart layout. The snapshot already had tickβapply / tickβpaint. It now also reportsaxis.engine.transportandaxis.chart.layoutso a live G1/G2 read can tell worker+single from the JSON/multi rollback. -
The one-chart layout resizes panes with Lightweight Charts
layout.panes.enableResize. Separator colors follow the void theme. A drag writes pixel heights into the store.PaneManager.resizecallsIPaneApi.setHeighton that layout so a sub-pane drag does not resize the whole chart. The multi-chart rollback keeps the custom handles and turns LWC separators off. -
AXIS debug mode (
VITE_AXIS_DEBUG) β detailed chart-action and engine logs. SetVITE_AXIS_DEBUG=1(alsotrue/yes; aliasVITE_DEBUG) in.envand restartbun run dev. Boot prints[axis:debug]; DevTools filter[axis:chart]/[axis:engine]. Lines also persist in a rotating localStorage ring (axis.debug.log.0β¦.3, 64 KiB each, oldest dropped). Download the ring asaxis-debug-*.logfrom System Logs (download icon) orwindow.__AXIS_DEBUG__.download(). Forming ticks and live deltas are sampled (~1/s). Optional overlay:localStorage.axis.debug=1then reload. e2e forces the flag off. -
The engine socket mints a Worker live ticket instead of putting
api_keyon/ws/live. When a cloud Worker key is set and neither the engine nor the Worker is loopback, the PWAGETs/api/ws/live?issue=ticketwith Bearer and upgrades?ticket=. The PYNEauthframe still carries the engine key. Direct local/ws/livestill uses?api_key=because the browser cannot sendAuthorization. A 401/403 mint does not reconnect; transport failures remint. -
One live batch commits price, plots, markers, then alerts.
commitLiveBatchis the Β§6.1 transaction. A workeronFramesbatch of mixed series+script deltas paints price then plots in the same task. Stream deltas apply through synchronousapplyStreamDelta/applyPrecomputedChart(snapshots stay on asyncapplyStreamPayload). -
v1 concept tests cover pane/crosshair identity, drawings through a window slide, the 20% p95 budget, and pan/zoom e2e. Playwright
@v1loads 10k mock bars while mock-poll is live and bounds pan/zoom frames; applies RSI + MACD + ATR for a price+3-indicator pane-parity screenshot; and keeps a trend line through pan, zoom, and a clock-advanced window slide.v1BudgetRegressedfails review when p95 grows more than 20% vs the fixture. -
A tab keeps at most two engine sockets. Chart slots share one WebSocket per engine endpoint (
acquireSlotWire/releaseSlotWire). Same endpoint from N slots is one live client with N refs. A second socket opens only for a different endpoint. A third waits until a slot is released. The primary chart drops its previous fingerprint on engine change so a single chart does not pin two unused sockets. -
Each chart host pins that pool for its slot.
ChartHostcallspinChartSloton mount andunpinChartSloton cleanup. Four hosts on one endpoint share one client (refs 4); unmounting three leaves one. Unpin remembers the fingerprint from pin, so a later engine change still releases the original socket. A second pin of the same slot id does not bump refs.
Removed
-
The shared-memory ring, GPU candle overlay, and HP flags are gone. Lightweight Charts paints candles, indicators, and fills. The ingest worker still owns
/ws/liveand decodes AXB1 whenaxis.engine.transportisworker(the default).axis.engine.transport=jsonkeeps the main-thread client. Settings no longer has a high-performance section.hp.get,hp.sample, anddev.traceare no longer MCP capabilities. Isolation headers stay. -
The
axis.chart.layout=multirollback is gone.axis.chart.layoutis no longer read or persisted β stored values are ignored likeaxis.engine.hp.*. One Lightweight Charts chart per slot only:ChartLayoutMode/chartLayoutMode/setChartLayoutMode/resetChartLayoutMode/usesSingleChartLayout,CHART_LAYOUT_KEY,SlotChart, shared-pane chrome, multi-chart slot-bar caches, custom pane-resize handles (pane-handle-*), crosshair mirroring across panes, and themap/mirrorbranches insyncTimeScales,alignTimeRangesFromPrice, and crosshair rewiring are removed.chart.lagv1 always reportslayout: 'single'.
Fixed
-
MCP
stream.resetre-pins mounted chart hosts.resetStreamWirekeeps live ChartHost slot ids, drains the pool, then pins those hosts onto the current engine fingerprint (new sockets). Unpin still drops a slot so reset cannot resurrect it. With no hosts mounted the pool stays empty untilgetWire. -
streamStopkeeps mounted ChartHost slots. Stop drains and disconnects sockets and clears remembered fingerprints, but still-mounted slot ids survive. The nextgetWirere-pins those hosts onto the current engine. Unpin still drops a slot so stop cannot resurrect it. With no hosts mounted,getWireopens one primary socket. -
A pane whose visible logical range disagrees with the price axis is reported.
alignTimeRangesFromPricesampleslogicalRangeSkewonce against the one shared chart (no per-pane mirror). Dev warns on every new skew size. Production warns about 1 in 32 times, once per size. Matching ranges are silent. -
A plot column shorter than the price axis is reported.
applyChartMutationssamplesplotLengthSkew. Dev warns on every new absolute size. Production warns about 1 in 32 times, once per size. A longer column still uses the overhang text and is not warned twice. -
Crosshair stays on the shared single chart. Data-driven (no
sourceEvent) moves no longer attempt a cross-mirror that never had a second chart to write to;syncCrosshair/rewireCrosshairsubscribe once perIChartApiinstead of per pane, andapplyCrosshairToPane+ the multi-pane resize handle are gone. Hovered-pane gating only gates the host callback now. -
The single chart wires one crosshair listener. Sub-pane facades are different Proxy objects around the same Lightweight Charts chart. Once the slot exists, later panes do not subscribe again.
-
A series that ends after the price tip is reported.
applyChartMutationssamples overlay and plot times against the candle axis. Dev warns on every new past time. Production warns about 1 in 32 times, once per time. A shorter series is warmup and is not a warning. -
Inbound live frames as
Blobdecode likeArrayBuffer. Cloudflare compat β₯ 2026-03-17 delivers binary WebSocket frames asBlob.decodeInboundstays sync for string /ArrayBuffer/ views;decodeInboundAsyncawaitsblob.arrayBuffer()then decodes. AXB1 is detected on the bytes and is neverTextDecoder'd. Malformed Blob bytes staymalformedand do not throw. The ingest worker callsdecodeInboundAsyncfor Blob instead of unwrapping itself. -
Strategy and plotshape markers commit with the plot series. Marker lists are built before the chart mutates. One
runApplyTransactionpaints markers then plots, so a throw insetShapeMarkers/setTradeMarkersdoes not leave a new plot generation on the chart. The 2500 marker cap is unchanged. Live ticks still update that bar's markers. -
A price seq gap waits for a snapshot. A live series delta that skips
seqno longer paints. The subscription resumes once, later ticks stay dropped, and the nextfullpaints. -
The first live script snapshot uses the same paint pump as later ticks.
evalScriptno longer paints off to the side of the chain, so a delta cannot race the snapshot. -
A live stream snapshot paints the whole indicator. Stream deltas still update the tip only. Treating the first snapshot as a live tick skipped the apply when the engine window and the chart window disagreed and there was no
bar_timesaxis, so the pane stayed empty once live started. -
A newer snapshot is not painted under a late forming tip. Script paints wait on a per-subscription chain and always apply the newest payload. The last paint is the snapshot, then later deltas.
-
APPEND_BAR flushes a pending forming tip then the append. Same as a
close: a confirmed (forming: false) delta for that subscription does not wait for the coalesce timer. -
AXB1
SNAPSHOT_PLOTSkeepsbar_timesaligned withcount. A plots snapshot with times round-trips exactly. Encode throws when times or a plot column is the wrong length. The ingest mapper copies those times onto the JSON full asbar_times. Times stay optional so a frame without them still encodes. -
A refused replay stays on the socket. After a gap, an error from the engine asks once more without AXB1. A second error leaves the subscription stale and does not paint later deltas. There is no REST repair.
-
An inbox full of closes asks for one snapshot. Forming tips are still the only frames dropped. When the inbox is over its cap and every frame left is a close, a snapshot, or a confirmed delta, those frames stay and the subscription resumes once. The next snapshot allows another resume.
-
A full send buffer drops forming tips and resyncs when it drains. Past 1 MB of unsent socket data the socket stays open, forming deltas are not applied, and each live subscription is marked resyncing. When the buffer drains, those subscriptions resume once. Snapshots and closes still pass.
-
A forming tick notifies Solid once per frame. The bar value is written immediately. The quote tick and the tip subscription flush together on the next animation frame, so a burst of ticks does not re-render the price chrome once per tick. A new bar and a window slide still commit immediately.
-
Pane badges sit on the shared chart's plot cell. On the one-chart layout, a volume or indicator badge was rebuilt onto the hidden placeholder the next time badges refreshed. The badge now stays on that pane's plot cell, and a refresh finds it there. The multi-chart rollback still badges its own pane div.
-
Script and strategy runs stay on the WebSocket. A failed socket run does not POST
/runor open a REST data session. Live scripts that miss the stream wait on the socket instead of falling back tovenue+REST. REST/runremains only when WebSocket is explicitly turned off. -
The live inbox drops extra forming tips. Each subscription keeps at most 32 frames. Only a forming delta is dropped. A
close, afull, an error, and a non-forming delta stay, and the snapshot waiter still receives thefull. -
The quote card stays on the candle while the stream is down. The quote sheet, the float price panel, and the chart price card use the last bar close when the stream plane is connecting, degraded, closed, or in error. An open stream still shows the live tick.
-
The single chart does not copy its time range or crosshair onto a second chart. Volume and indicator panes share the price chart's time scale. Range mirroring and crosshair mirroring run only for the multi-chart rollback.
-
A subscription that is waiting for a snapshot does not paint deltas. After a gap or a failed apply, further deltas are dropped until the next
full. That snapshot clears the wait and paints once. -
A late snapshot does not paint over a newer sequence. A full whose
seqis behind the cursor already applied for that subscription is dropped. The newer bars and the newer seq stay. -
A closed bar's OHLC is not rewritten after commit.
appendBarwarns (noteClosedRewrite) and returnsignored. The store keeps the committed close. Closing the forming tip is still an update. REST/dataset merge is unchanged. -
A forming tick does not change the closed-bar hash.
closedBarHashhashes only bars that are closed (closed !== false). History rows that omit the flag still count as closed. Closing the tip still changes the hash. A closed OHLC restatement is dropped and does not. -
Engine series closed-bar alerts run in the live apply.
onSeriesDeltaevaluates alerts after a non-ignored append of a closed bar. The venue multiplex skips that evaluation while the stream route is active so the same close does not fire twice. -
A closed bar whose OHLC changes after commit is reported and dropped. Dev warns on every new bar time. Production warns about 1 in 32 times, once per bar. The committed close stays.
-
A plot column longer than the price axis is reported.
applyChartMutationssamples the column lengths. Dev warns on every new overhang. Production warns about 1 in 32 times, once per size. A shorter column is sampled as length skew. -
Several live ticks in one turn paint the chart once. The store still updates immediately. Same-time ticks keep the latest bar. A newer time is applied after the previous bar in that same flush. A window slide still replaces the series immediately, with
fitoff. -
An open stream with no tick for 3 seconds says
delayed Ns. Reconnecting keeps its own text. The live tooltip on the top bar, the connection HUD, and the mobile live button reads the stream plane's last event time. -
Sync checks for the price tip, plot length, and closed bars.
paneAxisSkew,seriesPastTip,plotLengthSkew, andclosedBarHashare pure helpers. They do not paint. -
A dropped socket waits with jitter and does not retry a rejected login. The reconnect delay grows from 500 ms and stays at or under 30 s, with a little downward jitter. An error frame with no subscription and
retryable: falsestops the loop until the nextconnect(). -
Only a close marks a bar closed. An AXB1 tip is always
closed: false.APPEND_BARandCLOSE_VERIFYsetclosed: trueon the committed candle. A later forming tick cannot reopen it. -
Alerts wait for a closed bar. A forming tick no longer evaluates price, drawing, or indicator alerts. The live hook runs only when the bar is marked closed.
-
A live bar time comes from the server, not the local clock. AXB1 snapshot, tip, close, and append times go through the same unix-second normalizer as venue bars. A tip that omits the bar open stays on the previous server bar. The frame's
tsMsis not used as the candle time. -
A live
setDataputs the visible range back. Window slides and left-edge prefetches replace the series withfitoff.setDatacan still jump the logical range, so the range from before the replace is written back. A full load still fits. A tick still usesupdateand does not callfitContent. -
A snapshot that never arrives retries twice, then that subscription asks in JSON. The waits stay bounded (the same snapshot deadline, then 50 ms and 200 ms). An AXB1 socket resubscribes once without
encoding. A JSON socket just fails. The series socket stays open. -
An unknown AXB1 opcode does not close the socket. The decode counts
unknownOpcode, names the subscription, and that subscription resumes withoutencoding: 'axb1'. Other subscriptions stay on AXB1. A second unknown opcode for the same subscription does not resume again. -
A hidden tab does not replay every missed tick. The live socket stays open. Forming ticks fold into one slot per subscription. When the tab is shown, a
closed_uptothat moved resumes once from the last painted close. Otherwise the latest tip is applied once. A seq that never arrived still resumes. -
A dead ingest worker is replaced.
onerrorterminates that worker, starts another, and connects again. The next open resubscribes through the same reconnect handlers as a socket drop. After five crashes in oneconnect()the host stays down. It does not switch the socket back to main-threadJSON.parse. -
A dropped keepalive no longer looks like a missed frame. The tip coalescer still drops
UNCHANGEDframes and still folds several forming tips into one. Those seqs are now acknowledged on the next frame that is actually released (seqAckFrom). The applier and the apply transaction advance through that run. A seq nobody saw is still a gap and still resubscribes. JSON decode strips a forged ack, so a socket payload cannot hide a real hole. -
Tail-align pads at the head again, so a clamped series no longer paints early.
tailAlignSeriesToWindowwas flipped to pad at the end by the P03 perf commit (33cc4269) without a test or changelog change, contradicting its own doc block ("a shorter one head-padded withnull") and thetailAlign*contract. Consequence: ameta.windowBars: 3free-tier clamp ([11,12,13]on a 5-bar chart) painted 11/12/13 on the three oldest bars instead of the newest three β every value shifted two bars early. Restored head-padding (the newest engine value belongs on the newest chart bar); the engine-evaluated-fewer-history-bars case stays the job of the bar-time join (alignSeriesToTimes). Added a unit regression guard so a future "pad at the end" edit fails loudly, and corrected the stale comment inapply-context.tsthat described the flipped behaviour.tests/runner-engine-owned-apply.test.tsgoes 22/7 β 30/0. -
fillVisualAttrFromViewportno longer spins forever (GPU overlay / test-suite hang). The P03 single-pass rewrite leftconst len = 0with no increment in the merged hot+page fill loop, andlenwas returned as the fill length. Any viewport that touched the page slot β a zoomed-out chart on HP-3, ortests/hp/visual-attr.test.tsβ wedged the thread in an infinite loop; because that file never returned,bun run teststalled after its last completed test and only exited on timeout. Rewritten as the same two-pass shapefillVisualAttrFromHotuses: pass 1 walks the contiguous covered prefix and tracks the visible volume maximum (a gap ends the run), pass 2 writes the records. Also removed the duplicatedopen/highNDC writes the merge left behind. -
ColdStorecap test reads the absolute index it documents. AftersetBars(bars(20))withmax: 10,baseAbsis 10, soread(0, 1)is before the retained head and correctly returns empty (the A18/A23 contract). The test asserted the oldest surviving bar through relative index 0; it now readscoverage().baseAbs. -
Session overlays no longer tail-pin a window they never evaluated. A session/stream run whose engine shipped no bar axis was tail-pinned onto the chart even when its series length missed the window by thousands of bars (a 9577-bar engine window on a 5000-bar chart shifts every value and marker by 4577 slots β the SOLUSDT desync). The
staleNoAxisskip now also covers full runs whose length misses the window by more than a few mid-run appends (PIN_TOLERANCE_BARS = 8); the deliberate free-tier clamp (windowBars) keeps pinning, and equal-length runs keep the index zip. The WS/ws/runpath now also carriesmeta.engineTimeslike REST, so WS session runs can time-join instead of pinning. And a series repair now invalidates memoized data sessions + live tick/append sessions for that key, so the next run creates a fresh session over the repaired window instead of evaluating (and pinning) the stale one. Pinned by four newalignApplySeriesguard tests (gross mismatch skips, plots-array mismatch skips, clamp still pins, small race still pins) plus data-session / live-session invalidation tests. -
The WebGL2 overlay engages on HP-3 selection (hp2 D1). The overlay was a forced-
gpu=webgl2instrument;autostayed dormant even where the rung selector already landed on HP-3.startGpuOverlaynow gates on the same selection the HUD chip reports βprobeRendererKindwebgl2 plusselectHpRung() === 3β soautoon a WebGL2-only machine paints the parity overlay.off, GPU-lessauto, the painterless HP-4 rung, session-cap clamps below 3, and HP disabled all stay dormant. The hide-LWC switch remains gated (D2): this is still additive parity, not the production painter.
Added
-
A thrown paint resumes once instead of leaving a stale badge. The apply transaction still skips the rest of the batch. The status says why, and that subscription asks for one snapshot. A later failure does not resume again until a full arrives.
-
The worker transport and the single chart are the defaults. A tab with no
axis.engine.transportuses the ingest worker. A tab with noaxis.chart.layoutuses one Lightweight Charts chart per slot.jsonandmultiremain the rollback. A missing Worker still counts one JSON fallback and stays on the main-thread socket. -
Panning to the left edge prefetches one older history page. The dataset read does not run on the drag frame. The page is painted with
fitoff, and the logical range shifts by the number of bars inserted on the left so the viewport stays put. The rolling window grows with that page, up to the 100k cap, so the next live bar does not slice the page away. Conflation itself is unchanged: on at 2,500 bars, precomputed from 10,000. -
A live bar keeps its plot and its fill. When the engine axis ends before the chart tip, the last value is extended onto the newer bars instead of head-padding the series, which slid the fill onto later candles. A live tick whose axis is behind also drops the memoized session so the next run evaluates the bar the candle is already showing.
-
A script axis with a hole the candles do not have asks for a series repair.
noteChartSeriesHoleruns at apply time, so Donchian does not wait for the next series snapshot. -
A PYNE tick that reports
staledrops the script session. That flag means the engine clamped its history walk, not that the feed was idle. The next run loads the store after the gap page is written. -
A fresh engine tip no longer hides a hole in the series. Repair used to stop when
newestwas current, so a tick walk that skipped the middle (about 22 hours on DOGEUSDT 1m) stayed in the store and the Donchian axis. A bounds deficit of 30 or more bars now uploads the local window. An upload that adds nothing is remembered, so a weekend on a session venue does not re-upload every cooldown. A snapshot whose stamps jump, while the chart's do not, asks for that repair. -
axis.chart.layout=singleuses one Lightweight Charts chart per slot. The default staysmulti(one chart per pane, manual time-scale sync).singleadds indicator and volume panes withaddPaneon the price chart, sends each series to that pane index, and removing a sub-pane does not remove the candles. Time-scale mirroring is skipped because there is one scale. -
The ingest tip coalescer folds a window instead of keeping one frame. Last OHLC tip wins, plot columns union by key, and a plot tip no longer drops the pending candle. A non-tip frame flushes that subscription's tip first.
UNCHANGEDkeepalives with no bars and no columns are dropped. -
A script delta paints inside one apply transaction.
runApplyTransactionrefuses a seq gap, a rewind, a generation mismatch, or a pane/plot shape check beforecommit. A thrown step skips the rest of the batch and does not paint. The live wire commits the merged script delta through it, so a gap never reachesapplyScriptPush. -
A new candle uses
series.updateunless it is the first bar or the window slides.appendLiveBarused tosetDatathe whole window on every append, on the belief thatupdatecannot add a time. Lightweight Charts 5.2 appends when the time is newer than the tip. The first bar and a rolling-window slide stillsetData. Forming ticks stay onupdate. -
The live proxy forwards a Blob frame as the original bytes.
relayFrameacceptsBlob(Workers compat dates β₯ 2026-03-17 deliver binary frames that way) and both sockets preferbinaryType = 'arraybuffer'when the property exists. Text andArrayBufferframes are unchanged. The compat date stays2024-11-01. -
Subscribe and resume send
encoding: 'axb1'only when the ingest worker owns the socket. That isaxis.engine.transport=workeror HP on. The main-thread JSON client omits the field, so a server that ignores it keeps the old JSON frames, and a server that honors it does not switch a parse-only client onto binary. -
axis.engine.transport=workerdecodes/ws/liveoff the main thread without turning HP on.workeris now the default (see above);jsonis the main-thread rollback.workerreuses the ingest worker for the same JSON frames and does not allocate the hot ring, negotiate AXB1, or start the overlay. A tab that asks for the worker whereWorkeris missing stays on the JSON client and counts onejsonFallback. Pinned bytests/engine/v1-transport.test.ts. -
v1 engine metrics record tickβapply, tickβpaint, long tasks, and pan frames.
src/engine/v1/metrics.tsstampstick:recvbefore main-threadJSON.parseonfull/delta/close,batch:postwhen the ingest worker delivers a decoded batch, andapply:start/apply:endaroundapplyStreamPayload, then one frame for tickβpaint. The first stamp in a burst wins. Seq gaps and gap-driven resumes increment counters; conflation and the visible bar count are gauges onsetData; pan and wheel frame deltas sample while the price pane is ranging.chart.lagreturns the rolling p50/p95/p99 snapshot asv1. Pinned bytests/engine/v1-metrics.test.ts. -
Tick β frame latency is split into pipeline stages (hp2 A1). The Β§14.2 number was one delta β applied tick to next frame β so a bad p95 could hide anywhere between the worker and the glass. The ingest worker now stamps
publishedAton everyframesmessage (dedicated-workerperformance.nowshares the main clock origin), andpaint-latencyrecords per-burst stage marks: worker publish β main apply (publishToApply, covers postMessage + dispatch), apply β hot-ring copy-out, copy-out β overlay draw. Stages that never stamp (JSON path, LWC-only paint) reportnullinstead of fictional zeros, and the existing total is untouched.hp.getexposes the split aspaint.stagesp50s next topaint.latency, so the p95-tail hunt in.opencode/hp2/implementation.mdcan point at a stage instead of the whole pipeline. Pinned by three newpaint-latencystage tests (first-stamp-wins, null-path, burst reset). Controlled live runs (2 Γ 10 s, HP-0 JSON path, MCP idle): 587/593 frames @ ~59 fps, rolling paint p50 ~13 ms / p95 ~16 ms / max ~129 ms (n = 240) β the Β§14.2 tail (p95 β 160 ms, max β 520 ms) does not reproduce under quiet conditions and attributes to observer load during that measurement (heavy MCP diagnosis traffic + screenshots), not the pipeline. HP-on rung runs still pending for the A2 gate. -
The ingest worker reads its own cold history (hp2 B1). The page slot used to paint only what the main thread's chart window happened to hold: a range older than the REST window arrived as a short (or empty) slice and the overlay fell back. The worker now owns a
ColdStoreper series bind β hydrated frombars-cacheIDB on connect via the newcoldKey(sourceId/symbol/interval, posted onconnect), extended by every snapshot (upsertByTime), every verified bar close (appendTickβ forming re-ticks share the tail time and are ignored), and every page slice the main thread sends (the REST slice accumulates, so gaps close over time). Thepagemessage carries the wantedcount, and the worker publishes the merged range β main bars win on overlap, cold fills the rest, truncation at the first uncovered slot instead of an invented bar.ColdStoretracks its absolute base (baseAbs) so an evicted head or a prepended deeper history cannot silently misalign slots; losing absoluteness flipscompleteand the merge stops trusting positions. A symbol switch re-seeds; a hydrate racing the socket merges instead of wiping. Pinned by new write-back/upsert/eviction/merge tests intests/hp/page-buffer.test.ts. -
AXB1 codec fuzz + relay reconnect proof (hp2 E1). The audit found no dead surface:
HPC1_MAGICis the live control-block magic andAXB1_HDR_COMPRESSEDis live as a v1 reject-rule (decode throws, encode refuses) β both are now marked as audited inconstants.ts. Newtests/hp/axb1-fuzz.test.tsthrows 500 seeded-PRNG frames across all 11 opcodes atencode β decode(exacttoEqual, 4-byte alignment asserted) plus 50 single-bit-flip corruptions that must fail loudly withRangeError/TypeError, never silently.worker/tests/live-proxy.test.tsgains the reconnect leg: upstream drop closes the server side, the client redials with a fresh ticket, the JSON resubscribe stays text, and the first binary frame on the new socket is still byte-identical. -
Calc session runs in a worker (hp2 C1).
CalcSessionwas main-thread library code; the HP topology needs it off the Window thread with cold history as its replay source. Newsrc/hp/workers/calc-engine.ts(pure, testable) owns one session per series plus aColdStoreeach: snapshots seed cold and replay from cold once it holds history (full-history replay, not the 500-bar main window β a rebind with no reposted bars replays identically), tips stay scratch, only appends commit.FrameValuesEvaluatorfiles the Pro-API server values by bar time; itssnapshotCommitted/restoreCommittedare an O(1) cursor β the pickle-restore analog for a host with nothing to pickle β andevaluateCallspins the no-rewalk bound (12 bars + 3 tips = 15 calls, not 12 + 3Γ12). Hydrate merges IDB behind live bars instead of wiping them; per-series isolation and error-instead-of-throw replies are covered. Pinned by sixcalc-workertests against the PYNE-recordedbarstate-parityfixture (ta-cum confirmed series identical). The thincalc.worker.tsentry ships with the protocol; the live frame producer that posts to it is the explicit next step, not this commit. -
The GPU overlay paints zoomed-out viewports via pixel-column downsampling. The hot window plus one cold page cover at most
2 Β· HOT_CAPbars, so a fully zoomed-out chart fell back to LWC for most of the pane β and paging the whole series in would break theO(HOT_CAP)memory contract. When the exact hot+page fill cannot cover the viewport's real bars, the overlay now downsamples the chart window LWC paints (downsampleToPixelColumnsinsrc/hp/paint/): one bin per pixel column, first-open / column-extremes / last-close / summed volume, bin centers in fractional viewport slots that the renderer positions natively. It reads the exact array LWC paints, so parity is by construction and it needs no SAB traffic; the exact fill stays the path wherever the viewport is coverable, and the forming tip keeps its flag through the bin. Pinned by binning-semantics, fractional-center, hole-skipping, and viewport-clip tests (tests/hp/downsample.test.ts). -
Cold paging is wired: the GPU overlay paints past the hot ring. The overlay used to clear whenever the viewport started left of the hot window, because the page slot existed only as library code nothing populated. Now the overlay reports its absolute viewport every frame (
noteHpViewportinsrc/streams/stream-live.ts, throttled to 200 ms), the main thread slices the cold range out of the chart window it already owns, and the ingest worker β still the SAB's single writer, so a page replace can never tear a concurrent hot publish β replaces the page slot via the newpagemessage (publishBarPage, converted with the buffer's own price scale). A re-seed or reconnect retires the page (its absolute base is snapshot-relative), and the overlay fills the merged hot+page range in one seqlock sample (fillVisualAttrFromViewport), stopping at the first uncovered bar so LWC keeps painting underneath. Chart index β absolute mapping lives in one place (chartRangeToAbsolute) so the pager, the page slot, and the fill cannot disagree.hp.getreports the page (page.active/from/count). Pinned bychartRangeToAbsolute,publishBarPage,fillVisualAttrFromViewport, and hostpage-message tests. The worker-sideColdStore/IDB adapter stays library-only (future calc-reader territory). The@gpue2e re-asserts the shrunken-ring step: the cold viewport must repaint without zooming (downsample, then the paged exact fill) β the old clear assertion encoded the pre-paging contract. -
The HP engine is configured in Settings, not the console. Every
axis.engine.hp.*flag used to requirelocalStoragein DevTools, which made an opt-in plane look like a developer hidden switch. Settings β General gains a High-performance engine section: the master toggle (off keeps the JSON/ws/liveβ Lightweight Charts path byte-identical), the GPU painter (auto/webgl2/webgpu/off) and the SAB transport (auto/off), each writing the same tab-local keys so automation still works. The section renders the resultinghpChip()verdict inline (json Β· lwc,hp Β· sab+gpu,Kill switch, β¦) and, once something changed, the Reload to apply button the flags need β the plane is read at boot, so a silent write would look like a no-op. The architecture page and the README now point at the section instead of DevTools, and an e2e smoke asserts the toggle, the select write-through and the reload affordance (e2e/smoke.spec.ts). -
Tick β frame latency is measured (the plan Β§14.2 gate).
src/chart/paint-latency.tsstamps the first applied live tick of each frame and records the delta to that frame's timestamp β one rAF callback per burst, no steady loop, no timers, and the recording is a no-op for rejected ticks.appendLiveBarcalls it, and bothhp.get(paint.latency) andhp.sample(paint) expose p50 / p95 / max over the last 240 samples, so the default-flip decision can be argued with production numbers instead of a stopwatch.hp.sample's ring progress is nowclosedUptoTimeDeltaβ the engine'sclosed_uptois a time stamp, not an index, and the old name invited that misreading. -
Chart screenshots include the HP GPU overlay.
chart.screenshot(MCP) and the Screenshot menu composed LWC's pane canvases, drawings and the watermark β but never the HP-3 WebGL candle canvas, which is a sibling element, so a captured image silently differed from what the trader sees.capturePaneCanvasnow composites the overlay under the drawings (annotations stay on top) viaoverlayPlacement, which maps the overlay's CSS rect into the screenshot's pixel space (shot.width / pane.width, so 1Γ and 2Γ captures both line up). A WebGL drawing buffer is discarded at composite time, so the copy runs in the next animation frame β the overlay's own rAF tick was registered earlier, which is what keeps the buffer intact withoutpreserveDrawingBuffer. Verified by sampling 1 948 candle pixels: the live composite reads (174,92,94), the app screenshot (184,96,98), LWC alone (229,119,122) β the screenshot follows the composite. -
High-performance chart engine implementation spec on branch
engine/high-performance(.opencode/hp/implementation.md). It pins generation-based double-buffering, ingest/calc/render workers, the AXB1 4-byte-aligned binary delta, viewport-only SAB paging, int64 price ticks (float32 only after NDC), worker-onlyAtomics.wait, tip-vs-append Pinebarstatecontext, and a HP-0β¦HP-4 fallback matrix on top of the existing JSON/ws/liveplane. -
AXB1 codec + JSON execution context (HP PR 1). Canonical prices are int64 ticks (
decimalToTicks/priceToTicks); the encoder/decoder round-trips the 32-byte little-endian envelope and the TIP/APPEND/SNAPSHOT opcodes. Live JSON deltas withforming: truestampexecutionContext: 'tip'on the merged payload so a later calc worker will not commitbarstate.isconfirmedaccumulators untilAPPEND_BAR. -
Cross-origin isolation headers (HP PR 2). Every static host (Pages
_headers, nginx, Vite dev/preview, Bun and Python PWA servers) and the SW offline shell send one locked-stepCOOP/COEP: credentialless/CORP: same-origintrio β COOP landed popup-safe and later flipped tosame-origin(see Changed), so SharedArrayBuffer is now reachable without a second header pass.isolationProbe/selectHpRungstay HP-0 untilaxis.engine.hp.enabledand report SAB only whencrossOriginIsolatedis true. The kill switch isaxis.engine.hp.disabled. -
Ingest worker:
/ws/livedecode off the Window thread (HP PR 3).createLiveWsClientreturns anIngestHostClientonly whenaxis.engine.hp.enabled === '1'and a Worker exists; otherwise it returns the originalRedesign2WsClient, so the flag-off path is byte-for-byte today's client. The worker owns the socket (binaryType: 'arraybuffer'), decodes JSON and AXB1 through the shareddecodeInbound, holds forming tips for a 16 ms vsync coalesce whileclose/APPEND_BARflush immediately, and posts typed frames over aHostToIngest/IngestToHostport β the auth token rides once onconnectand is never echoed back. The host keeps theWireClientcontract (onFrame,awaitFrame, reconnect handlers) soStreamLiveWireis unchanged, and the Workers Manager gains anhp-ingestcard that probes asnone(there is nothing to HTTP-probe in a tab). Tests:tests/hp/ingest-core.test.ts(JSON + AXB1 fixtures through the pure decoder) andtests/hp/ingest-host.test.ts(loopback port: connect, frame delivery, exact subscribe serialization, reconnect, error mapping). -
Hot double-buffer + main-rAF copy-out (HP PR 4). One SharedArrayBuffer is sectioned into a 256-byte control block (the seqlock words), a series header (price scale + epoch), two reserved tip records, and the hot window itself:
HOT_CAP= 5_120 bars Γ 2 slots of int64-tick columns (~450 KiB β O(hot window), not O(N), so a mobile WebView never has to allocate 200kΓ2). The single writer (ingest) fills the unpublished slot and flips the generation from odd to even; readers sample only even generations, and a copy is re-verified by a second generation load β a torn sample is discarded and retried, and after the retry budgetcopyOutBarsreturnsnullso the paint path keeps the previous frame instead of showing a mixed candle.Atomics.waitis worker-only:waitInWorkerthrows the momentglobalThis.Window/selfsays Window thread, which the main thread replaces withsubscribeGeneration'srequestAnimationFramepoll. ARangeErrorfrom the allocator drops the tab to rung 1 for the session (axis.hp.rung = '1') instead of retrying the OOM on reload.copyOutBarsclips the visible range to the hot window (cold pages are HP PR 5) and produces the float64Bar[]Lightweight Charts paints; the tests feed those bars throughappendLiveBarand assert all four store verdicts (ignored/updated/appended/slid) plus the torn-write, main-thread-guard and 5_120Γ2 allocation cases (tests/hp/{hot-buffer,atomics-main-guard,copy-out-lwc}.test.ts). -
Viewport paging + columnar cold store (HP PR 5). The SAB gains one single-buffered page section (
PageSlot[HOT_CAP]) after the hot double-buffer, so panning into cold history copies one page of bars β a far-left jump replaces that page instead of allocating a third history buffer, and "fit content" over a range that still overlaps hot is acked without pulling 100k bars into shared memory (Β§9.2). The allocation is a fixed 691,968 bytes (~676 KiB): O(HOT_CAP) and provably independent of series length, because a 200k-bar history allocates the same bytes as a 1k one. A page replace runs its own seqlock transaction (beginPageWrite/commitPageWrite) that never moves the hot published slot, so the hot window and the forming tip keep updating while the page is on screen. Readers getcopyOutPageand thecopyOutViewportdispatcher besidecopyOutBars, all three sharing one seqlock-verified clipped copy.ColdStorekeeps the worker's full series as columnar int64 ticks β a page copy is a straight column read, never a re-encode β hydrates from the existingbars-cachememory-first, and on rung 1 (copyOutPageFromCold, no SAB at all) the viewport is still served straight from the cold store (tests/hp/page-buffer.test.ts). -
Calc scratch-vs-commit state machine (HP PR 6). The calc worker's core rule, made testable without a Worker or a Pine runtime: a forming tip is a scratch evaluation and a bar close is a commit.
barstate.tspins the Β§10.1 contexts β history replay is new + confirmed + never realtime (isfirstonly at index 0,islastonly on the rightmost bar), the snapshot's forming tip and every incremental tip are never confirmed sota.cum/ta.valuewhen/varaccumulators stay scratch,isnewis true only on the first tip after an append, andAPPEND_BARcloses the bar with a confirmed + realtime eval that folds the accumulators before the next slot opens.CalcSession(calc-core.ts) drives anyCalcEvaluatorβ PYNE in a worker, Pyodide, or a test double β throughonSnapshot/onTipUpdate/onAppendBar: each tip restores the committed snapshot before evaluating, so ticks cannot accumulate into committed state, and only the append path grows the committed plot series. This split is not invented: PYNE's own multi-tick realtime window setsisconfirmed = tick_i == n_ticks - 1(forming ticks unconfirmed, closing tick commits), which is exactly this table. Parity is pinned by fixtures recorded from PYNE (scripts/record-hp-fixtures.pyβtests/hp/fixtures/barstate-parity.json, pynescript 0.6.18, re-recordable after a PYNE release): the tests drive the session through wild forming ticks and assert the committed series still matches the recorded closed-runta.cum/ta.valuewhen/varoutputs bar for bar, that three 200β150β125 ticks leave committed untouched, and that the recordedbarstateflag columns match the replay contexts (tests/hp/barstate-tip.test.ts). -
AXB1 golden vectors now pin every payload shape (HP PR 7 prep).
tests/hp/axb1-vectors.jsongrows from a lone heartbeat to 12 vectors β heartbeat, unchanged, status, tip (close+volume+time and full OHLCV), append-bar, close-verify, plot-tip (int64 ticks + float64), a 2-bar OHLCV snapshot, a plots snapshot, error, and a JSON blob. Each vector carries the language-neutral frame spec (int64/uint64 as decimal strings, masks and value kinds as names) and the exact little-endian hex the encoder must emit, plushash_checkspinningfnv1a32on shared inputs. This is the cross-repo contract: PYNE'stests/test_axb1.pybuilds the same frames from the same spec with its own encoder and must produce identical bytes, so a wire-format drift now fails both repos' test suites instead of silently corrupting live frames.bun scripts/generate-axb1-vectors.tsregenerates the hex from the specs through the real AXIS encoder, and the codec test loops every vector through spec β encode β golden hex β decode β re-encode (tests/hp/axb1-codec.test.ts). -
The live relay no longer corrupts binary frames (HP PR 7b).
worker/src/live-proxy.tsdecoded every non-text WebSocket frame withTextDecoderbefore relaying it β fine for JSON, fatal for AXB1: any byte that is not valid UTF-8 would be mangled before reaching the client. The relay now hands payloads through untouched (relayFrame: text stays text,ArrayBufferand views stay bytes, in both directions), which is exactly what a transparent proxy means once the engine can speak binary. The test drives the realhandleLiveProxyupgrade path with a fakedWebSocketPairand upstream, and asserts an encoded AXB1TIP_UPDATEcrosses the relay byte-for-byte in both directions while JSON control frames stay text (worker/tests/live-proxy.test.ts). -
GPU paint plane: NDC staging + instanced WebGL2 renderer (HP PR 8).
hp/paint/visual-attr.tsturns visible candles into the plan's 32-byteVisualCandlerecord (Β§6.5):x / open / high / low / close / volumeas float32 and nothing else β prices are mapped to NDC in float64 against the caller's visible y-range and narrowed once at the store, so neither float64 nor int64 ever crosses the GPU boundary (WebGPU has no float64) and canonical ticks stay in the SAB.fillVisualAttrFromHotmirrors the copy-out's seqlock discipline (sample only even generations, re-verify after the copy,null= keep the previous frame) over avisible + 64capacity, withvolume_normnormalized against the visible maximum.hp/render/adds the painter seam:probeRendererKindpins the plan's probe order WebGPU β WebGL2 β LWC as a literal 4Γ4 truth table whereoffwins over capability and a forced mode never claims a backend the probe did not find, andcreateWebgl2Rendererdraws the whole viewport as one instanced call β one VAO, an 8-word-per-record divisor-1 attribute layout read straight out of the staging bytes, and a fragment shader that carves the body box plus a centered 1/8-slot wick out of a single quad per candle (packed0xAABBGGRRunpacked with core ES 3.00 bit ops, since GLSL ES 3.00 has nounpackUint4x8; alpha 194/255 blending over the transparent-clear, stacked LWC chrome). The factory is null-never-throws, so any context/shader failure falls through to LWC instead of failing the page. Tests pin the struct stride and byte order, the NDC math, the hot-fill contracts, the full probe table and the factory's failure surface (tests/hp/{visual-attr,select-renderer,webgl2-renderer}.test.ts). Live wiring is deliberately deferred: no producer writesvisual_generationyet and plan Β§14.2 forbids gutting PaneManager before GPU parity tests exist, so β like PR 4β7 β this lands as library + tests, and the e2e "candles visible" smoke waits for a producer. -
HP visibility: architecture page, agent playbook, and rung chip (HP PR 9).
docs/architecture/hp-engine.mdxdocuments the opt-in plane end to end β the HP-0β¦HP-4 rung matrix and selection algorithm, the flag table with the kill-switch rule, the COOP/COEP reality β the isolating pair (same-origin+credentialless; COOP flipped from the popup-safe value, see Changed) β while HP-3/4 still run on the copy transport until a producer wires the SAB hot ring, the AXB1 transport and its cross-repo golden vectors, the SABO(HOT_CAP)memory contract, the GPU paint seam, and a landed-vs-library-only status table β registered in the Architecture nav and track map.okf/playbooks/hp-engine.md(curated,okf_lock: human) gives agents the same map as a playbook: which hp submodule owns what, the contracts that must not break (seqlock discipline, 8-word/32-byte record stride, float32 only after NDC, GLSL ES 3.00 bit unpack, the right-edge NDC anchor, flag-off byte-identical rollback), and the reminder that rung selection β wiring. The Workers Manager gains a Chart plane card carrying the plan's HUD chip:hpChip()(src/hp/hud.ts) reports the selected rung in plan format βhp Β· sab+gpu/hp Β· copy+lwc/json Β· lwc(transport memory + painter) β where default-off honestly readsjson Β· lwcbecause that is exactly today's path; the kill switch shows asKill switchand engaged tabs show the rung word (HP-3). Pinned across the full flag Γ capability matrix including the session OOM clamp (tests/hp/hud.test.ts). The feed-chip surface and MCPstream.get'shpfield stay unwired. -
A working OpenCode project configuration. The project
opencode.jsondeclared a Prettier formatter whose command never passed a file, so every write ran a no-op formatter and then fell through to Biome β which is lint-only here (biome.jsonsetsformatter.enabled: false) and exited non-zero, so no file was ever formatted. The command now ends with$FILE, which OpenCode replaces with the absolute path, and the formatter is scoped to the code extensions the repo declares as Prettier-managed (.ts,.tsx,.js,.jsx,.mjs,.cjs,.json,.jsonc,.css); Markdown/MDX and YAML are excluded because Prettier rewrites the#-block license headers into headings and flips the compose files to single quotes. Biome,rustfmt,ruffanduvare disabled as formatters: Biome is the linter,src-tauri/src/*.rsis not rustfmt-clean (the repo uses a 2-space Rust style), and the Python helpers have no formatter config. The same file keepslsp: true, adds the OKF bundle as project instructions, ignores the heavy build directories in the file watcher, guardsgit push/git tag/ deploy /rm -rfbehind an approval prompt, enables thetodotool, and adds the/commit,/verifyand/releaseslash commands plus a read-onlyaxis-reviewersubagent. Tworeferencespoint OpenCode at the sister checkouts βpyneat/home/jango/Git/pynescript(the checkout is still namedpynescript, notpyne) andhooxat/home/jango/Git/hooxβ so Pine Scriptβ’ parity questions can be checked against the real PYNE builtin signatures without scanning the tree. Automatic compaction is enabled (auto, 15 000 retained tokens, 20 000 buffer) so long/verifyand/releaseruns stop walking into the context ceiling. Four more shell rules joingit push/git tagbehind the approval prompt:git reset --hard*,git clean -f*,git checkout --*andwrangler secret*β the destructive git forms that could discard work, and the command that mutates live Worker secrets. -
One git backend instead of two near-identical files. GitHub and GitLab were separate modules with twelve functions of the same body, and the dispatch picked between them with nine ternaries. The provider-specific parts β URL shape, auth header, content encoding, update token, conflict status β now live behind a
GitForge, and the shared operations live in one place. The per-provider differences stay explicit: GitHub identifies a blob by its sha and answers409on a conflict, GitLab only needs to know whether the file exists and may answer400/409/422. -
Engines declare what they are instead of being guessed from their id. Seventy-one places branched on the engine id string β
id === 'server',id.includes('pyne-worker')β to decide how the engine behaves: which credential slot it uses, whether the remote LSP is reachable, which transport the HUD reports, whether the run can be re-applied. An engine now declares a family (server/pyodide/worker/dynamic), and the behaviour branches on that. The id stays the user-facing name and the selection key, and the credential vault still resolves a family exactly, so an unknown engine id is never routed to the shared server or worker slot by accident. -
One event-listener primitive instead of the same four lines everywhere. Dozens of components repeated
onMount(() => target.addEventListener(...))/onCleanup(() => target.removeEventListener(...))β the same shape, easy to get subtly wrong (acaptureflag on one side only, a listener that outlives its component).attachEventListenerattaches and returns the disposer, so the two halves cannot drift apart, anduseEventListenerwires it to the component lifetime. -
One modal shell instead of six copies of the dialog chrome. The About, Symbol, Script settings, Dataset manager, Keyboard shortcuts and Storage change dialogs each carried their own backdrop, accent, header (title, hint, close), body and footer, plus their own focus trap, Escape handling and backdrop dismiss. They now render through one shared
Modal, which owns the chrome, the focus trap and the Escape listener β the listener exists only while the dialog is open. The Keyboard shortcuts dialog declaredaria-modal="true"and had no focus trap; it now has one, like the other five. -
The shortcuts dialog's close button now uses the shared icon. It was the only dialog drawing a literal
βinstead of the LucideXthe other five use. -
One run-result envelope instead of three. The engine result arrived over the REST path, the WebSocket path and the batch path, and each built the same envelope with its own copy of the body β plus a byte-identical copy of the bar-time-axis extractor. One mapper builds it now. The differences between the paths are stated per call site instead of hidden inside three copies: the WS path resolves the run mode server-side, the REST path infers
script_typefrom the payload kind, and the batch path only accepts a real booleanoverlay. -
One Worker route table instead of four copies of the route list. The route knowledge lived in the entry dispatcher's
switch, its ad-hocif/startsWithguards, the MCP proxy's own dispatcher, and the MCP allowlist's method+path pairs β and the four drifted. One table declares the routes now; the entry dispatcher and the MCP proxy both read it. The allowlist stays the authority on whether a path may be reached over MCP (it is a security boundary with its own script-id and version-path rules), and a test asserts the table and the allowlist agree. -
One popover shell instead of four copies of the menu skeleton. The chart-layout, screenshot, extra-widgets and run-split menus each carried an open flag, an anchor element, a measure-and-clamp placement, a resize/scroll reposition, an outside-click dismiss and an Escape handler. One
Popoverowns that skeleton now. Two of them had drifted: the run-split menu dismissed onmousedownwhere the others usedpointerdown, and it hand-rolled the outside check the shared helper already provided β it now dismisses on the same event as the others. -
A network failure carries its status code. The same three steps β read the body, decide what a non-2xx means, give the failure a status β were written into every network call, and the status was usually flattened into the error text, so a caller could not tell a 404 from a 429 without parsing a string.
src/api/client.tsowns those three steps now, and the cloud storage client and the venue fetchers carry the status on the error. The engine plane, the probe plane, the git adapters and the editor LSP client keep their own contracts on purpose β a failed engine request is a verdict, not an exception β and the module documents which is which. -
HP-2 producer wiring: ingest writes the hot SAB ring, the chart samples it on rAF. The SAB plane stops being library-only. When the tab is opted in and cross-origin isolated,
IngestHostClientallocates the hot ring once and rides it onconnect(hotSab); the ingest worker wraps the same buffer, confirms with ahotreply, and becomes the ring's single writer (hp/workers/hot-ring-writer.ts). The first series snapshot binds the ring (a snapshot for another id rebinds with an epoch bump, so a symbol switch never reuses ticks); a bound seriesdeltais written into the ring and stripped from the posted frame βseq,closed_uptoand the positional block still crosspostMessage, soapplySeriesPush's no-bar branch keeps resume/repair sequencing intact β while snapshots keep their payload (initial paint) and script deltas, foreign series and stale-only deltas pass through untouched: the frame path is always a correct fallback. Every flip republishes the whole window throughpublish(an unpublished slot must be fully rewritten or readers see stale zeros outside the touched indices); a forming tip overwrites the newest entry in place, and a full window slides one step with a whole-window rewrite per closed candle. Main-thread paint isstartHotRingPaint(src/chart/live-append.ts): asubscribeGenerationrAF pump samples the published generation viacopyOutTailand feeds the contiguous tail run that continues the store tail throughappendLiveBarβ the same store verdicts and paint invariants as the frame path (a forming tick never reopens a closed tip; a jumped ring β suspended tab, resume replay β is left to the snapshot/repair path).stream-live.tsstarts and stops the sampler with the wire lifecycle. Flag-off, kill switch, non-isolated tabs and a refused SAB allocation (the existing rung-1 OOM clamp) all keep today's JSON/transferable frame path byte-identical. New tests:tests/hp/hot-ring-writer.test.ts(bind/strip/slide/rebind/passthrough routing),tests/hp/hot-ring-paint.test.ts(sampler verdicts incl. the non-contiguous guard and disposer),tests/hp/ingest-host-sab.test.ts(the isolation Γ flag transport gate). -
HP-3 producer wiring: the WebGL2 candle overlay paints over the price pane (parity mode). The GPU paint plane gets its producer.
src/hp/render/gpu-overlay.tsstacks a transparent WebGL2 canvas over LWC's price-pane content area and draws the same candles LWC is painting β aligned by construction against LWC's own geometry (visible logical range + right-scale coordinates) β with LWC staying the painter and the full fallback; hiding LWC's candle series remains the plan-gated next switch (Β§14.2). Data is the HP-2 ingest hot SAB ring:stream-live.tsexposes the ring the wire allocated (getLiveHotBuffer), and ChartHost starts the overlay per active slot after the initial paint, stopping it in the main cleanup. The frame path reuses the PR-8 renderer (fillVisualAttrFromHotβ one instanced draw) with a new pureoverlayViewporthelper (fractional logical anchor, right-edge index, theMAX_OVERLAY_BARSsanity cap) and apriceScaleLinearprobe that refuses non-linear (log/percentage/indexed) right scales; every bail β no ring, ring/chart length mismatch, cold left edge, non-linear scale, torn seqlock sample β clears the transparent canvas, so a skipped frame shows LWC underneath and never a stale GL layer. The overlay engages only whenaxis.engine.hp.gpuis forced towebgl2: LWC paints candle bodies translucent (0.76) and stacking identical GL bodies composites them darker, soauto/off/webgpustay dormant until the hide-LWC-candles switch is measured. Tests:tests/hp/gpu-parity.test.tspins feed parity againstcopyOutBars(bit-exact NDC + reconstruction), hot-fill bit parity, capacity-drop parity,overlayViewportanchor identity vs LWC, the explicit-gate matrix (auto dormant even when probeable; forced+probeable engages once, disposes safely) andparseCssColorToRgbon LWC's premultiplied palette. The e2e smoke (e2e/gpu.spec.ts,@gpu) drives the full product chain β empty chart, offline mock-poll stream, scripted/ws/livefull snapshot + per-slot rollover re-seeds against a local WS server (Playwright's WS routing does not intercept the ingest worker's socket) β and asserts the overlay canvas turns visible and its framebuffer carries alpha-nonzero GL pixels. -
MCP observability for the HP plane:
hp.get+hp.sample. The HP flags live in tab-locallocalStorageand isolation is a runtime property β neither crossed the sanitized app snapshot, so an agent asking "is HP actually working in this tab?" got nothing.src/mcp/hp.tsadds two read-only capabilities:hp.getreports the flags (enable / kill switch /gpu/sab), the cross-origin isolation probe, the HUD chip verbatim, the session rung cap after a SAB OOM (axis.hp.rung), the actual hot-ring runtime (window bounds,seq/closedUpto, SAB bytes,tailTimevschartTailTime, and the tail-identity alignment the GPU overlay guard enforces), the wire state, the overlay canvas's DOM state, and a verdict βkill/off/awaiting/copy/fallback/degraded/misaligned/live;hp.samplemeasures a bounded window (100 msβ10 s, default 1 s) of movement: ringseqdelta (ingest publishes landing),closedUptoprogress, rAF frame cadence, and the chart's append/tail deltas. Pure reads β no flag writes, no wire start, no SAB allocation, no secrets. Tests:tests/mcp-dispatch.test.tspins registration as read-only, the default-off / kill-switch verdicts, the engaged-rung-without-wire wording, and the sample envelope incl. the window clamp.
Changed
-
The last hand-rolled LRU trims use
BoundedMap. The session run-mode memo, the session retry cooldown map and the dataset memory mirror each reimplemented delete + re-insert + evict-oldest; they now useBoundedMap.set, which owns that sequence (and, unlike the dataset store's old loop, needs no special-case to protect the just-written key β a fresh write is always newest, so the trim can never evict it).BoundedMapalso gains[Symbol.iterator]so the dataset mirror's existingfor..ofloops keep working. Pinned by the dataset, live-sessions and data-sessions suites. -
The cloud client uses the shared JSON fetch instead of its own.
storage/cloud's privateapi()reimplementedfetchJsonline for line (fetch +readJsonBody+HttpError, same{status, json}shape, same 30 s timeout). It now delegates, which givesfetchJsonits first production caller β the helper is no longer a tested-but-unused abstraction. No behavior change: same return shape, same timeout, same error contract. -
One flag pair instead of two state machines.
engine/feature-flagandhp/feature-flagwere the same module with different keys: an enabled key, a disabled key, in-memory fallbacks, and a kill-switch-wins rule written out twice. They now sharecreateFlagPairfromsrc/utils/flags.ts; the plane modules keep their key constants and public function names, the GPU/SAB/rung handling stays HP-specific, and the kill-switch-wins rule exists once. Side effect, now matching both modules' doc comments: the stream-engineisEnabledalso respects an explicit opt-out (previously only HP's did). Pinned by thehp/feature-flag+engine/feature-flagsuites. -
One storage accessor instead of eight helpers and thirty guards. Four modules each owned an
lsGet/lsSet/lsRemovetriple and ~30 call sites hand-rolledtypeof localStorageguards, differing only in whether a failed write returnedfalseorvoid. They now sharesrc/utils/storage.ts(getItem/setItem/removeItem/getJson/setJson/getFlag/setFlag), which never throws β the boolean write return is kept because the alerts persist path reports quota failures. The memory fallbacks in both feature-flag modules and the quota-reportingtrySetItemin the store stay exactly as they were. Pinned by the existing storage, alert, credential, vault, catalog, watchlist, plugin, optimize and editor suites (234 cases). -
Nine listener sets share one emitter instead of hand-rolled loops.
bar-replay, both alert modules, the MCP bridge, the dataset store, the secret vault, the credentials store, the editor bridge and the open-script source each kept aSetof listeners with its own subscribe/unsubscribe pair and notify loop β and two of them (editor-bridgedispatch,open-script-sourceemit) had no error isolation, so one throwing subscriber broke delivery for the rest. They now sharecreateEmitterfromsrc/utils/emitter.ts(snapshot iteration, per-listener fault isolation); the emitter gains aclear()for the dataset store's test reset. All public subscribe functions keep their names and signatures. Pinned by the existingbar-replay,editor-bridge,dataset-store,credentials, alert and MCP-bridge suites (236 cases). -
One public-REST definition per venue instead of two.
symbol-catalog(instruments) andwatchlist-tickers(24h tickers) each implemented OKX / Bybit / Coinbase / Kraken β same hosts, sameHttpErrorcontract, sameXBT β BTCnormalization β and had already drifted: the catalog sentcache: 'no-store'where the tickers did not. Each venue now owns its public fetchers (fetchOkxInstruments/fetchOkxTickers, β¦) insrc/data/venues/, sharing onevenueFetch(alwaysno-store, throwsHttpError) and one symbol spelling (toUsdt,okxInst,coinbaseProduct, Kraken legacy-key handling) invenues/spot.ts. The catalog and the Watchlist are thin mappers over those;toUsdt/okxInst/coinbaseProductstay re-exported fromwatchlist-tickersso the WS layer's imports are unchanged. Side-effect fix, not a behavior change: the Coinbase ticker path strippedUSDT/USDbut notUSDC, so aβ¦USDCsymbol built a broken product id β it now shares the exported helper. Pinned by the existingsymbol-catalogandwatchlist-tickerssuites (42 cases). -
One wire-bookkeeping core for both live socket clients.
Redesign2WsClient(direct WebSocket) andIngestHostClient(HP ingest worker) each carried a verbatim copy of the request/response waiter map, the reconnect-handler set with its swallow-per-listener notify loop,awaitFrame,dropPending, and thestats()snapshot β a fix to waiter cleanup or teardown in one never reached the other. They now sharePendingRequests<TFrame>(id-keyed waiters, timeout reject,dropAll) andReconnectHandlers(snapshot iteration, fault isolation) insrc/engine/wire-bookkeeping.ts, plus awireStats()snapshot helper. Public behavior is unchanged (addReconnectHandler,awaitFrame,stats()shapes identical; the one white-box test that read the private handler set now reads the shared core). Pinned by thews-client,ws-send,ingest-hostandingest-host-sabsuites plus themcp-live/engines-live-sessions/data-sessionssuites (124 cases). -
The Worker answers every route with the same error envelope. The entry
fetchopened itstry/catchafter the MCP, MCP-bridge and stream paths, so an exception in any of them escaped the Worker as an unhandled rejection instead of a500. The dispatch now runs inside onetry, and a sharedhandleErrorhelper logs the failure server-side and returns the canonical{ status: 'error', code: 'INTERNAL' }envelope. The error text is still never reflected to the caller. -
One rate limiter instead of two. The MCP plane kept a verbatim fork of the fixed-window limiter, with its own bucket map. Both now share
worker/src/rate-limit.ts. The sharedallowRatetakes an optional call weight so a JSON-RPC batch is still charged per method β and a batch that does not fit the remaining budget is rejected without being counted, so it cannot eat into the next caller's allowance. -
One abort/timeout combiner instead of eight. Six modules each had their own
requestSignal/withBudget/ hand-rolled budget block, and four of them silently dropped the caller's abort signal on runtimes withoutAbortSignal.any. They now sharesrc/utils/fetch-timeout.ts, whose fallback keeps the parent signal honoured. The one place that deliberately lets the caller's signal win β the venue page walk, which owns its own budget β uses a separate, explicitly named helper so the difference is intentional rather than accidental. -
One error helper instead of four. Three modules carried their own
errMessagecopy and four carried their own abort-detection heuristic. They now sharesrc/utils/errors.ts, which separates the two questions the old copies conflated:isAbortErrordecides whether to rethrow (name-based only, so a network error that merely mentions a timeout is still a network error), whileisAbortLike/isTimeoutLikedecide how to word the message. -
One JSON builder per CORS policy, and one error envelope across the Worker. Eleven near-identical JSON wrappers were spread across the Worker (
apiJson, threejsoncopies,corsJson,proxyJson, twojsonErrors), and forty-five hand-written{ status: 'error', code, message }objects bypassed the canonical envelope.http.tsnow exportsscopedJson(cors)andplainJson: a route binds its CORS policy once and cannot answer with another route's headers by accident, and the inline envelopes go througherrorBody()so the error shape has exactly one definition. Two name collisions are gone βmcp/protocol.tsjsonError(an MCP tool result, not an HTTP response) is nowtoolError, andonchain.ts's localproxyJsonno longer shadowsproxy-router.ts's export β andproxy-router.tsno longer uses a reversed argument order. The session Durable Object answered 404/426 with a plain-text body; it now answers with the JSON error envelope like every other route. Header values and status codes on every existing route are unchanged. -
One time normalizer instead of five. The venue parser, the plot-time helper, the engine-time helper and the two on-chain helpers each implemented the same ms-to-seconds rule, and each rejected a slightly different set of values: a plot time of
0was accepted by one helper and dropped by another, and a time past year 2100 was accepted by some and rejected by others. They now share one function, so a time the venue accepts cannot be rejected by the engine and vice versa. The venue parser keeps its ISO date-string support, which is the one shape the other planes never see. -
The cached-datasets modal has one state, not three. The Topbar, the StatusBar and the Data Source Manager panel each owned a
datasetsOpensignal for the same modal, so the copies could drift: opening it from one surface left the other two showing their own closed state. They now share one module-scope signal, like the mobile-sheet state. -
One labelled-field component instead of two. The Data Source Manager and the Script Library each had their own
sc-fieldwrapper, byte for byte identical. They now shareScField. -
The cloud script list is newest-first, like the other backends. The Worker returned the scripts in whatever order the row scan produced, so the Script Library listed them differently depending on the backend.
-
A backend without version history no longer throws a special error. The service probed each backend for optional methods and then threw
VERSIONING_UNAVAILABLEat the call site. The backends now declare what they can do, the service reads that declaration, and an unavailable history is an empty list (andnullfor a single revision) instead of a runtime error. -
COOP flipped to
same-originβ cross-origin isolation is on. Chromium setscrossOriginIsolated(and unlocks SharedArrayBuffer) only for COOPsame-origin; the previously shippedsame-origin-allow-popupspair could never isolate, so every HP rung above the transferable copy was unreachable in a real browser. No AXIS surface depends on a popup opener β the GitHub/GitLab device-flow popup is openednoopener,noreferrerand the token arrives by Worker polling (waitForDeviceToken), never viawindow.opener; the detachable editor and panel companion windows are same-origin (BroadcastChannel / direct DOM wiring) and stay in the same browsing-context group undersame-origin. The flip movesCOOP_VALUEplus every static host in lockstep (public/_headers, the SW offline shell,axis_pwa_server.py, both nginx configs; Vite/Bun servers import the map) and re-pins the three header tests. The capability probe stays the runtime source of truth: a tab that fails isolation keeps the copy path (hp Β· copy+gpu/ HP-1), and the GitHub login is worth one manual pass on a deployed host.
Fixed
- WebGPU is reported as what it is: selected, but with no painter.
autoon a WebGPU-capable machine selected rung HP-4 and the chip readhp Β· sab+gpuβ while no WebGPU renderer exists anywhere in the tree, so LWC painted and nothing said so. The chip now readshp Β· β¦+lwcwith ano painter yet β HP-4 reservednote whenever the probe outcome iswebgpu(the rung and the selected renderer are still reported, only the painter word follows what actually paints),hp.get's dormant-overlay verdict says the same, and the Settings GPU select labels the option(no painter yet β rung only). No behavior changed β this only stops the UI from claiming a backend that does not exist. - The GPU overlay draws LWC's candle widths, not a look-alike fraction. Even with the anchor fixed, the overlay's body was
0.75 Β· barSpacingand its wick0.125 Β· barSpacing, while Lightweight Charts computes the body withoptimalCandlestickWidth(barSpacing, pixelRatio)(a spacing-dependent coefficient, a flat 3-device-pixel special case, and device rounding) and draws a flat one-CSS-pixel wick β leaving a visible fringe on every candle edge.src/hp/render/lwc-candle-width.tsmirrors LWC's formula verbatim; the overlay derives the pixel ratio from LWC's own pane canvas (notdevicePixelRatio: LWC's canvas can be 1Γ on a 2Γ display) and passes CSS widths through the viewport, which the shader consumes as NDC half-width uniforms. Measured on the rendered chart: body 5 CSS px on both painters, wick 1 px on both (direct device-row runs, not column bucketing). The vertical mapping now also usescoordinateToPrice(h)so the GL viewport's bottom edge is LWC's price ath, noth β 1. - The GPU overlay's anchor now reproduces LWC's own
indexToCoordinateβ no more doubled wicks. The overlay derivedbarSpacingaswidth / (to β from)and anchored records atwidth β (to β i)Β·spacing, but LWC's visible range spanswidth / barSpacing β 1(half a slot inset on each side), its bar x iswidth β (to β i + 0.5)Β·barSpacing β 1, and the pane content box is the pane canvas (554 px here, not the 555 apaneRect β timeScaleestimate produced). The result was a ~5 px shear across the pane β near-zero mid-chart, a couple of pixels at the edges β which read as "slightly wrong" overlaid candles.overlayViewportnow recovers the real spacing from the range, folds the half-slot phase and 1 px inset into the right-edge anchor, andpaneContentSizetakes the geometry from the pane's own canvas.tests/hp/gpu-parity.test.tspins the identity against LWC's formula to 1e-9 and the on-screen check (column-profile correlation + crops) went from 0.55 to 0.84 with the wick/body doubling gone. - The HP-2 hot ring now allocates when the wire client is constructed, so the sampler and the GPU overlay actually engage.
IngestHostClientallocated the hot SAB insideconnect(), butbuildWirereadsrawClient.hotBuffersynchronously right aftercreateLiveWsClientβ before anyconnect()β so the read was alwaysnull:startHotRingPaintnever started,getLiveHotBuffernever exposed the ring, and both the HP-2 rAF copy-out and the HP-3 GPU overlay stayed dormant in the real app while the unit tests (which check afterawait connect()) stayed green.ensureHot()moved into the constructor (still gated on flag + isolation + SAB mode, still OOM-clamped to rung 1), and the regression is pinned by assertinghotBufferis non-null before the firstconnect(). - The GPU overlay places the hot ring as the chart's tail window, not as the whole series. The first production measurement (
hp.geton a live 9 500-bar chart) showedring.aligned: falsewithhotCount: 501,hotFrom: 0: the guard comparedgetBarsLength()against the engine snapshot's own index space (hotFrom + hotCount), which only ever matched when the snapshot happened to equal the chart window β the overlay therefore never painted on a real REST-loaded chart (the e2e staged equal counts and missed it). The ring is now placed by identity: the ring's newest bar must equal the chart's newest bar (tail time), ring slotjis chart indexchartBars β hotCount + j(ringWindowPlacement), and a viewport that starts left of the window clears instead of shifting candles β cold paging remains unwired, so zoomed-out views over more bars than the ring holds still draw nothing (LWC keeps painting).hp.getnow reportsring.tailTime/ring.chartTailTimeand a tail-identityaligned, and the@gpue2e covers the production shape: a shortened snapshot clears the canvas (alpha 0) and a wheel zoom into the tail repaints it. - A background backfill can no longer fail an unrelated test's fetch assertion.
tests/onchain-load-symbol.test.tsstarts aloadSymbolDatathat queues a Data Source Manager walk; the job outlived the test and kept paging after later files had restored the real GeckoTerminal source, so whichever file installed a fetch mock next counted a strayapi.geckoterminal.comcall. That is whysetScriptChartVisible > show does not run library() sourcesfailed in roughly every other full run with nothing to do with indicators β and the same leak failed its siblingshow while liveβ¦case instead. The case now resets the DSM inafterEach, the visibility describe resets multiplex rerun/live-bar timers inbeforeEach, and the assertion prints the offending URL and stack instead of a bare counter. - A long session no longer leaks a client, a timer or a memo per key. Six module-level maps grew with the number of distinct keys a session touched and nothing ever evicted them: the engine WebSocket clients (one socket plus its pending map per endpoint), the data-session series memos (one per endpoint + series), the dismissed job ids (one per job, forever), the pending live-bar writes (one per source + symbol + interval), and the bar-close verifiers (one per engine + series). They now share
src/utils/bounded-map.tsand are capped. Two of them needed more than a cap: an evicted WebSocket client is closed so the socket does not outlive it, and an evicted pending live bar is flushed rather than dropped β it is still a bar the dataset should receive. - A Worker route can no longer be reached with an uncapped body. Four routes read the request body outside the shared cap β
/api/keyscreated a key from an unbounded body,/api/git/oauth/*parsed the device-flow body unbounded, the MCP bridge forwarded an unbounded invoke payload, and the MCP plane carried its own hand-rolled copy of the byte check. All four now go through the shared capped reader and answer413 PAYLOAD_TOO_LARGEfor an over-cap body. A truncated device-flow body used to look like a request that carried no client id; it is now an explicit413. - Live indicators no longer drift away from the candles on the stream path. The stream plane replicates the engine's series (the wire has no client-side tick), but the stream branch returned before the engine-owned branch that ticks the chart's engine data session β so on the stream path nothing drove the engine and its series froze a few bars behind the venue. The script sessions that evaluate over that series froze with it, while the chart kept advancing from the venue, and the apply path tail-aligned the stale values onto the chart's tail: the indicators were displaced by the freeze gap (measured live: 38 bars after 38 minutes, growing with every new bar) and the newest bars carried no indicator value at all. The stream path now drives the engine's series on every venue flush (the
series-drivemodule existed for exactly this but was never wired in), and the re-run awaits that drive so the scripts evaluate against the freshly driven series. - Re-apply is no longer skipped once the rolling window slides. The dedupe compared only the bar length, but the rolling window slides with a constant length β so every reapply after the first looked "unchanged" and was silently skipped: the UI re-run button and the MCP
indicators.runcapability became no-ops, and the indicators kept the window they were last computed on while the candles moved on. The guard now compares the window identity (head bar time + length): a repeated call on the same window is still a no-op, a slid window re-runs.indicators.runalso reports how many scripts actually re-ran (reapplied). - Strategy events stay on the chart on every tick instead of flickering on/off. Silent live re-runs replaced all trade markers for a strategy with only the events from the current evaluation, so entry/exit labels disappeared between bar closes and only reappeared on the next close. Silent re-runs now merge new markers with existing ones (same-time markers are replaced, others are kept), so strategy actions are always visible; interactive runs still replace wholesale. Also
live.rerunOnis always honored βevery-tick(the default) is no longer silently downgraded tobar-closebased on engine capability probing. - A new candle now advances the chart instead of freezing it.
appendLiveBarpainted every genuinely new bar (appended) withseries.update, which can only overwrite the last bar in place and never adds a time slot, so the chart stuck at the last loaded time and each new candle rewrote that same slot (the right edge stayed at11:30while the price there changed). New bars now go through the same full-windowsetDatapath as a window slide, so the candle lands at the right edge withinrightOffsetwithout auto-scroll; forming-tick updates (updated, same time) still use the O(1) in-place path. - The engine flush names every local dataset before it clears anything. The purge list was built after the memory mirror had been wiped, so IndexedDB-only series (the mirror keeps 48) were never deleted on the engine, and the result still said they had been. Settings and
engine.flushnow capture memory and IndexedDB keys first, delete those engine series, then wipe the local stores. A list that cannot be read is reported as a failure instead of a clean delete. - A page that starts during a flush cannot put the series back. The generation check ran only when a write was queued, and a bars-cache put that had already copied its batch could commit after the clear. The flush bumps that generation and clears the mirror after the removes, after the remote deletes, and after the cache wipe. A write that loses the race deletes its key, and a batch copied out before the clear is dropped.
- Spot and another market no longer share one dataset. Reads and writes never passed the provider lock, so linear and inverse bars merged into the public spot series while the engine key used a different market. Non-spot and keyed series now append
|market|authMode. The engine wire still mapslineartoswapandinversetofutureat that boundary only. - A Data Manager job follows the dataset that was loaded. Backfill and apply-when-complete used the chart's current market, so a linear row grew the spot series and could paint onto a chart that had since switched market. The job keeps the selection's lock, and a completed job does not paint when that lock is no longer the chart's.
- An IndexedDB read failure no longer looks like an empty dataset.
getCachedBarsreturned[]on a failed read, and the next page merge replaced the series. A failed read now throws and the write is refused. A genuine miss, and a browser with no IndexedDB, still look empty. - Replacing a dataset keeps a DEX pool address's case. The replace path uppercased the stored symbol while the write path did not, so list and delete rebuilt a different key for an address that contains
:or/. - A dataset repaint no longer drops chart bars the dataset is missing. The merge kept only bars strictly newer than the dataset tip, so a same-time forming candle jumped backward and an interior hole-fill was punched back out. Equal times keep a closed dataset bar over a forming chart bar, and otherwise keep the chart bar.
- A script subscription grows with the chart window.
history_barswas sent only on the first subscribe, which usually ran against the short first paint, so the indicator stayed blank on the rest of the window. The depth is part of the subscription identity and is the chart window even when the store is still shorter. A script resubscribe does not cool the series socket. - A new candle no longer shifts the indicators. Every forming tick pushed the open bar's time onto the candle axis again, so the script series was one row per tick instead of one row per candle. The next candle repainted that series onto a longer index than the price pane, and the plots jumped. A forming tick now keeps the time it already recorded, and a new candle extends the axis by one bar.
- Zooming while the chart loads no longer blanks the candles. A history replace paints the new candles while volume or an indicator still holds the previous times. That frame throws inside the chart library, and the price series stays empty until the next bar updates both. Those extra times are dropped before the candle replace, and a zoom already in progress is not fitted away.
- Donchian keeps moving after a new candle. The live snapshot stored the shorter history the subscribe asked for, while the session and every later update were numbered from the longer series. Each new candle was rejected, so the channel stayed where the load left it and drew as a single point out to the last bar. The snapshot now counts the plots it actually contains, and a bad time axis no longer drops the value update.
- Bollinger %B no longer repaints when a candle closes. The live script snapshot carries bar times, and the deltas that follow carry only the new values. The apply path dropped that axis and zipped %B onto the candles by index, so each new candle moved every closed value onto the next bar. The price series was never moved. Closed %B now stays on the bar it was computed for, and only the new candle takes the new value.
- Pine plot colors are no longer overwritten by the palette on re-run. The legacy
plots[]overlay path dropped the engine'splot_meta.color, so any script evaluated without namedseriesfell back to the palette instead of the script's color. It now uses the same precedence as the named-series path: explicit panel pick (custom) β Pineplot_meta.colorβ palette. Auto-persisted (non-custom) colors no longer shadow a fresh script color, matching the earlier named-series fix. - Re-apply runs only once at a time. Multiple call sites (load-symbol Γ4, DSM, store, MCP) could fire during a single load, each re-running every script and flashing the chart through intermediate states. A guard now skips concurrent reapply passes;
stillCurrentstill supersedes stale ones. - Script colors survive a reapply when
plot_meta.coloris missing. The re-run store path previously fell toPLOT_PALETTE[colorIdx]when the engine didn't ship a resolvableplot_meta.color, wiping Pine colors (e.g.#6366f1) to palette colors on every reapply. It now keeps the stored color when the fresh run doesn't ship one. - Chart overlays preserve stored script colors when
plot_meta.coloris absent.buildOverlayPayloadsfell to the palette on the chart (even though the script panel stored the correct color) whenever the engine response lackedplot_meta.colorβ e.g. batched reapply. It now falls back to the stored (non-custom) color before the palette, across named-series, legacyplots[], and OHLC overlay paths. Precedence: explicit panel pick (custom) β Pineplot_meta.colorβ stored color β palette. - Fill bands render from
plot_metawhen no fill series array exists.resolvePlotFillBandsonly scannedseriesentries, sofill(plot1, plot2, color=...)bands had no band to draw β fills are derived from edge plots, not a separate series. It now also scansplot_metafor fill entries and resolves their Upper/Lower edge series. - Re-apply runs only once per bar window. Multiple call sites (load-symbol Γ4, DSM, store, MCP) fired sequentially during one load with the same bars, each flashing the chart through intermediate states.
reapplyChartScriptsnow skips whenstore.bars.lengthhasn't changed since the last pass. - Stale session/stream pushes no longer pin or freeze the chart. A live push whose engine shipped no bar-time axis over a moved window (gap fill, backfill, slide while the tab was idle) was index tail-pinned onto the chart tail, and Lightweight Charts spans the resulting
nagap as a long diagonal from the last pre-break value to the tip. Those pushes are now skipped instead (staleNoAxisinsrc/indicators/apply-context.ts, live pushes only β fresh full runs keep the tail-pin), so the last good plots stay on screen until a full run with an axis arrives. The same gap left the pane frozen instead when the values-only delta salvage dropped the axis permanently: the wire now resubscribes once per snapshot for a real full (axisResubPendinginsrc/engine/live-wire.ts) instead of painting unpaintable state on every tick, and a seq gap resubscribes instead of force-merging. Pinned bystaleNoAxisalignment tests, skip/paint live-push run tests, and wire resub-vs-paint tests (tests/runner-engine-owned-apply.test.ts,tests/engine/live-wire.test.ts).
Desktop installers (Tauri 2). CLI tarball, worker bundle, and checksums attach from the Release workflow.