From fa12b2255fd1fed5a366c137f4003323c69475ee Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Fri, 11 Sep 2026 18:46:22 +0200 Subject: [PATCH 1/7] fix(ci): stabilize Linux X11 and SwiftShader runtime fixtures --- .github/workflows/application.yml | 2 +- packages/dear-imgui/npm/diagnostics/node-input.ts | 5 ++++- packages/dear-imgui/npm/diagnostics/x11-run.sh | 11 +++++++++++ .../dear-imgui/npm/wasm/scripts/browser-smoke.mjs | 8 ++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/application.yml b/.github/workflows/application.yml index f3bafc2..4261bc9 100644 --- a/.github/workflows/application.yml +++ b/.github/workflows/application.yml @@ -74,7 +74,7 @@ jobs: - if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y ninja-build build-essential libgl1-mesa-dev xorg-dev libwayland-dev libxkbcommon-dev autoconf automake autoconf-archive libtool nasm bison flex pkg-config xvfb mesa-utils xdotool openbox wmctrl + sudo apt-get install -y ninja-build build-essential libgl1-mesa-dev xorg-dev libwayland-dev libxkbcommon-dev autoconf automake autoconf-archive libtool nasm bison flex pkg-config xvfb xauth x11-utils mesa-utils xdotool openbox wmctrl - run: npm ci --ignore-scripts - run: npm run build:common - name: VS2022 native unit build and all tests diff --git a/packages/dear-imgui/npm/diagnostics/node-input.ts b/packages/dear-imgui/npm/diagnostics/node-input.ts index a82833c..a64b5a9 100644 --- a/packages/dear-imgui/npm/diagnostics/node-input.ts +++ b/packages/dear-imgui/npm/diagnostics/node-input.ts @@ -29,10 +29,13 @@ export async function nativeInput(request: FixtureInput) { return; } await execute("xdotool", ["windowfocus", "--sync", windowId], { timeout: 5000 }); + // Ubuntu's xdotool 3.20160805 can wait forever on a repeated window-relative + // mousemove --sync. Keep motion/click ordered on the same X connection; the + // fixture's native event, focus and covering-frame assertions verify delivery. const args = request.action === "text" ? ["type", "--clearmodifiers", "--delay", "0", request.value!] : request.action === "keyDown" ? ["keydown", "BackSpace"] : request.action === "keyUp" ? ["keyup", "BackSpace"] - : ["mousemove", "--sync", "--window", windowId, String(request.x ?? 50), String(request.y ?? 24), + : ["mousemove", "--window", windowId, String(request.x ?? 50), String(request.y ?? 24), ...(request.action === "click" ? ["click", "1"] : request.action === "wheel" ? ["click", "4"] : [])]; await execute("xdotool", args, { timeout: 5000 }); } diff --git a/packages/dear-imgui/npm/diagnostics/x11-run.sh b/packages/dear-imgui/npm/diagnostics/x11-run.sh index 30ee17f..8d5019a 100644 --- a/packages/dear-imgui/npm/diagnostics/x11-run.sh +++ b/packages/dear-imgui/npm/diagnostics/x11-run.sh @@ -1,5 +1,16 @@ #!/usr/bin/env bash set -euo pipefail +# Check before starting either process so a missing utility cannot masquerade as +# a window-manager timeout. xauth is required internally by xvfb-run. +fixture_missing=() +for prerequisite in xvfb-run Xvfb xauth openbox xprop xdotool wmctrl; do + command -v "$prerequisite" >/dev/null 2>&1 || fixture_missing+=("$prerequisite") +done +if ((${#fixture_missing[@]})); then + echo "Missing X11 fixture prerequisites: ${fixture_missing[*]}" >&2 + echo 'On Ubuntu: sudo apt-get install xvfb xauth x11-utils openbox xdotool wmctrl' >&2 + exit 1 +fi # An isolated display and window manager make focus, iconify and WM_DELETE # actual application events without touching the host desktop session. if [[ ${XFRAMES_X11_FIXTURE_SESSION:-0} != 1 ]]; then diff --git a/packages/dear-imgui/npm/wasm/scripts/browser-smoke.mjs b/packages/dear-imgui/npm/wasm/scripts/browser-smoke.mjs index c416f1b..ff08889 100644 --- a/packages/dear-imgui/npm/wasm/scripts/browser-smoke.mjs +++ b/packages/dear-imgui/npm/wasm/scripts/browser-smoke.mjs @@ -35,6 +35,14 @@ const webGpuFlags = "--enable-webgpu-developer-features", "--use-gpu-in-tests", "--enable-accelerated-2d-canvas", + // Selecting the WebGPU adapter alone leaves Linux headless Chromium's + // compositor without a compatible backing for WebGPU canvas textures. + ...(process.platform === "linux" ? [ + "--enable-features=Vulkan", + "--use-vulkan=swiftshader", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + ] : []), ]; const browserCandidates = [ process.env.XFRAMES_BROWSER, From 5b76c83e5e5267522bed9935959e816c75573304 Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Fri, 11 Sep 2026 19:15:27 +0200 Subject: [PATCH 2/7] docs: record Stage 4 CI stabilization and successful hosted validation --- ROADMAP.md | 30 ++- docs/README.md | 6 + docs/architecture/fabric-runtime-hardening.md | 9 +- .../fabric-invalidation-2026-09.md | 223 ++++++++++++++---- packages/dear-imgui/npm/FABRIC_EMBEDDING.md | 4 +- packages/dear-imgui/npm/diagnostics/README.md | 15 +- 6 files changed, 219 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2b444f4..81ababe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # XFrames Roadmap -Last reviewed against repository history and implementation: 8 September 2026. +Last reviewed against repository history and runtime verification: 11 September 2026. ## Vision @@ -232,16 +232,18 @@ schema-v2 replacement of the earlier alpha API, 343 native tests on Windows/Linu same-ID move cycles per real runtime. Each ordinary cycle abandons prospective work; all ten lifetime counters return to baseline. The structural workload separately verifies 200 publications/calls and 40 bailouts from 240 React updates -per repetition. Desktop publication wakes GLFW once; Wasm retains its existing -frame loop. This does not deliver Stage 4 invalidation scheduling or a presented -frame acknowledgment. The local acceptance audit and three-repetition regular/ -100,000-row production comparisons are complete, with measured regressions and -remaining timing limits recorded explicitly. These uncommitted changes have not -run on hosted CI; the last Stage 2 hosted Wasm fixture failed. +per repetition. At Stage 3, desktop publication woke GLFW once and Wasm retained +its existing frame loop; Stage 4 replaced that scheduling policy. Publication +acknowledgment is not a presented-frame acknowledgment. The local acceptance +audit and three-repetition regular/100,000-row production comparisons are +complete, with measured regressions and +remaining timing limits recorded explicitly. Stage 3 was committed as `2cbb373`; +its [hosted run](https://github.com/xframes-project/xframes/actions/runs/34245396897) +failed the Wasm runtime fixture after successful native builds and tests. ### Stage 4 — Invalidation-Driven Rendering and Instrumentation -MVP complete under the revised delivery scope. See the +MVP delivered in commit `1de105f` under the revised delivery scope. See the [completion boundary and deferred qualification](docs/engineering/fabric-invalidation-2026-09.md#mvp-completion-boundary). This closes the scheduler MVP; it does not close the broader milestone below. @@ -260,9 +262,15 @@ pass 378 native tests on Windows/Linux and both complete 1,000-cycle runtime lifetime/resource/move fixtures. Correlation ends at backend submission; physical serial arrival and presentation remain unavailable. Current-source production observations are recorded with shared-host limitations and an extended Node -post-measurement editor-focus failure. Linux application execution remains -unverified after its build was stopped at the user's wrap-up instruction; current -working-tree changes have no hosted CI result. The milestone remains open. +post-measurement editor-focus failure. The focused CI stabilization in `fa12b22` +fixes missing X11 prerequisites, a repeated-motion wait in the Linux input fixture +and Chromium's SwiftShader canvas graphics setup. Its complete +[application run](https://github.com/xframes-project/xframes/actions/runs/34623926577) +passed JavaScript, both 378-test native suites, Windows/Node/Wasm builds, both +1,000-cycle runtime workloads, separate inactivity/activity gates, all required +App smokes, the browser wrapper and 65-result Node/Wasm parity. Linux application +execution is now verified. This closes the focused CI pass; consistently green +CI over subsequent changes and the broader milestone remain open. ### Milestone Review Gate — After Stages 0–4 diff --git a/docs/README.md b/docs/README.md index 779c79c..a689fc7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,12 @@ The live [xframes.dev](https://xframes.dev) website is maintained in a separate - [React Native Fabric Embedding](../packages/dear-imgui/npm/FABRIC_EMBEDDING.md) — implemented React Native 0.87 snapshot generation, host contract, workspace rules, upgrade procedure, Node/Wasm verification, and known gaps. - [Fabric-Compatible Runtime Hardening](architecture/fabric-runtime-hardening.md) — atomic Fabric commits, explicit destruction, lifecycle tests, recording/replay, invalidation-driven rendering, performance instrumentation, and automation. +## Engineering and verification + +- [Stage 4: Invalidation Scheduling](engineering/fabric-invalidation-2026-09.md) — delivered scheduler MVP, producer and lifetime boundaries, local validation, measurements, completed hosted CI stabilization and deferred qualification. +- [Stage 3: Atomic Fabric Publication](engineering/fabric-publication-2026-09.md) — schema-v2 publication, committed ownership, visibility guarantees and historical acceptance evidence. +- [Diagnostics and Reproduction Guide](../packages/dear-imgui/npm/diagnostics/README.md) — current fixture commands, platform prerequisites and measurement contracts. + ## Existing design records - [Canvas Widget Design](../CANVAS.md) — canvas purpose, data flow, draw commands, performance model, and integration patterns. diff --git a/docs/architecture/fabric-runtime-hardening.md b/docs/architecture/fabric-runtime-hardening.md index 78d4bd8..dbeecee 100644 --- a/docs/architecture/fabric-runtime-hardening.md +++ b/docs/architecture/fabric-runtime-hardening.md @@ -1,7 +1,7 @@ # Fabric-Compatible Runtime Hardening -- Status: target architecture with implemented Stage 3 publication and Stage 4 scheduling boundaries below; current acceptance evidence is tracked separately -- Last updated: 8 September 2026 +- Status: target architecture with implemented Stage 3 publication and committed Stage 4 scheduler MVP; broader runtime qualification remains open +- Last updated: 11 September 2026 - Related decision: [XFrames and GPUIX assessment](../strategy/gpuix-comparison-2026-08.md) ## Purpose @@ -60,7 +60,10 @@ schema sketches below are not the accepted publication wire spelling. ### Implemented Stage 4 scheduling boundary The [invalidation implementation record](../engineering/fabric-invalidation-2026-09.md) -supersedes the scheduler sketches below. Both backends use one shared +describes the MVP committed in `1de105f` and supersedes the scheduler sketches +below. Its local acceptance and the successful hosted CI stabilization in +`fa12b22` are recorded separately from the broader milestone qualification. +Both backends use one shared `FrameScheduler`: a monotonic generation, one constructing ticket and one latest submitted ticket. Successful publication advances revision and generation under the tree visibility locks. Imperative/resource/input work advances generation diff --git a/docs/engineering/fabric-invalidation-2026-09.md b/docs/engineering/fabric-invalidation-2026-09.md index e8a0488..36ce131 100644 --- a/docs/engineering/fabric-invalidation-2026-09.md +++ b/docs/engineering/fabric-invalidation-2026-09.md @@ -1,29 +1,32 @@ # Fabric Stage 4 invalidation scheduling -Status: Stage 4 MVP complete under the user's revised MVP scope, 8 September 2026. +Status: Stage 4 MVP delivered in commit `1de105f1d8479337e78a083b8c6645416c301c23` +(8 September 2026). Focused CI stabilization completed on 11 September 2026 in +`fa12b2255fd1fed5a366c137f4003323c69475ee`; all five jobs in +[application run 34623926577](https://github.com/xframes-project/xframes/actions/runs/34623926577) +passed, including the required Linux Node/Wasm runtime, inactivity/activity, +App smoke and parity gates. See the [stabilization evidence](#focused-ci-stabilization-11-september-2026). The shared scheduler, both backend loops, producer ownership and correlation are -implemented. Windows/Linux native suites pass 378 tests; both real backends pass -the complete 1,000-cycle lifetime/resource/move fixture and 65-result parity. -Production observations are recorded below. At the user's request to wrap up, -the unfinished Linux Node application build was stopped after 51/234 build steps. -Its cached work remains available; Linux application execution is unverified. -The subsequent request to finish an MVP authorized the final diagnostic -typecheck, evidence review and documentation closure; no native rebuild resumed. -This working tree has no current-source hosted run. The Stages 0–4 milestone -remains open independently of Stage 4 core delivery. Earlier checkpoints below -record their source/evidence boundaries and do not override later results. +implemented. Local Windows/Linux native suites pass 378 tests; local Windows +Node/browser runs pass the complete 1,000-cycle lifetime/resource/move fixture +and 65-result parity. +The focused pass also completed Linux application execution and verified both +1,000-cycle runtime workloads on hosted Linux. Production observations and the +original failed hosted run remain recorded below. +The Stages 0–4 milestone remains open independently of Stage 4 core delivery. +Earlier checkpoints below record their source/evidence boundaries and do not +override later results. ## MVP completion boundary -The user's later instruction, “wrap up the goal, go MVP,” supersedes the broader -acceptance bar for this delivery. The MVP includes the shared scheduler, both +Delivery uses an MVP acceptance bar. The MVP includes the shared scheduler, both native backend integrations, publication/imperative/input/resource wake paths, finite deadlines and Canvas activity control, truthful submitted-frame correlation, lifetime cleanup, deterministic tests and demonstrated ordinary Windows Node/browser application use. These features remain fully implemented; the revised scope defers additional qualification rather than removing behavior. -Final closure re-read the current evidence: 378 passing native tests on each of +The original MVP closure reviewed 378 passing native tests on each of Windows and Linux; both complete 1,000-cycle runtime stress reports; 65 shared parity results; required package/Fabric/lifecycle gates; Node development and production App smokes; full-App browser production and wrapper gates; and actual @@ -34,13 +37,14 @@ options parse, required jobs remain present, and no continue-on-error was added. The final working-tree whitespace check passes. No generated renderer snapshots or authoritative npm lockfile changes were introduced. -Deferred from this MVP: Linux Node application execution, a current-source -hosted CI run, reliable desktop input qualification across foreground conditions, +Linux Node application execution and passing hosted runtime gates were deferred +at MVP closure and are now verified by the focused CI pass below. Remaining +qualification includes reliable desktop input across foreground conditions, controlled performance targets, mixed-DPI/hardware WebGPU/physical serial and presentation evidence. The extended Node post-measurement focus failure remains recorded as a failure. Existing timing observations remain provisional. These are follow-up qualification items, and the broader Stages 0–4 milestone remains open. -The implementation and documentation are local, uncommitted changes. +The implementation and original acceptance record are committed in `1de105f`. Performance interpretation was clarified during this validation: this is a shared host with variable background load and foreground-window conditions. Requested @@ -123,30 +127,30 @@ without consuming deadlines until renderable again. ## Producer inventory This inventory comes from the current native/binding/widget source, not an -assumption that publication already wakes every producer. The implementation -boundaries below were established during the earlier core checkpoint; the -executing gate mapping following the table records their current verification. +assumption that publication already wakes every producer. The table describes +the delivered implementation; the following gate mapping records verification +and its remaining platform limits. -| Producer/consumer and source | Reason and lifetime | Work/visibility and required gate | +| Producer/consumer and source | Reason and lifetime | Implemented work/visibility boundary | | --- | --- | --- | -| `XFrames::ApplyCommitOperations`, real structural subject | publication; runtime | Generation now advances with revision under both tree locks; rejected/abandoned work must not invalidate. Real publication/capture race and ordinary wrapper coverage required. | -| `QueueElementInternalOp` and actual per-widget handlers | imperative; verified native subject lifetime | All visible Plot/Table/control/Map/Canvas commands share the handler boundary. Generation now advances after handling under the element lock. Inventory of queued work remains necessary: acknowledgment is not upload completion. | -| `AppendTextToClippedMultiLineTextRenderer` | imperative; live target | Append now invalidates under the element lock and notifies after release. Settled clipped-text wake gate required. | -| GLFW cursor/enter/buttons/scroll/keys/char/focus | input; renderer/window | Preserve ImGui backend callback chaining, then invalidate. Real native input from inactivity, dispatch, key repeat, drag and return-to-idle gates required. | -| Refresh/exposure, resize, framebuffer/content scale, iconify/restore/close; binding `resizeWindow` | window; renderer | Marshal desktop window work to render thread; zero-sized/hidden surfaces retain pending generations. Test restore and idle close without a polling fallback. | -| `SetDebug`, `ShowDebugWindow`, `PatchStyle`, initial font/style setup | diagnostics/style; runtime | Debug focus is queued to the render thread; style patches stage and publish under visibility locks. Initial fonts are renderer-owned. Remaining actual style/font/input gates are open. | +| `XFrames::ApplyCommitOperations`, real structural subject | publication; runtime | Generation advances with revision under both tree locks; rejected/abandoned work does not create successful-publication invalidation. | +| `QueueElementInternalOp` and actual per-widget handlers | imperative; verified native subject lifetime | Visible Plot/Table/control/Map/Canvas commands share the handler boundary. Generation advances after handling under the element lock; asynchronous upload completion has its own resource invalidation. | +| `AppendTextToClippedMultiLineTextRenderer` | imperative; live target | Append invalidates under the element lock and notifies after release. | +| GLFW cursor/enter/buttons/scroll/keys/char/focus | input; renderer/window | Chained callbacks preserve ImGui handling and invalidate the scheduler. | +| Refresh/exposure, resize, framebuffer/content scale, iconify/restore/close; binding `resizeWindow` | window; renderer | Desktop window work is marshalled to the render thread; zero-sized/hidden surfaces retain pending generations. | +| `SetDebug`, `ShowDebugWindow`, `PatchStyle`, initial font/style setup | diagnostics/style; runtime | Debug focus is queued to the render thread; style patches stage and publish under visibility locks. Initial fonts are renderer-owned. | | `RequestScreenshot` / backend capture | screenshot; renderer request | At most 32 requests; each carries its target generation. Requests complete after a covering submission or fail explicitly while unavailable/terminal/stopped. Node unavailable and populated capture gates passed at the loop checkpoint. | -| desktop `Image::RequestImage` / `PrepareFrame` | resource; Image lifetime | Widget-owned pending load replaces global ID queue/map. All jobs, including failures and clipped Images, drain before NewFrame. Native tests passed; current real file/upload gates pending. | -| Wasm `Image::RequestImage` completion | resource; Image lifetime | Owned cancellable fetch publishes into a weak mailbox before Resource invalidation/notification. Decode failure clears the job; replacement/removal expires owned work. Controlled browser gates pending. | -| `MapView::FetchMissingTiles`, `CompleteTile`, `PrepareFrame` | resource; Map lifetime | Four bounded runtime workers or owned browser fetches publish bytes under a mailbox lock. Preparation drains all completed jobs, including clipped Maps. Native capture/lifetime tests passed; actual HTTP/upload gates pending. | -| Map prefetch, cache stats/progress | resource/map; Map lifetime | Shared cache/stats ownership; cumulative progress queues outside-tree delivery with a late Node JS-thread lifetime check. Both backends now pump bounded owned prefetch work. Native stale-event test passed; actual callback gate pending. | -| Map zoom debounce / fetch in `Render` | map deadline; Map lifetime | A visible Map owns the nearest 150-ms zoom deadline; preparation clears clipped activity. Failed tile attempts settle without retries until another view request. Actual zoom input gate pending. | -| QuickJS/Lua/Janet script/data operations and pending script/texture queues | imperative/resource; each Canvas lifetime | `CanvasResources` owns cancellable script/texture mailboxes and render-thread uploads. Reload/unload supersede pending work and retire replaced GPU handles. Native engine resource tests passed; current actual completion gates pending. | -| Canvas per-frame script execution | canvas activity; Canvas lifetime | Default continuous execution preserves animation. `setContinuous(false)` permits settling; `redraw()` requests one explicit frame. Clipped or cleared scripts have no active owner. All three engines passed the prior real loop checkpoint; resource checkpoint rebuild pending. | -| ImGui input trickling, cursor blink, repeat, tooltip stationary/hover delays, navigation/drag/scroll and layout settling | bounded runtime owners/deadlines | Derive activity from actual pending work and timers, not all focused/hovered widgets. Define idle DeltaTime policy and verify first visible layout without rescue input. | -| `SetDiagnosticsEnabled` / `GetDiagnosticsFrame`, state/liveness/query getters | diagnostics; runtime | Off→on requests one frame; reads/off stay pure. New scalar scheduler telemetry remains enabled independently. Migrate all newer-frame assumptions to revision/generation/sample coverage. | - -Current gate mapping supersedes “pending/required” checkpoint notes in that table: +| desktop `Image::RequestImage` / `PrepareFrame` | resource; Image lifetime | Widget-owned pending load replaces global ID queue/map. All jobs, including failures and clipped Images, drain before NewFrame. | +| Wasm `Image::RequestImage` completion | resource; Image lifetime | Owned cancellable fetch publishes into a weak mailbox before Resource invalidation/notification. Decode failure clears the job; replacement/removal expires owned work. | +| `MapView::FetchMissingTiles`, `CompleteTile`, `PrepareFrame` | resource; Map lifetime | Four bounded runtime workers or owned browser fetches publish bytes under a mailbox lock. Preparation drains all completed jobs, including clipped Maps. | +| Map prefetch, cache stats/progress | resource/map; Map lifetime | Shared cache/stats ownership; cumulative progress queues outside-tree delivery with a late Node JS-thread lifetime check. Both backends pump bounded owned prefetch work. | +| Map zoom debounce / fetch in `Render` | map deadline; Map lifetime | A visible Map owns the nearest 150-ms zoom deadline; preparation clears clipped activity. Failed tile attempts settle without retries until another view request. | +| QuickJS/Lua/Janet script/data operations and pending script/texture queues | imperative/resource; each Canvas lifetime | `CanvasResources` owns cancellable script/texture mailboxes and render-thread uploads. Reload/unload supersede pending work and retire replaced GPU handles. | +| Canvas per-frame script execution | canvas activity; Canvas lifetime | Default continuous execution preserves animation. `setContinuous(false)` permits settling; `redraw()` requests one explicit frame. Clipped or cleared scripts have no active owner. | +| ImGui input trickling, cursor blink, repeat, tooltip stationary/hover delays, navigation/drag/scroll and layout settling | bounded runtime owners/deadlines | Activity reflects actual pending work and timers. DeltaTime after unbounded idle/suspension is capped at 100 ms; active/deadline intervals retain monotonic elapsed time. | +| `SetDiagnosticsEnabled` / `GetDiagnosticsFrame`, state/liveness/query getters | diagnostics; runtime | Off→on requests one frame; reads/off stay pure. Scalar scheduler telemetry remains enabled independently. Fixture waits use revision/generation/sample coverage. | + +Verification of these boundaries: | Boundary | Executing verification | | --- | --- | @@ -165,7 +169,12 @@ GLFW callbacks are installed before ImGui installs its chaining callbacks. The vendored ImGui Emscripten resize callback remains installed. This slice does not claim physical DPI/display or presentation measurements unavailable on this host. -## Current verification and pre-change control +## Historical verification and pre-change control + +The following checkpoints describe intermediate sources and runs from 8 September +2026. Their pending work and build status apply to those checkpoints. The +[MVP completion boundary](#mvp-completion-boundary) and +[current acceptance audit](#current-acceptance-audit) describe delivered status. Before changing native sources, rebuilt the Stage 3 Release Node addon using VS2022/MSVC 14.44, then ran a separate production control with: @@ -233,7 +242,7 @@ An isolated harness with matching React/current-source XFrames, synthetic NAV-SA input, sustained/idle/resume and listener cleanup remains to be executed. This source inspection is not application or physical receiver validation. -## Backend integration checkpoint +## Historical backend integration checkpoint The desktop loop now waits indefinitely while clean and inactive, or until a real owned deadline. Dirty/active frames use the existing vsync swap cadence. GLFW @@ -417,7 +426,7 @@ unconditional second ImPlot destruction in fixture teardown; teardown now checks whether the tested cleanup already released that context. Actual backend teardown verification remains pending. -## Input, platform lifetime and resource-stress checkpoints +## Historical input, platform lifetime and resource-stress checkpoints The actual input fixture now drives native window events on Node and Chromium CDP events on Wasm. Both pass text application callbacks, cursor blink deadlines, @@ -596,6 +605,9 @@ at `constructedAtMs`, after construction. The fixture label is corrected to ## Current acceptance audit +This table records the original MVP acceptance evidence. Its Linux application +and hosted CI limits are superseded by the focused stabilization results below. + | Requirement group | Result and evidence | | --- | --- | | Shared model, coherent capture, sleep/completion races, rejected work, terminal states, counters and bounded ownership | Implemented in shared `frame_scheduler` and real-subject visibility boundaries. Controlled-clock/latch tests execute in both 378-test native suites. Ordering is independent of optional snapshots. | @@ -628,13 +640,128 @@ must not be silently relabeled as complete runtime passes. ### Hosted and milestone limits -The latest inspected hosted run is [Stage 3 run 34245396897](https://github.com/xframes-project/xframes/actions/runs/34245396897), -head `2cbb37387aeab3e425867ae756f59c5e9a80dc29`, completed **failure**. JS, -Linux/Windows native compilation/tests and optimized Wasm compilation succeeded; -the browser fixture failed with DeviceLost/SharedImageBackingFactory diagnostics -for a 900×700 surface, skipping full-App browser/parity jobs. Current local -SwiftShader success and an updated workflow do not establish hosted coverage of -this uncommitted source. No broad skip or continue-on-error was added. +The starting hosted evidence reviewed on 11 September 2026 was +[Stage 4 run 34275033947](https://github.com/xframes-project/xframes/actions/runs/34275033947), +head `1de105f1d8479337e78a083b8c6645416c301c23`, completed **failure**. + +| Hosted job | Result | +| --- | --- | +| JavaScript | Passed Fabric verification, common build, diagnostic types and lifecycle checks. | +| Windows native | Passed native tests, Node addon compilation and package build. | +| Linux native/Node | Native tests, addon compilation and package build passed. The runtime fixture failed before launch because `xprop` was missing; its X11 window-manager readiness check timed out. Inactivity/activity and full-App smokes were skipped. | +| Wasm/WebGPU | Optimized native build, package build and diagnostic types passed. The runtime fixture failed at the initial empty-frame observation with `terminal renderer backendFailed`; inactivity/activity and full-App smoke gates were skipped. Its cause was diagnosed during the focused pass below. | +| Node/Wasm parity | Skipped because prerequisite runtime jobs failed. | + +The earlier [Stage 3 run 34245396897](https://github.com/xframes-project/xframes/actions/runs/34245396897) +also failed its browser gate. These runs remain failed historical evidence. The +focused pass below fixes the fixture prerequisites and graphics setup and +establishes successful hosted runtime coverage on its final code revision. + +### Focused CI stabilization, 11 September 2026 + +The fresh artifact review of run `34275033947` located the first browser failure +in `wasm/browser.log`: Chromium could not find a `SharedImageBackingFactory` for +the WebGPU canvas swapchain, then failed to create that shared image. The device +lost message in `page.log` (`A valid external Instance reference no longer +exists.`) and the scheduler's terminal `backendFailed` followed that failure. +Adapter/device creation and the native ready callback had already succeeded. + +A small, plain JavaScript WebGPU canvas reproduction in Ubuntu 24.04 Docker, +Chrome `153.0.8010.36`, reproduced the same shared-image error without XFrames or +Wasm. Selecting Vulkan SwiftShader and ANGLE SwiftShader together fixed canvas +submission and a captured red square. Vulkan-only and ANGLE-only attempts failed. +The passing configuration also worked without Xvfb, `--enable-gpu` or a GPU +blocklist override. The browser harness therefore adds only +`--enable-features=Vulkan`, `--use-vulkan=swiftshader`, `--use-angle=swiftshader` +and `--enable-unsafe-swiftshader` for its Linux SwiftShader mode. This is a fixture +graphics-setup correction; no renderer fallback, recovery, polling, retry or +submitted-frame semantics changed. Original stderr/page/result artifacts remain +preserved by the existing harness and workflow. + +Linux startup now explicitly installs `x11-utils` and `xauth`. The owned X11 +wrapper checks all seven required commands before starting either process and +prints an actionable Ubuntu install command. A deliberately missing `xprop` +failed immediately, and the real Xvfb/Openbox readiness path passed. + +Once Linux Node could run, its input gate exposed another fixture defect: +Ubuntu's `xdotool 3.20160805.1` hangs on repeated window-relative +`mousemove --sync` at the same position. A separate owned-X11 reproduction +timed out that command after two seconds and completed the identical move +without `--sync`. The input helper retains ordered motion/click requests on one +X connection, its command watchdog, and all native event/focus/frame assertions; +it removes only that tool's motion wait. No artificial extra motion is injected. + +Local evidence under `npm/build/diagnostics/`: `ci-probe-*` records the browser +comparison and screenshots; `ci-xdotool-probe.log` records the repeated-motion +reproduction; `ci-linux-build.log` records completion of the cached current-source +Linux addon/package build. `ci-wasm-linux-current/result.json` passes the full +short fixture with three lifecycle/resource/move cycles, nine production streams, +all producer/input/visibility/publication/shutdown gates and the real wrapper. +Its populated screenshot was inspected. It reused the existing optimized +current-source Wasm artifact. The root-only local container uses a temporary +Chrome `--no-sandbox` wrapper; hosted execution uses Chrome directly. + +`ci-node-input-current/result.json` also passes the complete short production +fixture after the input-helper fix: three lifecycle/resource/move cycles, nine +streams, all 11 resource gates, input, visibility and terminal shutdown. The +browser report contains 13 resource gates and passes its wrapper lifecycle. +Both report zero continuing idle frames. The existing parity command passes +65 shared binding results against these two reports. Diagnostic typechecking, +browser-script syntax, shell syntax and whitespace checks pass. + +The initial local browser attempt timed out while gathering Git metadata across +the Windows-mounted submodules. An affected Node metadata scan was stopped. +Those are failed/aborted local setup attempts, not runtime passes; configuring +the reproduction container's Git to omit unchanged submodule scans allowed the +current runs to start. The first executing Node run remains failed at the +repeated-motion command (`ci-node-linux-current`). + +The four-file code patch is committed as +`fa12b2255fd1fed5a366c137f4003323c69475ee` on +`codex/stage4-ci-stabilization`. Its +[application run 34623926577](https://github.com/xframes-project/xframes/actions/runs/34623926577) +completed **success** on 11 September 2026. All five jobs and every required +runtime gate executed and passed on that revision: + +| Hosted job/gate | Executed result | +| --- | --- | +| JavaScript | Fabric verification, common build, diagnostic types and all 21 development + 21 production lifecycle scenarios passed, covering all ten XF-LIFE defect IDs. | +| Windows native/addon/package | All 378 native tests passed with zero failures, disabled tests or errors. VS2022 addon compilation and Node package build passed. | +| Linux native/addon/package | All 378 native tests passed with zero failures, disabled tests or errors. GCC addon compilation and Node package build passed. | +| Linux Node/Mesa runtime | Scheduling, all 11 resource gates, real input, visibility, Fabric publication, lifetime/moves, diagnostics-disabled behavior and terminal cleanup passed. | +| Wasm/Chromium runtime | Optimized Docker/Emscripten 5.0.2 build (`XFRAMES_FAST_BUILD=OFF`), package build and types passed. Chromium 152 with SwiftShader passed scheduling, all 13 resource gates, input, visibility, publication, lifetime/moves, the ordinary wrapper and terminal DOM-listener cleanup. | +| Separate Node and Wasm inactivity/activity | Both passed: zero constructed/submitted idle frames over 10,002.64 ms and 10,001.83 ms respectively. Each also executed two 10-second continuous-Canvas intervals, with diagnostics off and on, then returned to inactivity. | +| Full-App smokes | Linux Node development, Linux Node production and browser production all passed. Their populated screenshots were inspected, as were both runtime fixture screenshots. | +| Node/Wasm parity | Executed against this run's native-Linux and Wasm artifacts; all 65 shared results, populated widget/hierarchy/Yoga state and final native destruction state matched. | + +Both primary runtime reports contain **1,000 ordinary lifecycle cycles, 1,000 +abandoned candidates, 1,000 resource/activity removal cycles, 1,000 original +same-ID moves and 1,000 resource/activity moves**. All ten lifetime deltas are +zero. Each report accounts for nine production streams. The separate activity +reports submitted 3,330/3,298 Node frames and 438/431 browser frames with expensive +diagnostics off/on. These shared-host software-rendering observations are +informational; no throughput or presentation qualification is inferred. + +No required gate failed or was skipped. Existing platform conditions skipped the +Linux-specific setup/tests/addon/rendering steps in the Windows job and the +Windows-specific unit/addon steps in the Linux job. The optional extended steps +were skipped because this was a normal push run; the manual extended mode and +the mandatory 1,000-cycle workloads are preserved. + +The runtime reports identify source SHA `fa12b2255fd1fed5a366c137f4003323c69475ee`. +The Node reports retain their post-build `sourceDirty: true` metadata; the +existing native CMake build regenerates and copies the tracked Janet core image. +Wasm records `sourceDirty: false`. Checkout logs and fresh addon/Wasm build steps +establish the revision provenance; dependency caches do not supply application +binaries. No implementation changes followed the validated code commit. + +Authoritative artifacts on the run are `javascript-lifecycle`, +`native-windows-2022`, `native-ubuntu-24.04` and `wasm-webgpu`, plus the parity job +log. Downloaded evidence is under `npm/build/diagnostics/ci-34623926577/`: +`run.json`, `run.log`, native XML, the four runtime `result.json` reports and +screenshots. `audit.json` records the requirement checks against those artifacts, +including matching source revisions, actual step outcomes, stress counts, +inactivity/activity intervals and parity. This completes the focused CI pass. The Stages 0–4 milestone remains open for controlled performance qualification, consistently green hosted CI and broader hardware/application evidence. The CNO diff --git a/packages/dear-imgui/npm/FABRIC_EMBEDDING.md b/packages/dear-imgui/npm/FABRIC_EMBEDDING.md index 7268976..8c2225c 100644 --- a/packages/dear-imgui/npm/FABRIC_EMBEDDING.md +++ b/packages/dear-imgui/npm/FABRIC_EMBEDDING.md @@ -269,7 +269,7 @@ must use the same schema and revision checks; hand off only after acknowledged empty publication and bridge disposal. Mixing writers does not silently rebase a stale Fabric snapshot. Native acknowledgment is not a presented-frame guarantee. -The Stage 4 working tree uses one invalidation scheduler on both backends. Applied +The Stage 4 MVP (`1de105f`) uses one invalidation scheduler on both backends. Applied publications invalidate once at the native visibility boundary. Imperative data, input, resources and window changes invalidate without advancing structural revision. Native draw construction captures revision/generation under the tree @@ -279,7 +279,7 @@ real deadline; inactive browser modules pause their animation callbacks. Canvas handles expose `setContinuous(false)` for static scripts and `redraw()` for an explicit frame. Initialization/ready and synchronous schema-v2 publication remain unchanged. See [the invalidation record](../../../docs/engineering/fabric-invalidation-2026-09.md) -for current verification and open acceptance work. +for local verification, completed hosted CI stabilization and deferred qualification. Observation code must compare lossless decimal `frameId`, `nativeRevision` and `coveredGeneration` using `BigInt`. Capture the operation's target generation and diff --git a/packages/dear-imgui/npm/diagnostics/README.md b/packages/dear-imgui/npm/diagnostics/README.md index 1a420fb..81c54fd 100644 --- a/packages/dear-imgui/npm/diagnostics/README.md +++ b/packages/dear-imgui/npm/diagnostics/README.md @@ -96,7 +96,11 @@ machine. Timing is informational, not a universal CI threshold. For browser runs set `XFRAMES_BROWSER` to an installed Edge/Chrome executable when discovery is insufficient. The default is a headless SwiftShader WebGPU adapter. `XFRAMES_WEBGPU_ADAPTER=default` selects the normal adapter. Report these -as separate environments. The harness requires port 3011 to be free so it can +as separate environments. On Linux the SwiftShader harness explicitly selects +Vulkan and ANGLE SwiftShader as well as the WebGPU adapter. Selecting only the +WebGPU adapter can fail Chromium's canvas shared-image creation and lose the +device before the initial frame. The normal-adapter and Windows flags are unchanged. +The harness requires port 3011 to be free so it can build its own fixture with the selected options. Graphics initialization failure is a failed integration run, never a rendering pass. @@ -216,7 +220,7 @@ and verify Yoga ownership in newer frames. --stress runs 1,000 ordinary cycles a `getCommitState` exposes always-current sequence/revision with diagnostics off. -The Stage 4 working tree replaces numeric `frame` with decimal-string `frameId`, +Stage 4 (`1de105f`) replaces numeric `frame` with decimal-string `frameId`, `nativeRevision` and `coveredGeneration`. The always-readable `scheduler` reports the live invalidation generation, completed coverage, submitted/constructed frame counts, wake/opportunity counts and fixed activity/deadline reasons. Queries do @@ -295,8 +299,11 @@ which environments actually ran; workflow configuration is not hosted-run proof. The complete fixture drives real text, held-key repeat, wheel zoom, minimize, restore, exposure and idle close through a PID-scoped Windows helper or an owned -X11 display/window manager. Linux requires `xvfb`, `xdotool`, `openbox` and -`wmctrl`; run `bash diagnostics/x11-run.sh npm run diagnostics:node`. Browser +X11 display/window manager. Linux requires `xvfb`, `xauth`, `xdotool`, `openbox`, +`wmctrl` and `xprop` (Ubuntu package `x11-utils`); run +`bash diagnostics/x11-run.sh npm run diagnostics:node`. The wrapper checks these +commands before launching Xvfb/Openbox and prints the Ubuntu install command if +one is missing. `xauth` is used internally by `xvfb-run`. Browser input/window state uses Chromium CDP. Native registrations, RAF/deadline handles, visibility listeners and pending screenshots must return to zero on terminal cleanup. A bounded DOM audit independently verifies native browser listeners. From 54bc3827c57c8d8e3a02026ce5998c752e0d1783 Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Fri, 11 Sep 2026 20:23:07 +0200 Subject: [PATCH 3/7] fix: qualify ordinary ubx-monitor application integration Correct the ImGui color ABI and inactive tab cursor positioning, with focused native and TypeScript regressions. Expose bounded native history and map overlay diagnostics for complete application verification. Retain the isolated application migration patch, reproducible setup, checksummed receiver-byte driver, and public-render full-App scenario. Record populated panel interaction, 60 seconds of NAV-SAT/NAV-PVT input, pause/reconnect, disconnected inactivity, and ordinary cleanup with late work. Update only the bounded Windows roadmap criterion. Validated fresh application typecheck and development startup, serial lifecycle checks, 380 Windows native tests, common/Fabric/ABI checks, 21 lifecycle scenarios in each React mode, rebuilt Node/Wasm short suites, and 65-result binding parity. Existing hosted 1000-cycle and extended gates remain unchanged; shared-host timings are informational. Companion ubx-monitor commit: e7de9e25ff3f608e8bb2613e471d0b97f47d58f2 on qualification/current-xframes-application. Original application checkout, settings and dependency graph are preserved. --- ROADMAP.md | 32 +- docs/README.md | 1 + .../fabric-invalidation-2026-09.md | 13 + .../ubx-monitor-application-2026-09.md | 347 ++++++ goal.txt | 138 +-- .../cpp/app/include/widget/map_view.h | 11 + .../cpp/app/include/widget/plot_line.h | 6 + .../cpp/app/include/widget/plot_scatter.h | 4 + .../cpp/app/src/widget/styled_widget.cpp | 5 +- packages/dear-imgui/cpp/app/src/xframes.cpp | 5 + .../dear-imgui/cpp/tests/xframes_test.cpp | 44 + packages/dear-imgui/npm/common/package.json | 2 +- .../npm/common/scripts/test-imgui-col-abi.ts | 21 + .../dear-imgui/npm/common/src/lib/types.ts | 22 +- packages/dear-imgui/npm/diagnostics/README.md | 42 + .../npm/diagnostics/ubx-application-setup.mjs | 88 ++ .../npm/diagnostics/ubx-application.patch | 1075 +++++++++++++++++ .../npm/diagnostics/ubx-application.ts | 416 +++++++ .../dear-imgui/npm/diagnostics/ubx-bytes.ts | 41 + 19 files changed, 2189 insertions(+), 124 deletions(-) create mode 100644 docs/engineering/ubx-monitor-application-2026-09.md create mode 100644 packages/dear-imgui/npm/common/scripts/test-imgui-col-abi.ts create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-application.patch create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-application.ts create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-bytes.ts diff --git a/ROADMAP.md b/ROADMAP.md index 81ababe..3a27c2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,13 +12,13 @@ The strategic basis for this focus is documented in [XFrames and GPUIX: Technica ## Next Milestone — Runtime Reliability & Measured Streaming Performance (in progress) -Deliver Phase 12 Stages 0–4: establish lifecycle tests, application-code CI, and performance baselines; make cleanup explicit; publish atomic Fabric transactions; then add revision-aware, invalidation-driven rendering. The first Stage 0 PlotBar/Table slice supplies lifecycle characterizations, opt-in native frame diagnostics, cross-runtime fixture commands, production baselines, and application-code CI configuration. Stage 1 adds explicit native destruction and lifetime cleanup. Stage 2 supplied the first shared native transaction path. Stage 3 now replaces that alpha structural API with prospective Fabric descriptions, one atomic final-tree publication and committed ownership. Stage 4's scheduler MVP is complete with local Windows Node/browser application evidence, native tests on Windows/Linux, and shared resource/lifetime gates. Full Stage 0 benchmark coverage, controlled performance qualification, Linux application execution and current-source hosted CI remain open. +Deliver Phase 12 Stages 0–4: establish lifecycle tests, application-code CI, and performance baselines; make cleanup explicit; publish atomic Fabric transactions; then add revision-aware, invalidation-driven rendering. The first Stage 0 PlotBar/Table slice supplies lifecycle characterizations, opt-in native frame diagnostics, cross-runtime fixture commands, production baselines, and application-code CI configuration. Stage 1 adds explicit native destruction and lifetime cleanup. Stage 2 supplied the first shared native transaction path. Stage 3 now replaces that alpha structural API with prospective Fabric descriptions, one atomic final-tree publication and committed ownership. Stage 4's scheduler MVP and focused CI stabilization are complete, including Linux application execution and a successful full hosted workflow on `fa12b22`. The bounded ordinary ubx-monitor Windows application slice now passes with synthetic receiver bytes and current local packages. Full Stage 0 benchmark coverage, controlled performance targets, broader hardware evidence and consistently green CI over subsequent changes remain open. See the [publication record](docs/engineering/fabric-publication-2026-09.md), [historical transaction record](docs/engineering/fabric-transactions-2026-09.md), [cleanup record](docs/engineering/fabric-cleanup-2026-09.md), [historical baseline](docs/engineering/fabric-baseline-2026-09.md), and [reproduction guide](packages/dear-imgui/npm/diagnostics/README.md). All ten lifetime defect IDs now execute passing gates, including abandonment and same-ID moves. Local verification and hosted coverage are recorded separately; consistently green hosted CI remains a milestone criterion. The GPUIX assessment identifies bridge correctness, lifecycle discipline, automation, and performance evidence as gaps to close while concentrating product work on Plot, Table, Map, and Canvas. The August Fabric upgrade and screenshot smokes provide foundations for this work. End-to-end performance advantages still require measurement. -Use ubx-monitor as the application validation target, with its CNO quality-series panel and configurable UI update rates as bounded showcase work. Review the milestone against the acceptance criteria below before expanding into durable replay and comprehensive automation (Stages 5–6). General shell and rich-text work remain driven by demonstrated application needs; additional language bindings remain out of scope. +The [ordinary ubx-monitor application slice](docs/engineering/ubx-monitor-application-2026-09.md) passed on Windows: current packed packages, complete application startup/typechecking, sustained synthetic NAV-SAT/NAV-PVT input, visible Plot/Table/Map/Canvas behavior and interaction, pause/resume, disconnect/reconnect and cleanup. The existing CNO panel is retained; configurable UI update rates remain follow-up work. Review the milestone against the acceptance criteria below before expanding into durable replay and comprehensive automation (Stages 5–6). General shell and rich-text work remain driven by demonstrated application needs; additional language bindings remain out of scope. --- @@ -57,9 +57,9 @@ The panels that make the app visually compelling and demonstrate XFrames' render - [x] Signal strength color coding on markers (dot radius scales with CNO) - [x] Used-in-fix vs tracked distinction (filled vs hollow markers) -### Signal Strength — Color Coding (in progress) +### Signal Strength — Color Coding (implemented; bounded Windows application validation passed) -PlotBar multi-series support is implemented in XFrames (mirroring PlotLine's existing `series` architecture). The remaining work is the ubx-monitor panel rewrite. +PlotBar multi-series support is implemented in XFrames (mirroring PlotLine's existing `series` architecture). The ubx-monitor panel from revision `571f556` now passes the bounded complete-application Windows scenario using current local XFrames packages. #### PlotBar Multi-Series (XFrames, done) @@ -74,7 +74,7 @@ PlotBar uses a `PlotBarSeries` struct and `std::vector m_series` #### CNO Quality Color Coding (ubx-monitor) -- [ ] Rewrite `SignalStrengthPanel.tsx` — split satellites into 4 quality-level series by CNO threshold: Weak (<20 dBHz), Moderate (20–30), Good (30–40), Excellent (>40). Each satellite appears in exactly one series. Use `PlotBar` with `series` prop, `showLegend=true`. Call `setSeriesData()` on each NAV-SAT update. +- [x] Implement `SignalStrengthPanel.tsx` with four quality-level series: Weak (<20 dBHz), Moderate (20–<30), Good (30–<40), Excellent (>=40). Each tracked satellite with positive CNO appears in exactly one series. Use `PlotBar` with `series`, a visible legend and `setSeriesData()` on satellite/sort changes. The panel at ubx-monitor `571f556` passed prior isolated parser/panel validation and the bounded current-package Windows full-App scenario, including native sort interaction. ### Signal Strength — Multi-Signal (blocked) @@ -125,7 +125,7 @@ WASM build migrated to emsdk 5.0.2 + Dawn WebGPU. Three canvas widget engines ## Phase 11 — Performance Optimization (mechanical optimizations done) -Viewport culling, periodic idle waiting (`glfwWaitEventsTimeout`), scroll extent fixes, and the mechanical optimizations below are implemented. Desktop still wakes at least every `1 / 30` second, and Wasm still requests a 30 Hz loop. Phase 12 replaces these policies and measures their effect. Further style/layout representation changes remain deferred until profiling justifies them. +Viewport culling, the historical periodic idle wait (`glfwWaitEventsTimeout`), scroll extent fixes, and the mechanical optimizations below were implemented in this phase. Phase 12 Stage 4 has since replaced the fixed desktop/browser 30 Hz policies with invalidation/deadline scheduling and recorded their measured effect. Further style/layout representation changes remain deferred until profiling justifies them. ### Stage 1 — Render Thread Unblocking & Hot Path Deduplication (done) @@ -172,7 +172,7 @@ These checks cover embedding compatibility, initialization, and screenshot captu --- -## Phase 12 — Fabric Runtime Hardening (Stage 1 cleanup implemented; Stage 0 coverage incomplete) +## Phase 12 — Fabric Runtime Hardening (Stages 1–4 implemented; milestone qualification incomplete) Detailed design: [Fabric-Compatible Runtime Hardening](docs/architecture/fabric-runtime-hardening.md). @@ -272,6 +272,22 @@ App smokes, the browser wrapper and 65-result Node/Wasm parity. Linux applicatio execution is now verified. This closes the focused CI pass; consistently green CI over subsequent changes and the broader milestone remain open. +### Ordinary ubx-monitor Application Qualification (bounded Windows slice complete) + +The [application record](docs/engineering/ubx-monitor-application-2026-09.md) +retains the reproducible setup/patch and 11 September results. The complete App +passed typechecking, ordinary development startup and one production Node/OpenGL +session: 1,200 NAV-SAT and 1,200 NAV-PVT messages over 60.016 seconds at 19.995 Hz +per type, populated Plot/Table/Map/Canvas captures, native sort/map zoom, +pause/resume, disconnect/reconnect, zero frames during ten settled disconnected +seconds, and ordinary disposal plus late-input/resource and native-shutdown +cleanup. Focused integration/ownership/tab/color fixes passed 380 Windows native +tests, affected package/Fabric/lifecycle checks, rebuilt Node/Wasm short suites +and 65-result parity. The original external checkout remains unchanged. +Shared-host timings are informational; synthetic transport/local tiles do not +qualify physical serial or public services. Configurable UI rates, controlled +performance, broader hardware and subsequent hosted CI remain follow-ups. + ### Milestone Review Gate — After Stages 0–4 - [ ] One native structural call and one native revision per accepted Fabric commit @@ -281,7 +297,7 @@ CI over subsequent changes and the broader milestone remain open. - [ ] Every accepted transaction can be correlated with a frame containing its revision, including transactions coalesced into the same frame - [ ] VS2022, Linux, and Wasm application-code CI is consistently green - [ ] Reproducible benchmarks meet the declared streaming targets and report remaining bottlenecks and measurement limitations -- [ ] ubx-monitor validates sustained real application use and demonstrates the delivery value of XFrames' Plot, Table, Map, and Canvas capabilities +- [x] ubx-monitor validates sustained real application use and demonstrates the delivery value of XFrames' Plot, Table, Map, and Canvas capabilities — bounded Windows Node/OpenGL scenario with synthetic transport and local tiles; see the [application evidence and limits](docs/engineering/ubx-monitor-application-2026-09.md) Review these results against the [strategic continuation gates](docs/strategy/gpuix-comparison-2026-08.md#reassessment-gates). If core correctness or performance targets remain unmet, prioritize the measured gaps and reassess scope before expanding the framework. Comparative workloads must reflect equivalent implemented functionality; use an application-composed timeline or grid where a GPUIX comparison is appropriate. diff --git a/docs/README.md b/docs/README.md index a689fc7..cc41c78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ The live [xframes.dev](https://xframes.dev) website is maintained in a separate ## Engineering and verification +- [ubx-monitor Ordinary Application Qualification](engineering/ubx-monitor-application-2026-09.md) — completed bounded Windows slice: current-package full-App integration, sustained synthetic telemetry across Plot/Table/Map/Canvas, connection lifecycle, cleanup and reproducible setup. - [Stage 4: Invalidation Scheduling](engineering/fabric-invalidation-2026-09.md) — delivered scheduler MVP, producer and lifetime boundaries, local validation, measurements, completed hosted CI stabilization and deferred qualification. - [Stage 3: Atomic Fabric Publication](engineering/fabric-publication-2026-09.md) — schema-v2 publication, committed ownership, visibility guarantees and historical acceptance evidence. - [Diagnostics and Reproduction Guide](../packages/dear-imgui/npm/diagnostics/README.md) — current fixture commands, platform prerequisites and measurement contracts. diff --git a/docs/engineering/fabric-invalidation-2026-09.md b/docs/engineering/fabric-invalidation-2026-09.md index 36ce131..786fe31 100644 --- a/docs/engineering/fabric-invalidation-2026-09.md +++ b/docs/engineering/fabric-invalidation-2026-09.md @@ -770,3 +770,16 @@ or physical byte-to-pixel latency. Mixed-DPI hardware transitions, every native drag/navigation gesture, hardware WebGPU and presentation are not independently measured here. Resolve these evidence gaps before expanding into Stage 5 replay or Stage 6 general automation. + +## Completed bounded application qualification slice + +The [ordinary ubx-monitor application record](ubx-monitor-application-2026-09.md) +extends the isolated CNO evidence to the complete App through its public Node +render/disposal entry. It covers current-package integration and whole-project +typechecking, sustained synthetic NAV-SAT/NAV-PVT input, visible +Plot/Table/Map/Canvas interaction, connection lifecycle, disconnected inactivity +and cleanup. The bounded Windows Node/OpenGL slice passed on 11 September 2026 +with synthetic receiver bytes, deterministic local tiles and preserved original +application settings. The record retains the reproducible patch/setup and results. +Controlled performance, broader hardware evidence and sustained CI history remain +separate milestone gaps; the completed CI stabilization is not being reopened. diff --git a/docs/engineering/ubx-monitor-application-2026-09.md b/docs/engineering/ubx-monitor-application-2026-09.md new file mode 100644 index 0000000..21268e2 --- /dev/null +++ b/docs/engineering/ubx-monitor-application-2026-09.md @@ -0,0 +1,347 @@ +# ubx-monitor ordinary application qualification + +Status: bounded Windows Node/OpenGL application slice passed on 11 September +2026. The executable task is in [goal.txt](../../goal.txt). Broader milestone and +controlled performance qualification remain open. + +## Purpose and starting boundary + +Validate the complete desktop application with current XFrames packages after +the [Stage 4 scheduler and CI stabilization](fabric-invalidation-2026-09.md). +This advances the [Stages 0–4 application criterion](../../ROADMAP.md#milestone-review-gate--after-stages-04). +It does not close the broader milestone or qualify controlled streaming performance. + +The completed CI pass at `fa12b2255fd1fed5a366c137f4003323c69475ee` passed all five +jobs in [run 34623926577](https://github.com/xframes-project/xframes/actions/runs/34623926577). +Its full-App smokes exercise the XFrames demo application. The separate +[ubx-monitor check](fabric-invalidation-2026-09.md#isolated-ubx-monitor-application-validation) +used a custom diagnostic host to mount `SignalStrengthPanel`, with 202 decoded +NAV-SAT messages and native PlotBar updates, including 200 sustained samples over +about 10 seconds. That remains useful panel evidence. It does not establish +ordinary startup, complete application typechecking, combined panel use or the +full application's activity and disposal behavior. + +Read-only inspection of `C:/dev/ubx-monitor` on 11 September found revision +`571f556` (`Tab indicators`), with only an untracked `AGENTS.md` in Git status. +Ignored settings and assets remain machine-local. Relevant source facts are: + +- `src/index.tsx` calls the public `render(App, assetsBasePath, fontDefs, theme)`. +- The manifest specifies React 18 and older published common/Node packages. + The previous isolated validation required React 19.2.3, style/config migrations + and a focused TypeScript check; the original whole-project check did not pass. +- `SignalStrengthPanel` already divides tracked satellites into `<20`, `20–<30`, + `30–<40` and `>=40` CNO bands, with legend and sorting. A panel rewrite is no + longer the next task. +- `App` includes the signal PlotBar, message Table, Map with position/trail/accuracy + overlays, and the sky-view JsCanvas in separate tabs. +- `useDataActivity` publishes a new activity object every second, and + `useUbxMessages` updates elapsed time and bandwidth. `SkyViewPanel` loads a + data-driven script without opting out of the Canvas default continuous mode. + Their full-app rendering consequences need measurement, not an assumed zero-frame + assertion whenever telemetry pauses. + +Recheck these facts at execution time and record any source changes. Do not copy +the original checkout's local serial configuration into the validation setup. + +## Deliverable and integration boundary + +Prepare an isolated application checkout with locally packed current-source +`@xframes/common` and `@xframes/node`, matching React/types, and a passing +whole-project typecheck. Preserve the original checkout. Retain the necessary +application migrations and setup as a reviewable patch/script or separate +application branch with reproduction instructions; ignored evidence alone is not +the deliverable. Keep the XFrames authoritative workspace lockfile and generated +Fabric snapshots unchanged unless a demonstrated prerequisite requires a change. + +Launch the actual App through the ordinary public Node render path, preserving +its event dispatch and disposal. A narrow diagnostic entry may retain the public +render function's disposer and observe native state. It must not replace the +application with a diagnostic composition or initialize a parallel Fabric host. +Use a deterministic serial transport substitute through the real connection, +parser and subscription path. A focused transport seam is allowed; rebuilding the +serial stack or adding a general automation framework is outside this slice. + +Keep current component behavior. Fix proven compatibility, lifecycle or redundant +update defects at their owner. The sky canvas may adopt the existing +`setContinuous(false)` contract after checking that its script is data-driven and +that data, resize and resource changes still repaint. Preserve visible stale-time +updates, actual animations and resource completion behavior. + +## Required bounded scenario + +Use a production Windows Node/OpenGL build with recorded source/package identity. +The minimum workload is 1,200 checksummed NAV-SAT and 1,200 NAV-PVT messages, +scheduled over at least 60 seconds at a requested 20 Hz per message type. Input +delivery must run independently of frame waits. Record elapsed time, actual +delivery rate, decoded counts, application updates and observed frames; account +for deliberate UI/frame coalescing and verify final sample state. Do not silently +discard source messages or infer 20 rendered Hz from 20 input Hz. + +Split some packets across transport callbacks and combine others in one callback. +Use synthetic positions and deterministic local map tiles/resources with recorded +identities so that public tile-service availability is not an acceptance dependency. +Exercise real native decoding/upload/render paths. Keep the actual sky-view script. + +| Gate | Required evidence | +| --- | --- | +| Integration | Whole-app typecheck and ordinary development startup pass with the packed packages, assets and theme; production scenario uses the same application integration. | +| Full application | Ordinary input visits Signals, Messages, Map and Sky View. Each has semantic populated state and an inspected screenshot; hidden state alone does not prove visible rendering. | +| Interaction | CNO sort or message filtering works through actual event dispatch; map pan/zoom updates the visible view and retains position/trail/accuracy overlays. | +| Sustained telemetry | Both message types traverse transport, actual parser and subscriptions. All scheduled messages are accounted for, retained histories stay within their declared limits, and the final visible data is current. | +| Pause and resume | Rendering settles between genuine status/deadline/resource changes; paused connected stale-time updates remain truthful. Resumed telemetry wakes the application and reaches submitted state. | +| Disconnect and reconnect | Connection state, subscriptions and parser/transport ownership remain correct in the same runtime; repeated connection use does not accumulate listeners or lose resumed input. | +| Disconnected inactivity | After status, input and resource work settles, a 10-second disconnected observation has no continuing constructed/submitted frames. Fix redundant app updates rather than suppressing native invalidations or disabling visible behavior. | +| Disposal | Await the ordinary render disposer and React effect cleanup. Native elements, hierarchy, subjects, JS mappings/registrations, telemetry listeners and scheduler/resource owners return to their documented empty baseline. Late old-transport packets and pending resource completions cannot revive the disposed tree. | + +Take cleanup measurements before process exit; forced termination is not disposal +evidence. Use bounded waits and retain errors/artifacts on failure. If the full +application exposes a proven XFrames defect, add a focused regression check and +run the affected shared Node/Wasm gates after repairing it. + +## Validation, evidence and stopping rule + +Record both repository SHAs and dirty changes, the application migration patch, +packed package identities, native build provenance, React/Node/toolchain versions, +OS/GPU/backend, assets, workload parameters and exact setup/run commands. Retain +semantic reports, screenshots, logs and measurements under ignored diagnostics +output; summarize actual passed/failed/unavailable checks in this record when run. +Report receipt-to-observed-submission p50/p95/p99/maximum with sample and coalescing +coverage when measurable. Do not label that interval physical byte-to-pixel or +presentation latency, or treat shared-host timing as a performance qualification. + +Reuse the completed CI evidence for unchanged XFrames code. Run affected package, +Fabric, diagnostics and native checks for changed seams; shared runtime changes +need relevant coverage in both bindings, and workflow changes need affected +hosted validation. Keep the existing 1,000-cycle gates and optional extended mode. +Do not repeat complete builds or benchmark matrices solely to refresh timestamps. + +Stop after one reproducible complete application scenario, necessary focused fixes +and evidence. A required blocked gate remains open. Other desktop platforms, +configurable UI update rates, controlled streaming targets, broader Map/Canvas +benchmarks, physical serial, hardware WebGPU, mixed-DPI/presentation and sustained +CI history are later qualification work. Stage 5 replay and Stage 6 general +automation remain deferred until the milestone review. + +## Integration artifacts and executed prerequisite checks + +The reviewable application changes are retained in +[`ubx-application.patch`](../../packages/dear-imgui/npm/diagnostics/ubx-application.patch). +[`ubx-application-setup.mjs`](../../packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs) +clones the original application, applies that patch, builds/packs the source +common/Node JavaScript packages around the current Release native binary, +installs the isolated dependency graph, and runs the complete typecheck and +serial lifecycle regression. It refuses to replace an existing checkout and +records original settings/instructions/dependency hashes without copying serial +settings into the integration. No registry release is involved. + +The application base is `571f5569bb923c3d4a8f37db8f8ada555323667a`; XFrames is +`5b76c83e5e5267522bed9935959e816c75573304` plus the reviewable working-tree +changes. Local package versions remain common `0.1.7` and Node `0.1.14`; version +numbers alone do not identify these local builds. The setup records tarball +integrities, native SHA-256, repository dirty diffs, font/script hashes and commands. +The original application's status remains only `?? AGENTS.md`. + +After validation, the application changes were committed as +`e7de9e25ff3f608e8bb2613e471d0b97f47d58f2` on +`qualification/current-xframes-application`. That branch is also available in +`C:/dev/ubx-monitor`; its checked-out `main`, settings and dependencies remain +unchanged. This qualification branch still requires the locally packed tarballs +prepared by the setup script. + +Demonstrated prerequisites and their owners: + +- Application: React `19.2.3`, matching React/Node types, Bundler resolution for + the existing `tsx` extensionless imports, nullable configuration parsing and + current `colors[ImGuiCol.Text]` styles. Current XFrames does not expose the + application's `TabItem.indicator`; the migration preserves activity/status + colors through the supported tab text style. The existing four-series CNO + feature is retained. The original tracked fonts and sky script are retained. +- Application: a narrow serial transport interface leaves connection state, + real `UbxParser.feed`, subscriptions and event callbacks intact. A reproduced + disconnect left three port listeners and ten parser listeners, and a late + byte callback threw on the cleared parser. Cleanup now detaches owned listeners, + guards superseded open callbacks and closes late opens. Generic parser messages + are forwarded once. A five-opening regression covers late bytes, late opens, + stale closes, reconnect and exact ACK delivery, with zero retained port listeners. +- Application: unchanged data-activity objects no longer trigger a publication + each second. Message rate decays during a pause and resets on disconnect; + connected stale seconds continue updating. A visible static sky probe generated + 91 frames in 1.5 seconds with continuous activity, so the panel now explicitly + selects `setContinuous(false)` for its unmodified data-driven script. +- Application: Console's append-only history was unbounded. Its existing widget + now rotates lifetime when JS history exceeds 65,536 UTF-16 code units, retaining + the latest 32,768 for replay. This bounds the native buffer too; the conservative + UTF-8 bound is 196,608 bytes. Message rows remain capped at 500, position histories + at 3,000 and the Map trail at 1,000 points. +- XFrames common: the public ImGui color enum had drifted from the native header, + visibly miscoloring tabs and later theme entries. It now matches the current + native order and aliases. The ABI regression compares all 65 names/aliases with + the actual dependency header. +- XFrames native: inactive tab headers used Yoga cursor positioning without + submitting content, causing the ImGui parent-boundary error. Tab headers now + retain ImGui's own cursor flow; the regression exercises actual tab frames. + Bounded native diagnostic summaries expose Map overlays/trail and retained + PlotLine/PlotScatter/Console sizes so application assertions can inspect native + state rather than infer storage from command counts. + +Completed checks: whole-app typecheck and five-opening serial lifecycle test +in a fresh final reproduction; literal development `npm start` and normal window +close (exit 0); production full-App scenario and awaited disposal; all 380 Windows +native tests in 35 suites, including the two added regressions; 21 bridge lifecycle +scenarios in each development/production mode; common build, Fabric/65-name ImGui +ABI checks and diagnostic typecheck; final rebuilt Node and optimized Wasm short +runtime suites; and 65-result binding parity. The short suites retain their three +streaming rates, three lifecycle cycles, native input, activity, resources and +shutdown checks. Existing hosted 1,000-cycle gates and optional extended mode are +unchanged; this working tree does not claim a new hosted pass. + +Early probes are diagnostic evidence, not full acceptance: some mouse clicks +missed while the laptop was in use. Subsequent probes separate pointer movement +from clicking, verify selected tab content and retry missed targets. Inspected +captures show the four populated panels. Desktop runs must be announced because +the PID-scoped Windows helper moves the real cursor and acquires focus. + +Evidence is under `packages/dear-imgui/npm/build/diagnostics/ubx-application/`, +with the final independent setup under `ubx-application-reproduction-final/`. The output +directories are ignored; the patch, setup, byte generator and application harness +are repository artifacts. Timing is informational on this actively used laptop. + +## Completed production scenario + +`production/result.json` reports `passed`, including the ordinary native shutdown +callback; `production.log` records exit 0 with no ImGui errors. This is one complete +App, one public Node renderer and its returned disposer. The harness observes the +real commit/internal-operation paths without replacing them. Its transport emits +wire bytes into the actual SerialManager and UbxParser. It does not inject hook +state. The local map fixture is a deterministic 2-by-2 color PNG, so the rendered +background is a repeated gradient rather than geographic cartography. + +| Acceptance | Executed evidence | +| --- | --- | +| Complete visible application | Inspected `signals-sorted.png`, `messages.png`, `map.png`, `map-zoomed.png` and `sky-final.png`, with matching semantic/native JSON. Four quality bands, 500 message rows, loaded local map textures and position/trail/accuracy overlays, and four satellites drawn by the unchanged sky script are populated. | +| Native interaction | Signals checkbox sorts the four bars descending through the real callback; map wheel changes zoom 15 to 16, preserves all three overlay types and updates the displayed zoom. The zoom capture precedes tile completion and shows the loading state; the earlier map capture shows decoded/uploaded tiles. No input retry was required in the production run. | +| Telemetry | 1,200 NAV-SAT plus 1,200 NAV-PVT packets scheduled independently of frame polling over 60,000 ms; delivery finished after 60,015.993 ms, or 19.994670 Hz per type. Warm-up, resume and reconnect add three packets per type: 1,203 decoded each and 2,406 generic messages, with unique sequence IDs. | +| Chunk boundaries | Across the session, 601 packet pairs use a split NAV-SAT prefix followed by the remainder plus NAV-PVT; 602 pairs are coalesced into one callback. Total: 1,804 raw callbacks and 197,292 bytes. No source packet loss or duplicate decoded sequence was accepted. | +| Bounded history | Observed high-water: 500 messages, 1,000 map trail points, 1,200 position/scatter points and 1,200 line points across retained series, and 67,322 native Console bytes. Declared limits are asserted throughout; a focused native test separately crosses the plot and map limits. Parser pending bytes were zero at observation polls; this is not its instantaneous peak during a split callback. | +| Pause/resume and Canvas wakes | After connected activity settled, stale-time text advanced during a 2.2-second observation with two submitted frames and zero continuous owners. Resumed data reached both observed native sequences. The real static sky script repainted for data and resize; a local Canvas texture completion also produced a covering frame with one live texture. | +| Connection lifecycle | Native disconnect/reconnect callbacks changed state in the same App. Old transport/parser listeners reached zero; application listener counts did not grow after reconnect. Both resumed packet types reached native state. | +| Disconnected inactivity | After input, stale status and resource work settled, the 10-second disconnected observation constructed and submitted zero frames. The capture happens after the measured interval. | +| Disposal and late work | Four real HTTP map requests were held pending at unmount. After awaited public disposal/effect cleanup, old ports received late bytes/close events and held resources were released. Native generation stayed unchanged during the settled late-work check; resources and ownership reached the baselines below. Normal window close then completed native shutdown. | + +The sustained interval submitted 3,498 frames. Its frame count includes ordinary +input, status and resource activity; it is not a promise of 20 rendered frames +per second. Session operation observations include 1,200 Table data replacements, +1,200 each of map marker/overlay/trail updates and Canvas data updates, 1,201 bar +series changes, 1,200 scatter replacements and 3,600 line appends. React may +coalesce decoded messages before widget publication. `production/stream.json` +retains receipt and native observation records, including the final scheduled +sequence check for both message types. + +| Receipt to observed submitted state (ms) | Samples / scheduled | Unobserved or coalesced between polls | p50 | p95 | p99 | Maximum | +| --- | --- | --- | --- | --- | --- | --- | +| NAV-SAT / bars | 1,176 / 1,200 | 24 | 35.711 | 53.652 | 67.507 | 114.698 | +| NAV-PVT / map position | 1,174 / 1,200 | 26 | 33.556 | 50.295 | 65.926 | 114.635 | + +Observations poll the last submitted native state every 10 ms; these intervals +include polling delay and do not distinguish every intermediate update coalesced +between polls. They exclude physical serial transport and GPU presentation. +Session process CPU totals were 68.656 s user and 10.922 s system; RSS moved from +252,731,392 to 262,144,000 bytes. These shared-host, single-session observations +are not steady-state CPU, a leak trend or controlled performance evidence. + +After public disposal, native elements/subjects were 0, hierarchy entries 1 +(the empty root), and scheduler owners 5, matching pre-App baseline. Active +owners/deadlines, queued/active map work and live/retired textures were 0. Four +idle map-worker threads and 13 window callbacks remained owned by the still-live +runtime. JS Fiber IDs, committed descriptions, staged/candidate nodes, pending +events, all forward/reverse/widget registrations, tracked application intervals, +telemetry listeners and both ports' parser/transport listeners were 0; event +subscription and registration service were closed/disposed. All 2,454 Fabric +publications applied, with no failed publication. At ordinary native shutdown, +scheduler owners, platform callbacks and map-worker threads became 0, workers +were stopped, wake delivery detached, and the surface status was `disposed`. + +## Reproduction and identity + +The production environment was Windows `10.0.26200`, Node `v24.14.0`, React +`19.2.3`, AMD Ryzen 7 5700U / Radeon Graphics, OpenGL `4.6.0 Compatibility Profile +Context 23.19.23.13.250826`. Native targets used VS2022 (developer tools +`17.14.35`) and existing build caches. Wasm reused `xframes-emsdk` with +`XFRAMES_FAST_BUILD:BOOL=OFF`, Emscripten `5.0.2`, and Headless Edge `152.0.0.0` +with SwiftShader; its browser regression is software WebGPU coverage. + +| Artifact | SHA-256 | +| --- | --- | +| Retained application patch | `61b1a9749a1ffc522d347a354a60a5c6e370f728c37367b0b7e2d5036236cd38` | +| Release Node binary, also installed in production and reproduction | `d9347b36fa7d95455cc4a18e9cf288b911931108e46d85c5114d13fa11f56380` | +| Production common 0.1.7 tarball | `ce6c9956a5894973ea3fe11f2992b21d905ffd3425ce8590ae01d1b231b7a816` | +| Production Node 0.1.14 tarball | `103d9089affa1817d0de424066ee24b8ae1bfaed14e5f2ae9af888c3aea12d1d` | +| Original sky script | `59759add2610a5ec8b2e9777657631f5ea7ae9d12d8e92f90c87c9e415279df7` | +| Local PNG fixture | `4a93ce814b499287294c61757580c670ca5ee8fc9147beb7d9d9544a827f84f1` | + +Reproduction rebuilds and repacks the JavaScript packages, so consult its own +`provenance.json` for tarball integrity and installed lock hash. It records source +revisions, dirty diffs (including new application files), untracked XFrames source +hashes, original AGENTS/manifest/lock/settings preservation, and font/script/native +hashes. `commands.json` retains every exact setup command and exit status. The +final handoff manifest/diffs under `ubx-application/` also capture documentation +updates after the setup snapshot. Required migrations are in the retained patch, +not just the ignored checkout. The first reproduction predates the final isolated +tile-cache path; use `ubx-application-reproduction-final` as the final setup proof. + +From `packages/dear-imgui/npm`, with the current Release native target built: + +```powershell +node diagnostics/ubx-application-setup.mjs --source=C:/dev/ubx-monitor --output=C:/dev/xframes/packages/dear-imgui/npm/build/diagnostics/ubx-application-reproduction-final +# For another reproduction, select a fresh output directory. +$env:NODE_ENV='production' +$env:TSX_TSCONFIG_PATH='diagnostics/tsconfig.json' +$env:XFRAMES_UBX_APP_DIR='C:/dev/xframes/packages/dear-imgui/npm/build/diagnostics/ubx-application/app' +$env:XFRAMES_DIAGNOSTICS_DIR='C:/dev/xframes/packages/dear-imgui/npm/build/diagnostics/ubx-application/production' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario +``` + +The production command above identifies the actual measured checkout. To rerun +using the fresh setup, select its `app` directory and a new evidence directory. +For ordinary startup, clear `TSX_TSCONFIG_PATH`, set `NODE_ENV=development`, then +run `npm start` inside that app. `ordinary-startup.ps1`, its stdout/stderr and +`ordinary-startup.json` retain the executed plain-startup check: native window +visible for five seconds, normal `WM_CLOSE`, exit 0, no forced termination. +Announce each desktop command before it takes focus or moves the cursor. + +Executed changed-source build/check commands (from npm root except the batch): + +```powershell +& 'C:/Program Files/Microsoft Visual Studio/2022/Community/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe' --build node/build --config Release --target xframes --parallel 4 +cmd /c build/diagnostics/ubx-application/build-tests.cmd +& ../cpp/tests/build/Google_Tests_run.exe --gtest_output=xml:build/diagnostics/ubx-application/native-tests-final.xml +docker run --rm --name xframes-ubx-app-wasm-build -v C:/dev/xframes:/src -v xframes-ccache:/ccache xframes-emsdk bash -c 'cd /src/packages/dear-imgui/cpp/wasm && cmake --build build-wasm --target xframes -j4' +$env:XFRAMES_DIAGNOSTICS_DIR='build/diagnostics/ubx-application/lifecycle' +npm run test:lifecycle +npm run diagnostics:typecheck +$env:NODE_ENV='production' +$env:XFRAMES_DIAGNOSTICS_DIR='build/diagnostics/ubx-application/regression-node-final' +npm run diagnostics:node +$env:XFRAMES_DIAGNOSTICS_DIR='build/diagnostics/ubx-application/regression-wasm-final' +npm run diagnostics:wasm +node diagnostics/transaction-parity.mjs build/diagnostics/ubx-application/regression-node-final/result.json build/diagnostics/ubx-application/regression-wasm-final/result.json +``` + +The setup executes common/package builds, ABI/Fabric tests, package packing and +both application checks. Build/test output is retained in `build-*-final.log`, +`native-tests-final.log/xml`, `lifecycle/`, `diagnostics-typecheck-final.log`, +`regression-*-final/`, and `parity-final.log`. Expected invalid-image fixture +messages occur in the short runtime resource tests; both suites finish passed. + +Exploratory failures remain in probe/typecheck/build logs: incompatible original +application APIs/types, the native inactive-tab assertion, stale serial ownership +and missed clicks motivated the focused changes. The first plain-startup wrapper +also failed to retain the Windows process exit code; retaining its process handle +fixed the observer, and the repeated startup returned 0. These failed observations +are not counted as successful acceptance. No final required gate is blocked. + +This result qualifies the bounded synthetic Windows application slice. Physical +serial, public tile services, other desktop environments, controlled UI update +rates/performance, hardware WebGPU, mixed DPI, GPU completion/presentation, +equivalent Electron/GPUIX comparisons and consistently green CI over later changes +remain unqualified here. Stages 5–6 and the broader milestone remain open. diff --git a/goal.txt b/goal.txt index 64e2d2b..6e05839 100644 --- a/goal.txt +++ b/goal.txt @@ -1,125 +1,45 @@ /goal -Implement the next Phase 12 vertical slice: Stage 4 revision-aware invalidation scheduling and frame correlation across ordinary Node/OpenGL and Wasm/WebGPU applications. +Deliver the next Stages 0-4 qualification slice: sustained ordinary ubx-monitor application use with current XFrames packages. Primary outcome: -A settled, inactive application stops constructing and submitting native frames. A committed publication, visible imperative change, input event, resource completion, screenshot request or real activity deadline wakes rendering promptly. Active interaction follows the backend/display cadence without the current fixed 30 Hz policy. Invalidation arriving while the renderer decides to sleep, constructs a frame or submits work cannot be lost. - -Every successfully submitted frame identifies the coherent native structural revision and invalidation generation represented by its draw data. Multiple publications may be covered by one later frame; a synchronous publication acknowledgment still means applied state and cleanup, not physical presentation. Preserve the complete Stage 3 publication/lifetime path while replacing its rendering policy. Deliver the scheduler, producer integration, correlation, deterministic tests, real-runtime gates and comparable measurements together. - -Alpha compatibility policy: -XFrames has been alpha from the outset and has made no stability commitment. Backward compatibility is not a default acceptance requirement: retain compatibility only for an identified consumer or migration need that justifies its cost. Do not preserve the fixed render loop or add a parallel scheduler merely to retain earlier alpha behavior. Keep the Stage 3 schema-v2 publication contract, public React components, imperative widget APIs and initialization/ready callbacks as scope choices for this slice, not a general stability promise. Make any narrowly necessary diagnostic or activity-policy API change explicit, migrate callers and tests, and reject unsupported contracts rather than keeping a second implementation. Do not restore schema v1 or the removed immediate structural exports. +Make the complete desktop application reproducibly launch through its ordinary @xframes/node render entry, consume repeatable synthetic receiver bytes, and demonstrate its existing Plot, Table, Map and Canvas panels together. Verify interaction, pause/resume, disconnect/reconnect and cleanup in one runtime. Fix only demonstrated integration or lifecycle prerequisites. This is a bounded application correctness slice, not closure of the broader milestone or a controlled performance qualification. Read first: -- ROADMAP.md, especially Phase 12 Stage 4 and the Stages 0-4 milestone review gate. -- docs/engineering/fabric-publication-2026-09.md for the delivered Stage 3 contract, ownership, visibility/lock boundaries, failure policy, acceptance audit and production measurements. -- docs/architecture/fabric-runtime-hardening.md for invalidation sources, lost-wakeup prevention, ImGui deadlines, frame correlation and instrumentation. Distinguish its full-target sketches from the implemented Stage 3 contract; durable replay, total ordering of all runtime messages and general automation are not prerequisites for this slice. -- docs/engineering/fabric-transactions-2026-09.md, fabric-cleanup-2026-09.md and fabric-baseline-2026-09.md in the same directory for historical comparisons, lifetime fields, fixture parameters and declared streaming/idle targets. -- docs/strategy/gpuix-comparison-2026-08.md for measured continuation gates and the technical-visualization use case. -- packages/dear-imgui/npm/diagnostics/README.md and packages/dear-imgui/npm/FABRIC_EMBEDDING.md for actual build/test commands, supported toolchains, workspace rules and measurement limits. -- Current renderer, resource, input and diagnostic code before selecting the scheduling mechanism. Do not infer that every producer already wakes merely because structural publication does. - -Starting evidence (historical results, not a substitute for current verification): -- Commit 2cbb37387aeab3e425867ae756f59c5e9a80dc29 delivered Stage 3 after Stage 2 commit 476e2b3 and Stage 1 commit 62f46a4. The Stage 3 record's references to then-uncommitted changes describe its verification session; Stage 3 is now committed. -- Ordinary Fabric creates/clones/child sets are prospective. completeRoot submits one schema-v2 final-tree publication. Native hierarchy/element locks cover preflight, mutation, Yoga ownership, reachability deletion and revision advancement. JS installs committed ownership only after acknowledgment; rejected or failed publication cannot report successful completion. -- Surface 0 is the only supported surface. Native structural sequence/revision counters are uint64 decimal strings; successful empty/no-op publications advance once, rejection never advances, and unexpected application failure quarantines the surface. Same-ID moves preserve Element/Yoga/subject/data identity. Keep these guarantees. -- Stage 3's complete Windows and Linux native suites each passed 343 tests; 21 isolated Fabric scenarios passed in each development/production process, with all ten XF-LIFE IDs executing and passing. Refreshed Node/Wasm artifacts matched 64 shared results, including all three Canvas engines. -- Both real runtimes passed 1,000 ordinary lifecycle cycles, 1,000 abandoned candidates and 1,000 native same-ID moves. All ten original lifetime deltas and new committed/staging/retained-candidate counts returned to baseline. Empty container 0 retains one hierarchy entry and no Element/Yoga node. The ordinary mounted fixture has 8 elements, 9 hierarchy entries, 2 subjects, 8 Fibers, 7 public mappings and 2 registrations. -- The Stage 3 stress/production reports contained 62 wire results and preceded an isolated Canvas bootstrap error-path correction; the refreshed 64-result integration used three lifecycle cycles. Preserve that evidence distinction and run the complete current fixture for this slice. -- Desktop BeginRenderLoop still calls glfwWaitEventsTimeout(1.0 / 30.0) and renders after every return; Emscripten still requests a 30 Hz main loop. Stage 3 posts one desktop wake per non-rejected publication. These mechanisms do not yet provide dirty/active scheduling. -- Completed diagnostics capture native tree state during draw construction and publish that snapshot after backend draw submission. They currently lack structural revision/invalidation coverage. Existing fixture helpers often wait for an arbitrary newer frame, including after unchanged state; those assumptions need deliberate migration when idle frames stop. -- Stage 3's comparable ordinary fixture reduced structural calls from 42 to 6 across six completeRoot publications. Its separate 14-node workload requests 240 React updates per repetition, producing 200 publications/calls and 40 bailouts. Keep the batching proof independent of scheduling/frame coalescing. -- Production regressions remain: Node regular 120 Hz input achieved 76.25-78.17 Hz; regular idle CPU was 13.26% of one core, with substantial cross-run variation; Wasm's 100,000-row observation maximum reached 283.67 ms. These are observed limits, not isolated causes or proof of presentation latency. -- At drafting on 8 September 2026, Stage 3 hosted run https://github.com/xframes-project/xframes/actions/runs/34245396897 was in progress. The preceding completed Stage 2 run 34219862696 failed in its Wasm WebGPU fixture after a successful build, skipping browser smoke/parity. Inspect the current run and subsequent hosted status afresh; local success and CI configuration are not hosted coverage. - -Deliverables: +- docs/engineering/ubx-monitor-application-2026-09.md for the source snapshot, required scenario, acceptance matrix and evidence limits. +- ROADMAP.md, especially the next application slice and the Stages 0-4 milestone review gate. +- docs/engineering/fabric-invalidation-2026-09.md, especially isolated ubx-monitor validation, the MVP boundary and completed CI stabilization. +- packages/dear-imgui/npm/diagnostics/README.md, diagnostics/ubx-telemetry.ts and the ordinary Node render/disposal path. +- The actual ubx-monitor checkout's AGENTS.md, package manifest, entry point, App, SerialManager, hooks and affected panels before choosing migrations or a transport seam. -1. Establish one shared invalidation and frame-completion model. - - Define monotonic invalidation generation, the generation covered by a completed frame, structural revision represented by that frame, dirty state and active/deadline reasons. Structural revision orders structural publications; resource/input/imperative invalidation must not increment it or create extra structural transactions. - - A successful publication, including accepted empty/no-op publication, requests rendering at publication scope. Rejected preflight and abandoned Fabric work must not create a successful-publication invalidation. Define the terminal wake/diagnostic behavior of quarantined surfaces without repeatedly drawing partial content as healthy. - - Publish work before its wake and synchronize generation capture with the state it describes. A renderer must never mark generation N covered if its captured tree/resources exclude work represented by N. Capture the native revision with draw-state construction under the existing visibility boundary, not by reading the latest revision after submission. - - Prove the transition into waiting and the transition from frame submission back to clean are race-safe. Work arriving before sleep, between checking state and waiting, during rendering, or after the draw snapshot but before completion remains pending or wakes another iteration. A Boolean cleared after rendering or an unproved generation check is insufficient. - - Preserve structural dispatch -> serialized subject -> hierarchy -> elements lock order. Scheduler wakeups, backend callbacks and frame observers must not introduce tree-to-dispatch lock inversion, recursively acquire tree locks, or invoke application JS under them. - - Keep scheduling correctness available with expensive diagnostics disabled. Define initialization, enable/disable, counter exhaustion and runtime disposal behavior; diagnostic resets must not reset ordering or lose pending work. Keep counters lossless across bindings. - - Bound scheduler reasons, active registrations, deadlines, pending notifications and correlation records. Coalescing wakeups/frame opportunities is permitted; discarding committed state or underlying imperative data is not. +Starting evidence (recorded on 11 September 2026; inspect current checkouts before work): +- Stage 4 scheduler MVP: 1de105f1d8479337e78a083b8c6645416c301c23. CI stabilization: fa12b2255fd1fed5a366c137f4003323c69475ee. Evidence documentation: 5b76c83. +- Application workflow https://github.com/xframes-project/xframes/actions/runs/34623926577 passed all five jobs on fa12b22, including both 378-test native suites, required 1,000-cycle runtime workloads, inactivity/activity gates, App smokes and 65-result Node/Wasm parity. Reuse this evidence for unchanged code; the startup and SwiftShader issues are resolved. +- The previous external-app check passed 202 NAV-SAT messages through the real parser/subscription/CNO panel, including 200 samples over about 10 seconds, idle/resume and cleanup. It used a custom diagnostic host and an isolated migrated panel import graph. It did not launch the complete ordinary App or pass its whole-project typecheck. +- C:/dev/ubx-monitor was at 571f556 with an untracked AGENTS.md. Its manifest still specifies React 18 and older published XFrames packages. SignalStrengthPanel already contains four CNO bands and a sort toggle; do not implement that feature again. The full App also contains MessageView, MapPanel and SkyViewPanel. -2. Replace desktop periodic rendering with event/deadline scheduling. - - Integrate the shared model into the actual ImGuiRenderer/Node path. Wait indefinitely when clean and inactive; use the nearest genuine deadline when one exists. Dirty work and active interaction must not inherit a fixed 30 Hz wait. Do not leave a periodic rescue render that conceals missing producers. - - Preserve and correctly chain GLFW/ImGui backend callbacks for mouse motion/buttons, wheel, keyboard/text, focus, window refresh/exposure, resize and content scale. A real input event must wake a clean renderer and reach ImGui/event dispatch without replacing required backend handling. - - Cover first frame after ready, framebuffer/window-size changes, minimize/restore, zero-sized framebuffer and window close while idle. When drawing is temporarily impossible, retain pending state without a busy retry loop or claiming a successful submitted frame. - - Process render-thread work such as image/texture uploads before the corresponding frame can be marked covered. A queue processed incrementally must keep remaining work scheduled until drained; one initial wake must not strand later jobs. - - Use monotonic time and an explicit policy for ImGui DeltaTime after a long idle interval. Track real deadlines for cursor blink, key repeat, tooltip/hover delays, drag/scroll/navigation activity and widget timers. Do not keep all focused or hovered widgets permanently active merely to avoid the audit. - - Preserve screenshot capture and normal shutdown/ready behavior. Screenshot requests made while clean must complete or fail explicitly; a scheduler callback must not retain or access a destroyed renderer/window. +Bounded work: +1. Prepare a reproducible application integration. + Preserve the original external checkout, its untracked instructions, settings and dependency graph. Use an isolated checkout with locally packed current-source common/Node packages and matching React/types. Resolve the complete application's actual type/API/configuration mismatches, then pass its whole-project typecheck and ordinary startup. Retain a reviewable, reproducible setup and application patch; do not leave required migrations only in ignored local files. Record both repositories' revisions, dirty diffs and package/build provenance. -3. Replace the browser 30 Hz policy with dirty/active requestAnimationFrame scheduling. - - Use the same invalidation/coverage semantics through the actual Wasm entry point and ReactNativeWrapper. Follow browser animation-frame cadence while dirty or active, and skip ImGui preparation, native draw construction and WebGPU submission when clean. - - Prefer pausing/cancelling the animation-frame loop when inactive. A lightweight browser callback that only checks state is acceptable if justified and measured separately from rendered frames; it must not retain the old fixed-frequency native render loop. - - Browser input, JS publications/imperative calls, async resource callbacks, resize/scale and visibility restoration must restart or schedule rendering after the loop becomes clean. Preserve the existing bounded deferred native-to-JS event drain. - - Handle hidden tabs, zero-sized/minimized surfaces, visibility return and delayed browser callbacks without spinning or losing pending invalidation. Do not promise a frame while the platform suspends rendering; retain its revision/generation and deliver it when renderable again. - - A skipped/failed surface acquisition or WebGPU submission must not advance completed-frame coverage. Define bounded retry on recoverable conditions and honest terminal behavior for unrecoverable device failure; broad device-recovery redesign is outside scope. - - Teardown, Strict Mode wrapper reuse and ordinary remount must not accumulate animation callbacks, timers, event listeners or stale renderer references. No second browser-only state/ownership engine. +2. Exercise the ordinary complete App. + Extend or accompany the existing telemetry harness using the public Node render entry and its returned disposer. Keep the actual App, event callbacks, SerialManager connection/parser/subscription path and widget operations. Substitute only the physical serial transport with a bounded deterministic source; do not inject decoded hook state or use a second custom Fabric host as full-app evidence. Supply checksummed NAV-SAT and NAV-PVT bytes, including chunk splits and coalesced packets, and exercise connect/disconnect/reconnect without opening a physical receiver. -4. Integrate every existing invalidation producer and time-dependent consumer. - - Produce a source-level inventory mapping each state-changing path to its invalidation reason, owning runtime/widget lifetime, wake mechanism, queued render work and regression test. Cover structural publications, all visible imperative widget paths, native interaction, window/style/font/debug changes and screenshots. - - Cover Image fetch/decode/upload, desktop image jobs, Map tile completion/upload and visible prefetch progress, and QuickJS/Lua/Janet Canvas script/data/texture completion. Exercise actual completion paths after the renderer has become idle; unrelated mouse movement or a periodic frame must not rescue them. - - Account for existing work that advances only during Render: Map's zoom debounce/fetch deadline, queued Canvas scripts/textures, ImGui layout/interaction settling and time-dependent drawing. Define a bounded settling policy based on real pending work so a first visible frame does not require a later unrelated event. - - Define the Canvas activity policy explicitly. Time-dependent scripts must continue advancing while active; static content must have a way to settle. Any narrowly necessary explicit redraw/continuous-activity control belongs to this slice and must be documented, migrated and tested. Do not infer arbitrary script time dependencies or silently freeze existing animations. - - Keep asynchronous completion and active/deadline registrations tied to the correct runtime and widget lifetime. Removal, replacement or shutdown must release them; late completion cannot target a reused native ID, wake a destroyed runtime or leave a permanent active reason. Preserve registrations/resources across a same-ID move. - - Fix resource-lifetime or synchronization defects narrowly where needed for safe producer integration, including callbacks that currently capture a destroyed widget. Do not expand into a general resource loader, total-order command protocol or unrelated asynchronous shutdown rewrite. - - Queries and ordinary diagnostic polling must not invalidate the renderer. Enabling diagnostics, refreshing debug state or explicitly requesting a frame may schedule bounded work under a documented policy; a diagnostic test must not keep its own idle workload alive. +3. Run the bounded application scenario in the linked plan. + Use one production Node/OpenGL session with at least 60 seconds of scheduled telemetry at a requested 20 Hz for each of NAV-SAT and NAV-PVT (1,200 messages of each type). Drive input independently of frame observation; report actual delivery rate and explicit coalescing. Visit and verify populated Signals, Messages, Map and Sky View panels through ordinary input, including a sort/filter action and a map navigation action. Use deterministic local map resources and the actual Canvas script. Pause/resume, disconnect/reconnect, then unmount and deliver late input/resource work. Assert bounded histories, listeners, scheduler/resource owners and native/JS lifetime cleanup, with a 10-second disconnected inactivity observation after genuine work settles. -5. Expose bounded, truthful revision-to-frame correlation and measured costs. - - Extend shared frame/query diagnostics and binding types with frame identity, captured structural revision, covered invalidation generation, current pending state and relevant reason/deadline counters. Preserve the distinction between live applied state, constructed draw data, backend submission and any observable swap/presentation endpoint. - - Define completion as a submitted frame containing revision >= the requested revision, with state/ownership assertions where required. Several publications may share one frame; an intermediate tree need not be individually displayed. Record first inclusion where measured rather than pretending one frame corresponds to exactly one commit. - - Imperative PlotBar/Table data does not advance structural revision. Correlate it using generation coverage and/or instrumented sample identity with native application times, so a frame carrying the right structural revision but stale data cannot satisfy a data-frame assertion. Avoid imposing a new public total order on all widget commands. - - Retain source/application receipt, React request/completeRoot, serialization, native parse/validation/application, invalidation, wake, frame construction and backend submission timing where measurable. Compute durations using one clock each; do not subtract JS and native absolute timestamps without a valid clock relationship. - - Report p50/p95/p99/maximum, publication/frame coalescing, wake counts, frames constructed/submitted, skipped opportunities, active/deadline reasons, idle CPU and lifetime counts. Distinguish browser callback opportunities from actual native frames; do not invent skipped callbacks when a paused loop produced none. - - Keep optional histories and latency samples bounded, with explicit truncation/overflow accounting. Report instrumentation overhead and unavailable intervals. No replay subjects containing completed trees, no durable trace recorder, and no claim of GPU completion or physical presentation based on queue submission, glDraw/glfwSwapBuffers return, or a screenshot alone. - - Migrate diagnostics and smoke wait helpers from arbitrary newer-frame assumptions to revision/generation/sample coverage or a narrowly explicit frame request. A rejected publication or repeated query should not require an unsolicited frame. Preserve unchanged-state/parity assertions without keeping the old periodic loop to satisfy tests. +4. Repair demonstrated blockers at their owner. + Preserve visible stale-time/status behavior while avoiding redundant unchanged publications. A data-driven sky canvas may explicitly use the existing non-continuous mode if its real script has no animation; verify data, resize and resource wakes. Do not force zero frames while visible time-dependent state or genuine resource/activity work is changing. Keep fixes focused on the application, package integration or a proven runtime defect, with a targeted regression check where justified. -6. Turn scheduling correctness into native and actual-application regression gates. - - Add deterministic shared-native scheduler tests with a controllable monotonic clock and coordinated barriers/latches. Cover clean-to-dirty, repeated/coalesced invalidations, each sleep/completion race, deadline creation/cancellation, counter limits, first frame, rejected publication, quarantine, disposal and skipped submission. Sleeps or occasional snapshots cannot establish absence of a lost wakeup. - - Keep Stage 3's deterministic visibility test through real subject delivery. Add a revision/coverage race proving that a publication after frame-state capture cannot be relabeled as included in that earlier frame, and that its work remains scheduled. - - Run equivalent fixtures through current-source Node/OpenGL and Wasm/WebGPU after a proved settled/idle state. Independently stimulate publication, imperative data, native input, delayed resources, timer/deadline activity, screenshot capture and resize/restore. Require semantic state, generation/revision coverage, timely wake and return to inactivity after each finite activity. - - Use deterministic local assets and controlled delayed resource responses for Image/Map/Canvas gates, not public tile servers or timing luck. Cover multiple queued jobs, failure/completion, removal before completion and time-dependent Canvas/Map behavior on each supported backend. - - Prove active work is not governed by the former 30 Hz policy using deterministic scheduling assertions and measured real-runtime cadence on declared adapters. Software rendering and display limits must be reported rather than turned into misleading universal FPS assertions. - - Preserve all ten XF-LIFE gates, rejected/failed JS completion behavior, real binding parity, one-call/one-revision publication accounting, ordinary wrapper/Strict Mode lifecycle, populated data, stale handles/events and diagnostics-disabled behavior. - - Re-run 1,000 ordinary lifecycle cycles with pending/abandoned work and native same-ID moves on each runtime. Assert zero growth in all ten original lifetime fields and in new scheduler callbacks/listeners/timers/deadlines/active owners/correlation state after acknowledged unmount and a submitted covering empty frame. No process exit, forced GC or destroyed survivors as cleanup substitutes. +5. Record and validate the result. + Capture semantic state, populated screenshots, exact commands, messages/updates/frames, unavailable metrics, cleanup counts and failures in the application engineering record. Run the full app typecheck, ordinary development startup and production scenario. Run affected XFrames package/Fabric/diagnostic/native checks for any source changes, rebuilding affected targets when needed. A shared runtime change requires relevant Node/Wasm regression coverage; a workflow change requires its affected hosted gates. Preserve the existing 1,000-cycle gates and optional extended mode. Update roadmap status only to the extent the new evidence supports it. -Validation, measurements and acceptance: -- Build current-source native artifacts with supported VS2022 and Docker/Emscripten toolchains. From packages/dear-imgui/npm, pass fabric:verify, build:common, diagnostics:typecheck, test:lifecycle, build:node and build:wasm, all native tests on Windows/Linux, and the expanded real Node/Wasm parity/scheduling gates. Preserve applicable Stage 3 coverage; the old test count is not itself an acceptance criterion. -- Require ordinary Node development/production and full-App browser production smokes, actual Wasm wrapper lifecycle, populated screenshots and revision/generation-tagged semantic evidence. A fake-only scheduler, manually calling Render from tests or direct-wire-only fixture does not complete this slice. -- For a quiet fixture with no active animation/deadline/resource/input, prove zero continuing native frame construction/submission after bounded settling. Measure an additional 10-second idle interval and target CPU below 1% of one core on the declared reference machine. Record remaining browser callbacks separately. Diagnostic polling must not cause frame growth. -- For each supported wake source, begin from inactivity and demonstrate a covering frame within a bounded test timeout, without unrelated rescue input. Test repeated transitions into and out of inactivity, including teardown/remount and wake races. Keep correctness gates independent of universal host timing thresholds. -- Preserve exact Stage 3 comparable three-repetition production parameters: regular 1,000 initial rows, 128 points, rates 20/60/120 Hz, duration 3,000 ms, warm-up 1,000 ms, idle 2,000 ms, zero stress cycles; extended 100,000 rows, duration 1,000 ms, warm-up 200 ms, idle 1,000 ms, zero stress cycles. Collect the additional 10-second idle/activity tests separately so historical comparisons remain honest. -- Use optimized native binaries and production React, run measurement workloads sequentially without concurrent task-owned builds, and identify host/adapter/toolchain/source revision. Distinguish Node hardware OpenGL, Linux software GL, SwiftShader WebGPU and any hardware WebGPU results. Reuse historical reports only with their stated fixture/source limits; capture a pre-change control when a new comparison needs it. -- Preserve the representative 14-node React structural workload and imperative streaming accounting. Report requested React updates, actual completeRoot publications, native calls/revisions and submitted frames separately. Presentation coalescing may reduce frame count; every underlying native update and final sample must remain accounted for. -- Evaluate declared streaming targets: at least 95% of requested 20/60/120 Hz input rates, observed-frame p95 below 50 ms and p99 below 100 ms at 60/120 Hz on the reference environment, and no final-sample timeout. Report requested input, achieved input, observed samples/coalescing and render cadence independently; 120 input Hz is not a 120 FPS promise. -- Investigate remaining missed targets, regressions and long tails with reproducible stage timings. Resolve scheduling/coverage defects before acceptance; state separately any measured non-scheduler bottleneck and unmet performance/milestone target. Do not weaken workload/data accounting, relabel observation latency as byte-to-pixel latency, or require an unsupported claim of a universal timing win. -- Validate bounded real application telemetry with C:/dev/ubx-monitor if that checkout remains available. Read its AGENTS.md and use an isolated validation checkout/harness: the drafting-time workspace has an untracked AGENTS.md, machine-local settings and an older React 18/published-XFrames dependency graph. Exercise its actual CNO/telemetry-to-widget path with current-source XFrames and a matching React runtime, using deterministic recorded/synthetic receiver input when hardware is unavailable. Cover sustained updates, idle/resume and cleanup; distinguish this from physical serial-byte arrival or hardware validation. Preserve the original checkout/settings and report narrow integration changes needed. If external dependencies prevent execution, record the exact unverified application gate and do not mark the broader milestone complete. -- Extend application CI to run scheduler, frame-coverage, producer, visibility, parity and lifetime gates, retaining stress/extended commands, bounded timeouts and failure artifacts. No continue-on-error, catch-all suppression, restored compatibility gates or new broad platform skips. Inspect current hosted outcomes, address in-scope failures and identify unverified coverage or pre-existing infrastructure failure honestly. -- Update ROADMAP.md only for delivered Stage 4 requirements and evidence; leave unmet milestone boxes open. Add docs/engineering/fabric-invalidation-2026-09.md linking publication/transaction/cleanup/baseline records. Update architecture, embedding and diagnostics documentation to describe the implemented scheduler and migrated observation contract. -- Record the state machine, lock/wake ownership, producer/deadline inventory, Canvas activity policy, failure/visibility behavior, frame-correlation fields/endpoints, fixture migrations, commands/results, performance comparisons, application validation and remaining limitations. Keep raw reports, screenshots, generated binaries and machine-local telemetry/configuration out of source changes. +Scope and stopping rule: +Complete one reproducible full-app scenario on the available Windows Node/OpenGL environment; another desktop platform is useful only if needed to reproduce a failure. Record platform/backend limits. Synthetic transport and local map resources establish application behavior, not physical serial, network-service or hardware WebGPU qualification. Timings are informational on a shared host: distinguish receipt-to-observed-submission latency from presentation and report sample/coalescing coverage. Requested input rate is not a rendered-frame-rate promise. -Scope and constraints: -- Preserve Fabric, React 19.2.3, RN 0.87.0, RxJS, ReactivePlusPlus, Yoga, Dear ImGui and supported public component/widget behavior. Work in owned host/native/backend seams; do not edit generated Fabric snapshots. -- Preserve the authoritative XFrames npm workspace lockfile and supported toolchains. Installing matching current packages in an isolated external validation environment is distinct from upgrading XFrames dependencies or rewriting another application's working tree. -- Keep synchronous schema-v2 structural completion, surface 0, final reachability cleanup, committed JS ownership and native quarantine. Native submission metadata must respect those boundaries; scheduling must not move all mutations into a new asynchronous transaction engine. -- Implement both native backends and all existing wake sources needed for safe inactivity. Focused Image/Map/Canvas correctness fixtures are required; a comprehensive new Map/Canvas performance suite, hardware WebGPU baseline and Electron/GPUIX comparison remain separate work. -- Narrow fixture input injection, controlled clocks and frame wait helpers are allowed for verification. General automation/query/input APIs, durable recording/replay, transport replacement, binary commit encoding, generic diff optimization and new widgets are outside this slice. -- Do not add macOS, multiple windows/surfaces, a new renderer, CMake consolidation, package publishing, device/serial features or a general async shutdown/resource redesign. Fix a demonstrated prerequisite narrowly and document it. -- Do not use a fixed polling/render fallback, fake invalidations, dropped updates, disabled animations/resources, permanent activity flags or process restarts to satisfy apparent responsiveness/lifetime results. -- Stage 4 core completion does not by itself complete Stage 0 benchmark coverage or the Stages 0-4 milestone. Hardware/application evidence, declared performance targets and consistently green hosted CI must be assessed independently. +Reuse valid build caches and prior CI evidence. Do not repeat full native/Wasm builds or benchmark matrices without a relevant change or unresolved failure. Stop when the integration, required scenario, focused fixes and evidence are complete; if a required gate is blocked, report it explicitly without claiming this slice passed. Do not expand the task to remove every historical limitation. -Relevant implementation locations: -- packages/dear-imgui/cpp/app/src/imgui_renderer.cpp and include/imgui_renderer.h: backend loops, input callbacks, uploads, surface handling, screenshots and submission. -- packages/dear-imgui/cpp/app/src/xframes.cpp, xframes_commit.cpp and include/xframes.h: structural/imperative boundaries, diagnostics, locking and runtime ownership. -- packages/dear-imgui/cpp/app/src/widget/{image,map_view,js_canvas,lua_canvas,janet_canvas}.cpp and corresponding headers; other widget/element input, animation and state paths found by the inventory. -- packages/dear-imgui/cpp/wasm/src/main.cpp and packages/dear-imgui/npm/node/src/xframes-node.cpp: binding initialization, events and metadata exposure. -- packages/dear-imgui/npm/common/src/lib/nativeCommit.ts, react-native/nativeFabricUiManager.ts and widgetRegistrationService.ts: publication observations and committed lifetime ownership. -- packages/dear-imgui/npm/node/src/lib/render.tsx and packages/dear-imgui/npm/wasm/src/lib/ReactNativeWrapper.tsx: ordinary lifecycle and completion. -- packages/dear-imgui/cpp/tests/ and required scheduler/test registration; packages/dear-imgui/npm/diagnostics/{runtime,transactions,publications,bridge-lifecycle}.tsx/.ts and report/parity/types/runner files as applicable. -- .github/workflows/application.yml; full-App Node/browser smoke runners; the bounded external ubx-monitor validation path described above. +Keep the existing Fabric publication/ownership contract, scheduler semantics and resource/lifetime guarantees. No registry release, original external-checkout migration, new widget, general automation API, serial-stack redesign, renderer replacement, permanent polling fallback, fake invalidations or weakened assertions. Configurable UI update rates and controlled streaming performance, broader Map/Canvas benchmarks, physical serial, hardware WebGPU, mixed-DPI/presentation evidence and consistently green CI over subsequent changes remain follow-ups. Stage 5 replay and Stage 6 general automation stay deferred until the milestone review. -Completion handoff: -Audit every deliverable and acceptance requirement before marking the goal complete. Summarize the ordinary publication/imperative/resource/input -> invalidation -> wake -> coherent frame -> submission path, desktop/browser inactivity behavior, deadline/Canvas policy, proof against lost wakeups and false revision coverage, preserved Stage 3 invariants and bounded ownership. Report all local and hosted results, matched workload measurements, idle/active gains, regressions and unavailable presentation/application evidence. Assess the Stages 0-4 milestone explicitly; recommend addressing its measured gaps before expanding into Stage 5 replay or Stage 6 automation. +Handoff: +Summarize the concrete application integration, source/package identities, commands and executed gates, bounded telemetry/interaction/cleanup evidence, any narrow fixes and remaining milestone gaps. Distinguish completed checks from planned work and attach a reproducible application patch rather than relying on the validation checkout alone. diff --git a/packages/dear-imgui/cpp/app/include/widget/map_view.h b/packages/dear-imgui/cpp/app/include/widget/map_view.h index 48ab8a1..46109c0 100644 --- a/packages/dear-imgui/cpp/app/include/widget/map_view.h +++ b/packages/dear-imgui/cpp/app/include/widget/map_view.h @@ -147,6 +147,17 @@ class MapView final : public StyledWidget { std::string BuildTileUrl(int x, int y, int zoom); public: + json GetDiagnosticsState() const { + size_t points = 0; + for (const auto& line : m_polylines) points += line.points.size(); + return {{"zoom", m_zoom}, {"centerLon", m_centerLon}, {"centerLat", m_centerLat}, + {"markerCount", m_markers.size()}, {"overlayCount", m_overlays.size()}, + {"polylineCount", m_polylines.size()}, {"polylinePoints", points}, + {"firstPolylineLimit", m_polylines.empty() ? 0 : m_polylines.front().pointsLimit}, + {"lastMarker", m_markers.empty() ? json(nullptr) : json{{"lat", m_markers.back().lat}, {"lon", m_markers.back().lon}}}, + {"lastOverlayRadiusMeters", m_overlays.empty() ? json(nullptr) : json(m_overlays.back().radiusMeters)}}; + } + static std::unique_ptr makeWidget(const json& widgetDef, std::optional maybeStyle, XFrames* view) { auto id = widgetDef["id"].template get(); auto widget = std::make_unique(view, id, maybeStyle); diff --git a/packages/dear-imgui/cpp/app/include/widget/plot_line.h b/packages/dear-imgui/cpp/app/include/widget/plot_line.h index 6111290..040387a 100644 --- a/packages/dear-imgui/cpp/app/include/widget/plot_line.h +++ b/packages/dear-imgui/cpp/app/include/widget/plot_line.h @@ -34,6 +34,12 @@ class PlotLine final : public StyledWidget { std::string m_legendLabel = "line-plot"; public: + json GetDiagnosticsState() const { + size_t points = 0; + for (const auto& series : m_series) points += series.xValues.size(); + return {{"seriesCount", m_series.size()}, {"pointCount", points}, {"pointsLimit", m_dataPointsLimit}}; + } + static std::unique_ptr makeWidget(const json& widgetDef, std::optional maybeStyle, XFrames* view) { auto id = widgetDef["id"].template get(); int xAxisDecimalDigits = 0; diff --git a/packages/dear-imgui/cpp/app/include/widget/plot_scatter.h b/packages/dear-imgui/cpp/app/include/widget/plot_scatter.h index 257c797..3faa5a7 100644 --- a/packages/dear-imgui/cpp/app/include/widget/plot_scatter.h +++ b/packages/dear-imgui/cpp/app/include/widget/plot_scatter.h @@ -20,6 +20,10 @@ class PlotScatter final : public StyledWidget { std::string m_legendLabel = "scatter-plot"; public: + json GetDiagnosticsState() const { + return {{"pointCount", m_xValues.size()}, {"pointsLimit", m_dataPointsLimit}}; + } + static std::unique_ptr makeWidget(const json& widgetDef, std::optional maybeStyle, XFrames* view) { auto id = widgetDef["id"].template get(); bool axisAutoFit = false; diff --git a/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp b/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp index ce7149d..d5de396 100644 --- a/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp +++ b/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp @@ -390,7 +390,10 @@ void StyledWidget::PreRender(XFrames* view) { const float left = YGNodeLayoutGetLeft(m_layoutNode->m_node); const float top = YGNodeLayoutGetTop(m_layoutNode->m_node); - if (!YGFloatIsUndefined(left)) { + // Tab headers use ImGui's tab-bar cursor flow. Inactive tabs submit no + // content item, so positioning them from Yoga can extend the parent without + // an item and trigger ImGui's SetCursorPos boundary assertion. + if (m_type != "tab-item" && !YGFloatIsUndefined(left)) { // printf("%d %s %f %f\n", m_id, m_type.c_str(), left, top); ImGui::SetCursorPos(ImVec2(left, top)); } diff --git a/packages/dear-imgui/cpp/app/src/xframes.cpp b/packages/dear-imgui/cpp/app/src/xframes.cpp index 034c966..a91df86 100644 --- a/packages/dear-imgui/cpp/app/src/xframes.cpp +++ b/packages/dear-imgui/cpp/app/src/xframes.cpp @@ -237,6 +237,11 @@ json XFrames::BuildDiagnosticsStateUnlocked() { const auto resources = element->GetResourceDiagnostics(); if (!resources.is_null()) record["resources"] = resources; if (auto* plot = dynamic_cast(element.get())) record["state"] = plot->GetDiagnosticsState(); + if (auto* plot = dynamic_cast(element.get())) record["state"] = plot->GetDiagnosticsState(); + if (auto* plot = dynamic_cast(element.get())) record["state"] = plot->GetDiagnosticsState(); + if (auto* map = dynamic_cast(element.get())) record["state"] = map->GetDiagnosticsState(); + if (auto* text = dynamic_cast(element.get())) + record["state"] = {{"byteCount", text->m_textBuffer.size()}, {"lineCount", text->m_lineOffsets.Size}}; if (auto* table = dynamic_cast(element.get())) { record["state"] = {{"rowCount", table->m_data.size()}, {"columnCount", table->m_columns.size()}, {"filterDirty", table->m_filterDirty}, {"filteredCount", table->m_filteredIndices.size()}, diff --git a/packages/dear-imgui/cpp/tests/xframes_test.cpp b/packages/dear-imgui/cpp/tests/xframes_test.cpp index 06899ff..8c533c0 100644 --- a/packages/dear-imgui/cpp/tests/xframes_test.cpp +++ b/packages/dear-imgui/cpp/tests/xframes_test.cpp @@ -318,6 +318,50 @@ class XFramesQueueTest : public XFramesTest { void Frame() { xf->Render(900, 700); xf->CompleteDiagnosticsFrame(); } }; +TEST_F(XFramesQueueTest, InactiveTabHeadersDoNotExtendParentCursorWithoutAnItem) { + Publish({{0, {1}}, {1, {2}}, {2, {3, 4, 5}}, {3, {6}}, {4, {}}, {5, {}}, {6, {}}}, { + Create(2, "tab-bar", {{"style", {{"flex", 1}, {"width", "100%"}}}}), + Create(3, "tab-item", {{"label", "Connection"}, {"style", {{"flex", 1}, {"width", "100%"}}}}), + Create(4, "tab-item", {{"label", "Map"}, {"style", {{"flex", 1}, {"width", "100%"}}}}), + Create(5, "tab-item", {{"label", "Signals"}, {"style", {{"flex", 1}, {"width", "100%"}}}}), + Create(6, "unformatted-text", {{"text", "Disconnected"}}), + }); + auto& io = ImGui::GetIO(); + io.ConfigErrorRecoveryEnableAssert = false; + io.ConfigErrorRecoveryEnableDebugLog = true; + io.ConfigErrorRecoveryEnableTooltip = false; + for (int i = 0; i < 4; ++i) { + Frame(); + EXPECT_EQ(ImGui::GetCurrentContext()->ErrorCountCurrentFrame, 0); + } +} + +TEST_F(XFramesQueueTest, ApplicationDiagnosticsReportRetainedHistoryAndMapOverlays) { + Publish({{0, {1}}, {1, {2, 3, 4}}, {2, {}}, {3, {}}, {4, {}}}, { + Create(2, "plot-line", {{"dataPointsLimit", 2}}), + Create(3, "plot-scatter", {{"dataPointsLimit", 2}}), Create(4, "map-view"), + }); + Internal(4, {{"op", "setPolylines"}, {"polylines", json::array({{{"points", json::array()}, {"pointsLimit", 2}}})}}); + Internal(4, {{"op", "setMarkers"}, {"markers", json::array({{{"lat", 51.5}, {"lon", -0.12}}})}}); + Internal(4, {{"op", "setOverlays"}, {"overlays", json::array({{{"lat", 51.5}, {"lon", -0.12}, {"radiusMeters", 30}}})}}); + for (int i = 0; i < 5; ++i) { + Internal(2, {{"op", "appendData"}, {"x", i}, {"y", i}}); + Internal(3, {{"op", "appendData"}, {"x", i}, {"y", i}}); + Internal(4, {{"op", "appendPolylinePoint"}, {"polylineIndex", 0}, {"lat", 51.5}, {"lon", -0.12 + i * .001}}); + } + Frame(); + const auto state = xf->GetDiagnosticsState(); + EXPECT_EQ(Node(state, 2)["state"]["pointCount"], 2); + EXPECT_EQ(Node(state, 3)["state"]["pointCount"], 2); + const auto map = Node(state, 4)["state"]; + EXPECT_EQ(map["polylinePoints"], 2); + EXPECT_EQ(map["firstPolylineLimit"], 2); + EXPECT_EQ(map["markerCount"], 1); + EXPECT_EQ(map["overlayCount"], 1); + EXPECT_EQ(map["lastMarker"], (json{{"lat", 51.5}, {"lon", -0.12}})); + EXPECT_EQ(map["lastOverlayRadiusMeters"], 30); +} + namespace { class ActivityProbeRenderer final : public ImPlotRenderer { public: diff --git a/packages/dear-imgui/npm/common/package.json b/packages/dear-imgui/npm/common/package.json index cf8634f..8829c7e 100644 --- a/packages/dear-imgui/npm/common/package.json +++ b/packages/dear-imgui/npm/common/package.json @@ -18,7 +18,7 @@ "extract-rn-fabric-renderer": "tsx ./scripts/extract-rn-fabric-renderer.ts", "check-rn-fabric-renderer": "tsx ./scripts/extract-rn-fabric-renderer.ts --check", "typecheck:fabric-tools": "tsc --noEmit --project ./scripts/tsconfig.json", - "test:fabric-host": "tsx ./scripts/test-rn-fabric-host.ts", + "test:fabric-host": "tsx ./scripts/test-rn-fabric-host.ts && tsx ./scripts/test-imgui-col-abi.ts", "verify:rn-fabric": "npm run check-rn-fabric-renderer && npm run typecheck:fabric-tools && npm run test:fabric-host", "tsup": "tsup --format cjs --external react,react-dom", "build:library": "npm run verify:rn-fabric && rimraf ./dist && npm run tsup", diff --git a/packages/dear-imgui/npm/common/scripts/test-imgui-col-abi.ts b/packages/dear-imgui/npm/common/scripts/test-imgui-col-abi.ts new file mode 100644 index 0000000..28bc73c --- /dev/null +++ b/packages/dear-imgui/npm/common/scripts/test-imgui-col-abi.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { ImGuiCol } from "../src/lib/types.js"; + +// Compare the public numeric wire API to the native dependency it configures. +// This catches inserted/reordered colors as well as renamed compatibility aliases. +const header = readFileSync(resolve(__dirname, "../../../cpp/deps/imgui/imgui.h"), "utf8"); +const body = header.split("enum ImGuiCol_\n")[1]?.split("};")[0] + ?? header.split("enum ImGuiCol_\r\n")[1]?.split("};")[0]; +assert.ok(body, "Missing native ImGuiCol definition"); +const native: Record = {}; +let next = 0; +for (const match of body.matchAll(/^\s*ImGuiCol_(\w+)(?:\s*=\s*ImGuiCol_(\w+))?\s*,/gm)) { + const [, name, alias] = match; + native[name] = alias ? native[alias] : next++; + assert.equal(ImGuiCol[name as keyof typeof ImGuiCol], native[name], `Native ImGuiCol_${name} ABI`); +} +assert.equal(native.COUNT, ImGuiCol.COUNT); +assert.ok(Object.keys(native).length > 60); +console.log(`ImGuiCol ABI: ${Object.keys(native).length} native names and aliases match`); diff --git a/packages/dear-imgui/npm/common/src/lib/types.ts b/packages/dear-imgui/npm/common/src/lib/types.ts index 667a68b..428be17 100644 --- a/packages/dear-imgui/npm/common/src/lib/types.ts +++ b/packages/dear-imgui/npm/common/src/lib/types.ts @@ -34,11 +34,14 @@ export enum ImGuiCol { ResizeGrip, ResizeGripHovered, ResizeGripActive, - Tab, + InputTextCursor, TabHovered, - TabActive, - TabUnfocused, - TabUnfocusedActive, + Tab, + TabSelected, + TabSelectedOverline, + TabDimmed, + TabDimmedSelected, + TabDimmedSelectedOverline, PlotLines, PlotLinesHovered, PlotHistogram, @@ -48,13 +51,22 @@ export enum ImGuiCol { TableBorderLight, TableRowBg, TableRowBgAlt, + TextLink, TextSelectedBg, + TreeLines, DragDropTarget, - NavHighlight, + DragDropTargetBg, + UnsavedMarker, + NavCursor, NavWindowingHighlight, NavWindowingDimBg, ModalWindowDimBg, COUNT, + // Dear ImGui's supported aliases, with the current native ABI values. + TabActive = TabSelected, + TabUnfocused = TabDimmed, + TabUnfocusedActive = TabDimmedSelected, + NavHighlight = NavCursor, } export enum ImPlotScale { diff --git a/packages/dear-imgui/npm/diagnostics/README.md b/packages/dear-imgui/npm/diagnostics/README.md index 81c54fd..7b1b042 100644 --- a/packages/dear-imgui/npm/diagnostics/README.md +++ b/packages/dear-imgui/npm/diagnostics/README.md @@ -345,6 +345,48 @@ preparation/construction and is not GPU execution or presentation. ## Isolated ubx-monitor telemetry +This existing command mounts the actual signal panel through a custom diagnostic +host. It validates the parser/subscription/CNO path; it does not launch the +ordinary complete App. The [completed application slice](../../../../docs/engineering/ubx-monitor-application-2026-09.md) +below uses the public Node render/disposal path and includes Table, Map, Canvas +and connection lifecycle in one production Windows session. + +## Ordinary complete ubx-monitor application + +The complete application integration is retained in `ubx-application.patch`, +targeting application revision `571f5569bb923c3d4a8f37db8f8ada555323667a`. +Build current-source Release Node native artifacts first, as described above. +From the npm workspace: + +```powershell +node diagnostics/ubx-application-setup.mjs --source=C:/dev/ubx-monitor --output=C:/path/to/new/isolated/output +$env:NODE_ENV='production' +$env:TSX_TSCONFIG_PATH='diagnostics/tsconfig.json' +$env:XFRAMES_UBX_APP_DIR='C:/path/to/new/isolated/output/app' +$env:XFRAMES_DIAGNOSTICS_DIR='C:/path/to/new/evidence' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario +``` + +Announce desktop runs: this Windows fixture moves the real mouse and acquires +focus for about two minutes. It waits for hover before clicking, verifies the +selected tab and retries missed targets. It uses the actual `src/index.tsx`, +public Node render function, returned disposer, App and callbacks. Only physical +serial transport and map resources are substituted. Receiver packets contain +valid checksums; delivery timers run independently of observation and input. +The local resource server uses the deterministic PNG in `resource-server.mjs`. + +Omit `--scenario` and use `NODE_ENV=development` for the shorter whole-App +startup/panel/disposal probe. That probe is not sustained acceptance. Setup runs +the complete application `typecheck` and `test:serial` scripts and records package, +source, dirty-diff and asset identities. It leaves the original checkout intact. +The [application record](../../../../docs/engineering/ubx-monitor-application-2026-09.md) +records the completed Windows slice, failed exploratory probes and remaining +milestone limits. For ordinary development startup, run `npm start` from the +prepared application directory with `NODE_ENV=development` and no diagnostic +`TSX_TSCONFIG_PATH` override; close the native window normally. + +### Legacy panel-only command + Read the external checkout's `AGENTS.md`; clone it into an ignored validation directory and install locally packed current-source `@xframes/common` and `@xframes/node` with React 19.2.3. Keep its original settings and dependency graph diff --git a/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs b/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs new file mode 100644 index 0000000..61a6418 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const repository = resolve(root, "../../.."); +const args = Object.fromEntries(process.argv.slice(2).map(arg => { + const match = /^--(source|output)=(.+)$/.exec(arg); + if (!match) throw new Error(`Unknown argument: ${arg}`); + return [match[1], match[2]]; +})); +const source = resolve(args.source ?? "C:/dev/ubx-monitor"); +const output = resolve(args.output ?? resolve(root, "build/diagnostics/ubx-application-reproduction")); +const app = resolve(output, "app"); +if (existsSync(app)) throw new Error(`Refusing to replace an existing checkout: ${app}`); +if (output === source || output.startsWith(source + "/") || output.startsWith(source + "\\")) + throw new Error("The validation output must be outside the original application"); +mkdirSync(output, { recursive: true }); +const packages = resolve(output, "packages"); +mkdirSync(packages); +const hash = file => createHash("sha256").update(readFileSync(file)).digest("hex"); +const records = []; +function run(command, argv, cwd, label) { + const result = spawnSync(command, argv, { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024 }); + records.push({ command, argv, cwd, label, status: result.status }); + writeFileSync(resolve(output, `${label}.log`), (result.stdout ?? "") + (result.stderr ?? "")); + writeFileSync(resolve(output, "commands.json"), JSON.stringify(records, null, 2)); + if (result.error || result.status !== 0) throw result.error ?? new Error(`${label} failed; see ${output}`); + return result.stdout; +} +const git = (argv, cwd, label) => run("git", argv, cwd, label); +const npmPath = process.env.npm_execpath ?? resolve(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"); +if (!existsSync(npmPath)) throw new Error("Run via npm exec, or set npm_execpath to npm-cli.js"); +const npm = (argv, cwd, label) => run(process.execPath, [npmPath, ...argv], cwd, label); +const preservedFiles = ["AGENTS.md", "package.json", "package-lock.json", "config.json"]; +const original = Object.fromEntries(preservedFiles.filter(file => existsSync(resolve(source, file))) + .map(file => [file, hash(resolve(source, file))])); +const sourceRevision = git(["rev-parse", "HEAD"], source, "original-revision").trim(); +if (sourceRevision !== "571f5569bb923c3d4a8f37db8f8ada555323667a") + throw new Error(`Application patch targets 571f556; inspect changed source ${sourceRevision} before migrating`); +const originalStatus = git(["status", "--porcelain=v1"], source, "original-status"); +git(["diff", "--binary", "HEAD"], source, "original-diff"); +const xframesRevision = git(["rev-parse", "HEAD"], repository, "xframes-revision").trim(); +git(["status", "--porcelain=v1"], repository, "xframes-status"); +git(["diff", "--binary", "HEAD"], repository, "xframes-diff"); +const untracked = git(["ls-files", "--others", "--exclude-standard", "-z"], repository, "xframes-untracked") + .split("\0").filter(Boolean); +const xframesUntracked = Object.fromEntries(untracked.map(file => [file, hash(resolve(repository, file))])); +// Native targets must already have been built from current source. Do not silently +// substitute a registry binary or refresh native builds just for a timestamp. +const native = resolve(root, "node/build/Release/xframes.node"); +if (!existsSync(native)) throw new Error("Build the current Release Node target before setup"); +npm(["run", "build:common"], root, "build-common"); +npm(["run", "build:node"], root, "build-node-package"); +const packed = JSON.parse(npm(["pack", "--workspace", "@xframes/common", "--workspace", "@xframes/node", + "--pack-destination", packages, "--json"], root, "pack")); +git(["clone", "--no-hardlinks", source, app], repository, "clone"); +if (existsSync(resolve(source, "AGENTS.md"))) cpSync(resolve(source, "AGENTS.md"), resolve(app, "AGENTS.md")); +const patch = resolve(here, "ubx-application.patch"); +const patchSha256 = hash(patch); +git(["apply", "--check", patch], app, "patch-check"); +git(["apply", patch], app, "patch-apply"); +// Include newly added application sources in the retained dirty diff as well. +git(["add", "--intent-to-add", "src/connection/SerialTransport.ts", "tests/serial-lifecycle.ts"], app, "patch-new-files"); +// Keep the committed lock's dependency selections; refresh only local tarball +// integrity because a rebuilt native artifact may have a different build identity. +npm(["install", "--package-lock-only", "--ignore-scripts", "--save-exact", + "../packages/xframes-common-0.1.7.tgz", "../packages/xframes-node-0.1.14.tgz"], app, "local-lock"); +npm(["ci"], app, "install"); +npm(["run", "typecheck"], app, "typecheck"); +npm(["run", "test:serial"], app, "serial-lifecycle"); +git(["diff", "--binary", "HEAD"], app, "application-diff"); +for (const [file, before] of Object.entries(original)) { + if (hash(resolve(source, file)) !== before) throw new Error(`Original ${file} changed during setup`); +} +if (git(["status", "--porcelain=v1"], source, "original-status-after") !== originalStatus) + throw new Error("Original checkout status changed during setup"); +const identities = {}; +for (const file of ["assets/fonts/roboto-regular.ttf", "assets/fonts/roboto-mono.ttf", "src/scripts/sky-view.js", + "package-lock.json", "node_modules/@xframes/node/dist/xframes.node"]) identities[file] = hash(resolve(app, file)); +writeFileSync(resolve(output, "provenance.json"), JSON.stringify({ source, sourceRevision, original, + xframesRevision, xframesUntracked, patchSha256, nativeSha256: hash(native), packages: packed, + identities, node: process.version, nativeBuildRequirement: "Current-source Release build; retain build logs alongside this record" }, null, 2)); +if (hash(patch) !== patchSha256) throw new Error("Application patch changed during setup; repeat in a fresh output directory"); +console.log(`Prepared ${app}; typecheck and serial lifecycle passed. Desktop execution is a separate explicit run.`); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-application.patch b/packages/dear-imgui/npm/diagnostics/ubx-application.patch new file mode 100644 index 0000000..786fde1 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-application.patch @@ -0,0 +1,1075 @@ +diff --git a/package-lock.json b/package-lock.json +index eed378d..fc79691 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -9,14 +9,15 @@ + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { +- "@xframes/common": "^0.1.6", +- "@xframes/node": "^0.1.13", +- "react": "^18.2.0", ++ "@xframes/common": "file:../packages/xframes-common-0.1.7.tgz", ++ "@xframes/node": "file:../packages/xframes-node-0.1.14.tgz", ++ "react": "19.2.3", + "serialport": "^12.0.0", + "ubx-parser": "^0.2.6" + }, + "devDependencies": { +- "@types/react": "^18.2.66", ++ "@types/node": "20.19.0", ++ "@types/react": "19.2.0", + "tsx": "^4.19.1", + "typescript": "^5.4.5" + } +@@ -721,28 +722,30 @@ + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, +- "node_modules/@types/prop-types": { +- "version": "15.7.15", +- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", +- "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", ++ "node_modules/@types/node": { ++ "version": "20.19.0", ++ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", ++ "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", + "dev": true, +- "license": "MIT" ++ "license": "MIT", ++ "dependencies": { ++ "undici-types": "~6.21.0" ++ } + }, + "node_modules/@types/react": { +- "version": "18.3.28", +- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", +- "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", ++ "version": "19.2.0", ++ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", ++ "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@types/prop-types": "*", +- "csstype": "^3.2.2" ++ "csstype": "^3.0.2" + } + }, + "node_modules/@xframes/common": { +- "version": "0.1.6", +- "resolved": "https://registry.npmjs.org/@xframes/common/-/common-0.1.6.tgz", +- "integrity": "sha512-sX1/mD5LZrf3npCABuz2gFq8h39XuEvuZ810pbznRYsgd5PO7ErRpcUjafeFiKM0U8qKUKToAGXHU/8LKRySIA==", ++ "version": "0.1.7", ++ "resolved": "file:../packages/xframes-common-0.1.7.tgz", ++ "integrity": "sha512-A9XgSPMfi1ITWKo/yqIlr/C/6WVF6ga9Nwr7YooW47DIYeoSTEKIF5meVM9pSx6yR2EaAMYTI8MpoPW1wUGRWg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1", +@@ -762,13 +765,13 @@ + "whatwg-fetch": "^3.6.20" + }, + "peerDependencies": { +- "react": "^18.2.0" ++ "react": "^19.2.3" + } + }, + "node_modules/@xframes/node": { +- "version": "0.1.13", +- "resolved": "https://registry.npmjs.org/@xframes/node/-/node-0.1.13.tgz", +- "integrity": "sha512-pV5OLhB4Wa9oi3NP0E/nf9eemzgCPQ1Pi86m+5fbFNSxrQRALyxM4SnjqOEPMu3Cte4FbGuZYWza15p2HDYx7g==", ++ "version": "0.1.14", ++ "resolved": "file:../packages/xframes-node-0.1.14.tgz", ++ "integrity": "sha512-LMpGOYwQN15H5U0FQGmvoGjxqDKUY0JxZ+pBbefN7B8LF0dsAe2q6k/xDDM4AbH5y7uQv9irORkiaSbiiJJXtg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1", +@@ -789,8 +792,8 @@ + "whatwg-fetch": "^3.6.20" + }, + "peerDependencies": { +- "@xframes/common": "^0.1.0", +- "react": "^18.2.0" ++ "@xframes/common": "^0.1.7", ++ "react": "^19.2.3" + } + }, + "node_modules/aggregate-error": { +@@ -1484,13 +1487,10 @@ + "license": "MIT" + }, + "node_modules/react": { +- "version": "18.3.1", +- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", +- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", ++ "version": "19.2.3", ++ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", ++ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", +- "dependencies": { +- "loose-envify": "^1.1.0" +- }, + "engines": { + "node": ">=0.10.0" + } +@@ -1689,6 +1689,13 @@ + "node-gyp-build-test": "build-test.js" + } + }, ++ "node_modules/undici-types": { ++ "version": "6.21.0", ++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", ++ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", ++ "dev": true, ++ "license": "MIT" ++ }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +diff --git a/package.json b/package.json +index 2a09432..a237482 100644 +--- a/package.json ++++ b/package.json +@@ -7,17 +7,20 @@ + "license": "MPL-2.0", + "author": "Andrea Mancuso", + "scripts": { +- "start": "tsx ./src/index.tsx" ++ "start": "tsx ./src/index.tsx", ++ "typecheck": "tsc --noEmit", ++ "test:serial": "tsx ./tests/serial-lifecycle.ts" + }, + "dependencies": { +- "@xframes/common": "^0.1.6", +- "@xframes/node": "^0.1.13", +- "react": "^18.2.0", ++ "@xframes/common": "file:../packages/xframes-common-0.1.7.tgz", ++ "@xframes/node": "file:../packages/xframes-node-0.1.14.tgz", ++ "react": "19.2.3", + "serialport": "^12.0.0", + "ubx-parser": "^0.2.6" + }, + "devDependencies": { +- "@types/react": "^18.2.66", ++ "@types/node": "20.19.0", ++ "@types/react": "19.2.0", + "tsx": "^4.19.1", + "typescript": "^5.4.5" + } +diff --git a/src/App.tsx b/src/App.tsx +index 3ba29ee..b6895cc 100644 +--- a/src/App.tsx ++++ b/src/App.tsx +@@ -1,6 +1,6 @@ + import * as React from "react"; + import { XFrames } from "@xframes/node"; +-import { WidgetPropsMap } from "@xframes/common"; ++import { ImGuiCol } from "@xframes/common"; + import { ConnectionPanel } from "./panels/ConnectionPanel"; + import { ConsoleView } from "./panels/ConsoleView"; + import { MessageView } from "./panels/MessageView"; +@@ -18,15 +18,17 @@ const ACTIVE_COLOR = "#2ecc71"; + const INACTIVE_COLOR = "#808080"; + const DORMANT_COLOR = "#d0d0d0"; + +-type Indicator = WidgetPropsMap["TabItem"]["indicator"]; ++// Current packages expose per-tab text color for the activity status. + + export const App = () => { + const activity = useDataActivity(); + const { status } = useSerialConnection(); + const connected = status === "connected"; + +- const dot = (active: boolean): Indicator => +- ({ color: connected ? (active ? ACTIVE_COLOR : INACTIVE_COLOR) : DORMANT_COLOR }); ++ const tabStyle = (active: boolean) => ({ ++ flex: 1, width: "100%", ++ colors: { [ImGuiCol.Text]: connected ? (active ? ACTIVE_COLOR : INACTIVE_COLOR) : DORMANT_COLOR }, ++ }); + + return ( + +@@ -37,64 +39,55 @@ export const App = () => { + + + + + + + + + + + + + + + + + + + + + + + + + + + + +diff --git a/src/components/LabelRow.tsx b/src/components/LabelRow.tsx +index 4b90003..ed04145 100644 +--- a/src/components/LabelRow.tsx ++++ b/src/components/LabelRow.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { XFrames } from "@xframes/node"; + import { themeColors } from "../themes"; +@@ -6,11 +7,11 @@ export const LabelRow = ({ label, value, color }: { label: string; value: string + + + + + ); +diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx +index 5c3a656..72dbd06 100644 +--- a/src/components/SectionHeader.tsx ++++ b/src/components/SectionHeader.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { XFrames } from "@xframes/node"; + import { themeColors } from "../themes"; +@@ -5,6 +6,6 @@ import { themeColors } from "../themes"; + export const SectionHeader = ({ text }: { text: string }) => ( + + ); +diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx +index b4c1586..fdf3439 100644 +--- a/src/components/StatusBar.tsx ++++ b/src/components/StatusBar.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { XFrames } from "@xframes/node"; + import { useSerialConnection } from "../hooks/useSerialConnection"; +@@ -30,8 +31,8 @@ const FIX_COLORS: Record = { + 5: "#3498db", + }; + +-const dividerStyle = { color: themeColors.slate }; +-const labelStyle = { color: themeColors.lightSlate }; ++const dividerStyle = { colors: { [ImGuiCol.Text]: themeColors.slate } }; ++const labelStyle = { colors: { [ImGuiCol.Text]: themeColors.lightSlate } }; + + export const StatusBar = () => { + const { status } = useSerialConnection(); +@@ -70,29 +71,29 @@ export const StatusBar = () => { + /> + + +- ++ + + + +- {status === "connected" && baudRate && ( ++ {status === "connected" && baudRate !== null && ( + <> +- ++ + + + + )} + +- ++ + +- ++ + + + ); +diff --git a/src/connection/SerialManager.ts b/src/connection/SerialManager.ts +index b05700b..4f94d95 100644 +--- a/src/connection/SerialManager.ts ++++ b/src/connection/SerialManager.ts +@@ -1,5 +1,6 @@ + import { EventEmitter } from "events"; +-import { SerialPort } from "serialport"; ++import { physicalSerialTransport } from "./SerialTransport"; ++import type { SerialTransport, SerialTransportPort } from "./SerialTransport"; + import { UbxParser } from "ubx-parser"; + import type { ConnectionStatus, SerialPortInfo } from "./types"; + import { enableUbxNavMessages, coldStart, warmStart, hotStart } from "./ubx-commands"; +@@ -7,8 +8,8 @@ import { enableUbxNavMessages, coldStart, warmStart, hotStart } from "./ubx-comm + const MAX_RECONNECT_ATTEMPTS = 5; + const RECONNECT_INTERVAL_MS = 2000; + +-class SerialManager extends EventEmitter { +- private port: SerialPort | null = null; ++export class SerialManager extends EventEmitter { ++ private port: SerialTransportPort | null = null; + private parser: UbxParser | null = null; + private status: ConnectionStatus = "disconnected"; + private intentionalDisconnect = false; +@@ -16,9 +17,21 @@ class SerialManager extends EventEmitter { + private reconnectAttempt = 0; + private lastPath: string | null = null; + private lastBaudRate: number | null = null; ++ private detachListeners: (() => void) | null = null; ++ ++ constructor(private transport: SerialTransport = physicalSerialTransport) { ++ super(); ++ } ++ ++ setTransport(transport: SerialTransport): void { ++ if (this.status !== "disconnected" || this.port || this.reconnectTimer) { ++ throw new Error("Change serial transport only while disconnected"); ++ } ++ this.transport = transport; ++ } + + async listPorts(): Promise { +- const ports = await SerialPort.list(); ++ const ports = await this.transport.list(); + return ports.map((p) => ({ + path: p.path, + manufacturer: p.manufacturer || undefined, +@@ -39,12 +52,16 @@ class SerialManager extends EventEmitter { + this.emit("status", this.status); + this.emit("baudrate", baudRate); + +- this.port = new SerialPort({ path, baudRate }, (err) => { ++ const port = this.transport.open({ path, baudRate }, (err) => { ++ if (this.port !== port) { ++ if (!err && port.isOpen) port.close(() => {}); ++ return; ++ } + if (err) { + this.status = "disconnected"; + this.emit("status", this.status); + this.emit("error", err.message); +- this.port = null; ++ this.releasePort(); + return; + } + +@@ -53,6 +70,7 @@ class SerialManager extends EventEmitter { + this.emit("status", this.status); + }); + ++ this.port = port; + this.parser = new UbxParser(); + this.setupPortListeners(); + } +@@ -61,11 +79,7 @@ class SerialManager extends EventEmitter { + this.intentionalDisconnect = true; + this.clearReconnect(); + +- if (this.port && this.port.isOpen) { +- this.port.close(); +- } +- this.port = null; +- this.parser = null; ++ this.releasePort(); + this.status = "disconnected"; + this.emit("status", this.status); + this.emit("baudrate", null); +@@ -93,79 +107,59 @@ class SerialManager extends EventEmitter { + return this.status; + } + ++ private releasePort(): void { ++ const port = this.port; ++ this.port = null; ++ this.detachListeners?.(); ++ this.detachListeners = null; ++ this.parser = null; ++ if (port?.isOpen) port.close(() => {}); ++ // An opening transport is closed by its completion callback if superseded. ++ } ++ + private setupPortListeners(): void { +- this.port!.on("data", (chunk: Buffer) => { ++ const port = this.port!; ++ // ubx-parser 0.2.x extends EventEmitter but omits its removal methods from ++ // the declaration file. Keep this compatibility assertion at the owner. ++ const parser = this.parser! as UbxParser & EventEmitter; ++ const current = () => this.port === port && this.parser === parser; ++ const onData = (chunk: Buffer) => { ++ if (!current()) return; + this.emit("rawdata", chunk); +- this.parser!.feed(chunk); +- }); +- +- this.parser!.on("message", (msg) => { +- this.emit("message", msg); +- }); +- +- this.parser!.on("NAV-PVT", (msg) => { +- this.emit("NAV-PVT", msg); +- }); +- +- this.parser!.on("ACK-ACK", (msg) => { +- this.emit("message", msg); +- }); +- +- this.parser!.on("ACK-NAK", (msg) => { +- this.emit("message", msg); +- }); +- +- this.parser!.on("MON-HW", (msg) => { +- this.emit("MON-HW", msg); +- this.emit("message", msg); +- }); +- +- this.parser!.on("MON-HW3", (msg) => { +- this.emit("MON-HW3", msg); +- this.emit("message", msg); +- }); +- +- this.parser!.on("MON-RF", (msg) => { +- this.emit("MON-RF", msg); +- this.emit("message", msg); +- }); +- +- this.parser!.on("NAV-STATUS", (msg) => { +- this.emit("NAV-STATUS", msg); +- this.emit("message", msg); +- }); +- +- this.parser!.on("NAV-DOP", (msg) => { +- this.emit("NAV-DOP", msg); +- this.emit("message", msg); +- }); +- +- this.parser!.on("NAV-SAT", (msg) => { +- this.emit("NAV-SAT", msg); +- }); +- +- this.port!.on("close", () => { +- if (this.intentionalDisconnect) { +- this.status = "disconnected"; +- this.emit("status", this.status); +- } else { +- this.port = null; +- this.parser = null; +- this.startReconnect(); +- } +- }); +- +- this.port!.on("error", (err) => { +- this.emit("error", err.message); +- if (this.intentionalDisconnect) { +- this.status = "disconnected"; +- this.emit("status", this.status); +- } else { +- this.port = null; +- this.parser = null; +- this.startReconnect(); +- } ++ parser.feed(chunk); ++ }; ++ const onMessage = (msg: unknown) => { ++ if (current()) this.emit("message", msg); ++ }; ++ parser.on("message", onMessage); ++ // UbxParser already emits each decoded message on `message` exactly once. ++ const events = ["NAV-PVT", "MON-HW", "MON-HW3", "MON-RF", "NAV-STATUS", "NAV-DOP", "NAV-SAT"]; ++ const handlers = events.map((event) => { ++ const handler = (msg: unknown) => { if (current()) this.emit(event, msg); }; ++ parser.on(event, handler); ++ return [event, handler] as const; + }); ++ const onClose = () => { ++ if (!current()) return; ++ this.releasePort(); ++ if (!this.intentionalDisconnect) this.startReconnect(); ++ }; ++ const onError = (error: Error) => { ++ if (!current()) return; ++ this.emit("error", error.message); ++ this.releasePort(); ++ if (!this.intentionalDisconnect) this.startReconnect(); ++ }; ++ port.on("data", onData); ++ port.on("close", onClose); ++ port.on("error", onError); ++ this.detachListeners = () => { ++ port.off("data", onData); ++ port.off("close", onClose); ++ port.off("error", onError); ++ parser.off("message", onMessage); ++ for (const [event, handler] of handlers) parser.off(event, handler); ++ }; + } + + private clearReconnect(): void { +@@ -208,15 +202,15 @@ class SerialManager extends EventEmitter { + } + + private attemptReconnect(): void { +- if (this.port) { +- this.port.removeAllListeners(); +- if (this.port.isOpen) this.port.close(); +- this.port = null; +- } +- this.parser = null; ++ this.releasePort(); + +- this.port = new SerialPort({ path: this.lastPath!, baudRate: this.lastBaudRate! }, (err) => { ++ const port = this.transport.open({ path: this.lastPath!, baudRate: this.lastBaudRate! }, (err) => { ++ if (this.port !== port) { ++ if (!err && port.isOpen) port.close(() => {}); ++ return; ++ } + if (err) { ++ this.releasePort(); + if (!this.intentionalDisconnect) { + this.scheduleReconnectAttempt(); + } +@@ -228,6 +222,7 @@ class SerialManager extends EventEmitter { + this.emit("status", this.status); + }); + ++ this.port = port; + this.parser = new UbxParser(); + this.setupPortListeners(); + } +diff --git a/src/connection/SerialTransport.ts b/src/connection/SerialTransport.ts +new file mode 100644 +index 0000000..17d8903 +--- /dev/null ++++ b/src/connection/SerialTransport.ts +@@ -0,0 +1,21 @@ ++import { EventEmitter } from "events"; ++import { SerialPort } from "serialport"; ++import type { SerialPortInfo } from "./types"; ++ ++export interface SerialTransportPort extends EventEmitter { ++ readonly isOpen: boolean; ++ close(callback?: (error: Error | null) => void): void; ++ write(data: Buffer): unknown; ++} ++ ++// Only the byte transport varies. Connection state, parsing and subscriptions ++// remain owned by SerialManager. open's callback must run asynchronously. ++export interface SerialTransport { ++ list(): Promise; ++ open(options: { path: string; baudRate: number }, callback: (error: Error | null) => void): SerialTransportPort; ++} ++ ++export const physicalSerialTransport: SerialTransport = { ++ list: () => SerialPort.list(), ++ open: (options, callback) => new SerialPort(options, callback), ++}; +diff --git a/src/connection/config.ts b/src/connection/config.ts +index d1a1c7e..244c1a9 100644 +--- a/src/connection/config.ts ++++ b/src/connection/config.ts +@@ -14,12 +14,12 @@ let cache: AppConfig | null = null; + export function getConfig(): AppConfig { + if (!cache) { + try { +- cache = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); ++ cache = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) ?? {}; + } catch { + cache = {}; + } + } +- return cache; ++ return cache!; + } + + export function updateConfig(partial: Partial): void { +diff --git a/src/hooks/useDataActivity.ts b/src/hooks/useDataActivity.ts +index 05f9f9e..2494d3f 100644 +--- a/src/hooks/useDataActivity.ts ++++ b/src/hooks/useDataActivity.ts +@@ -33,7 +33,8 @@ export function useDataActivity(): Record { + for (const evt of TRACKED_EVENTS) { + next[evt] = now - (lastSeen.current[evt] ?? 0) < STALE_THRESHOLD_MS; + } +- setActivity(next); ++ setActivity((previous) => TRACKED_EVENTS.every((event) => !!previous[event] === next[event]) ++ ? previous : next); + }, 1000); + + return () => { +diff --git a/src/hooks/useUbxMessages.ts b/src/hooks/useUbxMessages.ts +index fcb3c3a..fbb1396 100644 +--- a/src/hooks/useUbxMessages.ts ++++ b/src/hooks/useUbxMessages.ts +@@ -67,6 +67,8 @@ export function useUbxMessages() { + + const onStatus = (s: ConnectionStatus) => { + if (s === "disconnected") { ++ timestampsRef.current = []; ++ setMessageRate(0); + lastMessageTimeRef.current = 0; + setTimeSinceLastMessage(0); + byteChunksRef.current = []; +@@ -85,6 +87,8 @@ export function useUbxMessages() { + // Decay bytes/sec when no data arrives + const now = Date.now(); + const cutoff = now - 1000; ++ timestampsRef.current = timestampsRef.current.filter((time) => time > cutoff); ++ setMessageRate(timestampsRef.current.length); + byteChunksRef.current = byteChunksRef.current.filter((c) => c.time > cutoff); + setBytesPerSec(byteChunksRef.current.reduce((sum, c) => sum + c.bytes, 0)); + }, 1000); +diff --git a/src/index.tsx b/src/index.tsx +index 98066ea..a97e3ef 100644 +--- a/src/index.tsx ++++ b/src/index.tsx +@@ -15,4 +15,4 @@ const fontDefs = { + + const assetsBasePath = resolve("./assets"); + +-render(App, assetsBasePath, fontDefs, theme); ++export const dispose = render(App, assetsBasePath, fontDefs, theme); +diff --git a/src/panels/ConnectionPanel.tsx b/src/panels/ConnectionPanel.tsx +index e270c55..4f5831e 100644 +--- a/src/panels/ConnectionPanel.tsx ++++ b/src/panels/ConnectionPanel.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useState, useCallback, useEffect, useRef } from "react"; + import { XFrames } from "@xframes/node"; +@@ -20,8 +21,7 @@ const statusColors: Record = { + + const disabledComboStyle = { + width: 250, +- color: themeColors.slate, +- backgroundColor: themeColors.deepNavy, ++ colors: { [ImGuiCol.Text]: themeColors.slate, [ImGuiCol.FrameBg]: themeColors.deepNavy }, + }; + + export const ConnectionPanel = () => { +@@ -128,7 +128,7 @@ export const ConnectionPanel = () => { + {error && ( + + )} + +diff --git a/src/panels/ConsoleView.tsx b/src/panels/ConsoleView.tsx +index e3b180c..d9392d3 100644 +--- a/src/panels/ConsoleView.tsx ++++ b/src/panels/ConsoleView.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useEffect, useRef, useState } from "react"; + import { XFrames } from "@xframes/node"; +@@ -51,11 +52,31 @@ export const ConsoleView = () => { + const offsetRef = useRef(0); + const frameBufferRef = useRef(Buffer.alloc(0)); + const [hasData, setHasData] = useState(false); ++ const historyRef = useRef(""); ++ const replayRef = useRef(true); ++ const [historyGeneration, setHistoryGeneration] = useState(0); ++ ++ // The native text handle is append-only. Rotate its lifetime when trimming ++ // the console so both retained JS text and native text stay bounded. ++ useEffect(() => { ++ if (textRef.current && replayRef.current) { ++ textRef.current.appendTextToClippedMultiLineTextRenderer(historyRef.current); ++ replayRef.current = false; ++ } ++ }, [hasData, historyGeneration]); + + useEffect(() => { + const onRawData = (chunk: Buffer) => { + if (!hasData) setHasData(true); +- if (!textRef.current) return; ++ const appendText = (text: string) => { ++ historyRef.current += text; ++ if (historyRef.current.length > 65536) { ++ historyRef.current = historyRef.current.slice(-32768); ++ replayRef.current = true; ++ setHistoryGeneration((value) => value + 1); ++ } ++ if (!replayRef.current) textRef.current?.appendTextToClippedMultiLineTextRenderer(text); ++ }; + + frameBufferRef.current = Buffer.concat([frameBufferRef.current, chunk]); + const buf = frameBufferRef.current; +@@ -75,7 +96,7 @@ export const ConsoleView = () => { + // No sync found — dump remaining, keep last byte if it could be start of sync + const keep = buf[buf.length - 1] === 0xb5 ? 1 : 0; + if (buf.length - keep > pos) { +- textRef.current.appendTextToClippedMultiLineTextRenderer( ++ appendText( + formatHexDump(buf.subarray(pos, buf.length - keep), offsetRef.current), + ); + offsetRef.current += buf.length - keep - pos; +@@ -86,7 +107,7 @@ export const ConsoleView = () => { + + // Dump non-UBX bytes before sync + if (syncPos > pos) { +- textRef.current.appendTextToClippedMultiLineTextRenderer( ++ appendText( + formatHexDump(buf.subarray(pos, syncPos), offsetRef.current), + ); + offsetRef.current += syncPos - pos; +@@ -108,7 +129,7 @@ export const ConsoleView = () => { + const name = ubxName(cls, id); + const separator = `\n──── ${name} [${cls.toString(16).padStart(2, "0")}:${id.toString(16).padStart(2, "0")}] ${payloadLen} bytes ────\n`; + const frame = buf.subarray(pos, pos + frameLen); +- textRef.current.appendTextToClippedMultiLineTextRenderer( ++ appendText( + separator + formatHexDump(frame, offsetRef.current), + ); + offsetRef.current += frameLen; +@@ -131,7 +152,7 @@ export const ConsoleView = () => { + + + + ); +@@ -140,6 +161,7 @@ export const ConsoleView = () => { + return ( + + +diff --git a/src/panels/HardwareStatusPanel.tsx b/src/panels/HardwareStatusPanel.tsx +index ee44b61..d4a2ae4 100644 +--- a/src/panels/HardwareStatusPanel.tsx ++++ b/src/panels/HardwareStatusPanel.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useRef, useEffect } from "react"; + import { XFrames } from "@xframes/node"; +@@ -65,7 +66,7 @@ export const HardwareStatusPanel = () => { + + + + ); +diff --git a/src/panels/MapPanel.tsx b/src/panels/MapPanel.tsx +index a6c641d..35d5db9 100644 +--- a/src/panels/MapPanel.tsx ++++ b/src/panels/MapPanel.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useRef, useEffect, useState } from "react"; + import { XFrames } from "@xframes/node"; +@@ -70,14 +71,14 @@ export const MapPanel = () => { + + + + ) : ( + + + + )} +@@ -86,7 +87,9 @@ export const MapPanel = () => { + style={{ flex: 1, width: "100%" }} + minZoom={3} + maxZoom={18} +- cachePath="./tile_cache" ++ cachePath={process.env.UBX_MONITOR_TILE_CACHE ?? "./tile_cache"} ++ tileUrlTemplate={process.env.UBX_MONITOR_TILE_URL} ++ attribution={process.env.UBX_MONITOR_TILE_URL ? "Local synthetic map fixture" : undefined} + onChange={(e: any) => setZoom(Math.round(e.nativeEvent.value))} + /> + +diff --git a/src/panels/NavigationStatus.tsx b/src/panels/NavigationStatus.tsx +index e310e1b..49412ae 100644 +--- a/src/panels/NavigationStatus.tsx ++++ b/src/panels/NavigationStatus.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { XFrames } from "@xframes/node"; + import { ComboChangeEvent } from "@xframes/common"; +@@ -116,7 +117,7 @@ export const NavigationStatus = () => { + + + + ); +@@ -129,7 +130,7 @@ export const NavigationStatus = () => { + return ( + + +- ++ + { + + + + ); +@@ -102,7 +103,7 @@ export const PositionTrackingPanel = () => { + text={headerText} + style={{ + font: { name: "roboto-mono", size: 14 }, +- color: themeColors.silver, ++ colors: { [ImGuiCol.Text]: themeColors.silver }, + }} + /> + +@@ -126,7 +127,7 @@ export const PositionTrackingPanel = () => { + text={statsText} + style={{ + font: { name: "roboto-mono", size: 14 }, +- color: themeColors.silver, ++ colors: { [ImGuiCol.Text]: themeColors.silver }, + }} + /> + +diff --git a/src/panels/SignalStrengthPanel.tsx b/src/panels/SignalStrengthPanel.tsx +index 34dd79a..9dcd507 100644 +--- a/src/panels/SignalStrengthPanel.tsx ++++ b/src/panels/SignalStrengthPanel.tsx +@@ -1,3 +1,4 @@ ++import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useRef, useEffect, useMemo, useState, useCallback } from "react"; + import { XFrames } from "@xframes/node"; +@@ -109,7 +110,7 @@ export const SignalStrengthPanel = () => { + + + + ); +@@ -121,7 +122,7 @@ export const SignalStrengthPanel = () => { + {stats && ( + + )} + { + + ))} + +diff --git a/src/panels/SkyViewPanel.tsx b/src/panels/SkyViewPanel.tsx +index 859505c..3fe9b51 100644 +--- a/src/panels/SkyViewPanel.tsx ++++ b/src/panels/SkyViewPanel.tsx +@@ -11,6 +11,8 @@ export const SkyViewPanel = () => { + + useEffect(() => { + if (!canvasRef.current) return; ++ // sky-view.js draws only the current dimensions and satellite data. ++ canvasRef.current.setContinuous(false); + canvasRef.current.setScriptFile("./src/scripts/sky-view.js"); + scriptLoaded.current = true; + }, []); +diff --git a/tests/serial-lifecycle.ts b/tests/serial-lifecycle.ts +new file mode 100644 +index 0000000..43741fb +--- /dev/null ++++ b/tests/serial-lifecycle.ts +@@ -0,0 +1,71 @@ ++import assert from "node:assert/strict"; ++import { EventEmitter } from "node:events"; ++import { SerialManager } from "../src/connection/SerialManager"; ++import type { SerialTransport } from "../src/connection/SerialTransport"; ++ ++class Port extends EventEmitter { ++ isOpen = false; ++ close() { this.isOpen = false; queueMicrotask(() => this.emit("close")); } ++ write() { return true; } ++} ++const openings: { port: Port; complete: (error: Error | null) => void }[] = []; ++const transport: SerialTransport = { ++ list: async () => [{ path: "synthetic" }], ++ open: (_options, callback) => { ++ const port = new Port(); ++ openings.push({ port, complete: (error) => { port.isOpen = !error; callback(error); } }); ++ return port; ++ }, ++}; ++const manager = new SerialManager(transport); ++const errors: string[] = []; ++manager.on("error", (message) => errors.push(message)); ++const statuses: string[] = []; ++manager.on("status", (status) => statuses.push(status)); ++let messages = 0; ++manager.on("message", () => ++messages); ++// UBX ACK-ACK for NAV-PVT, with valid checksum. Generic delivery occurs once. ++const ack = Buffer.from([0xb5, 0x62, 5, 1, 2, 0, 1, 7, 16, 52]); ++manager.connect("synthetic", 38400); ++const first = openings[0]; ++first.complete(null); ++first.port.emit("data", ack.subarray(0, 3)); ++first.port.emit("data", ack.subarray(3)); ++assert.equal(messages, 1); ++manager.disconnect(); ++assert.deepEqual(first.port.eventNames(), []); ++assert.doesNotThrow(() => first.port.emit("data", ack)); ++assert.equal(messages, 1); ++// A completion arriving after disconnect cannot publish connected state. ++manager.connect("synthetic", 38400); ++manager.disconnect(); ++openings[1].complete(null); ++assert.equal(manager.getStatus(), "disconnected"); ++assert.equal(openings[1].port.isOpen, false); ++// An old close or open completion must not affect a newer connection. ++manager.connect("synthetic", 38400); ++const pending = openings[2]; ++manager.connect("synthetic", 38400); ++const current = openings[3]; ++current.complete(null); ++pending.complete(null); ++first.port.emit("close"); ++assert.equal(manager.getStatus(), "connected"); ++assert.equal(pending.port.isOpen, false); ++current.port.emit("data", ack); ++assert.equal(messages, 2); ++// Exercise the actual delayed reconnect path, then release all ownership. ++current.port.isOpen = false; ++current.port.emit("close"); ++assert.equal(manager.getStatus(), "reconnecting"); ++await new Promise((done) => setTimeout(done, 2100)); ++assert.equal(openings.length, 5); ++openings[4].complete(null); ++assert.equal(manager.getStatus(), "connected"); ++openings[4].port.emit("data", ack); ++assert.equal(messages, 3); ++manager.disconnect(); ++await new Promise((done) => setImmediate(done)); ++assert.ok(openings.every(({ port }) => !port.isOpen && port.eventNames().length === 0)); ++assert.deepEqual(errors, []); ++console.log(JSON.stringify({ status: "passed", openings: openings.length, messages, statuses, retainedPortListeners: 0 })); +diff --git a/tsconfig.json b/tsconfig.json +index e467c14..071fafd 100644 +--- a/tsconfig.json ++++ b/tsconfig.json +@@ -1,9 +1,9 @@ + { + "compilerOptions": { + "target": "ES2022", +- "module": "Node16", ++ "module": "ESNext", + "lib": ["ES2022"], +- "moduleResolution": "Node16", ++ "moduleResolution": "Bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, diff --git a/packages/dear-imgui/npm/diagnostics/ubx-application.ts b/packages/dear-imgui/npm/diagnostics/ubx-application.ts new file mode 100644 index 0000000..273b801 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-application.ts @@ -0,0 +1,416 @@ +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { EventEmitter } from "node:events"; +import { cpus, platform, release } from "node:os"; +import { check, waitFor } from "./assertions"; +import { observeNativeFrame, readDiagnostics, counterDelta, waitForNativeIdle } from "./frames"; +import { navSatPacket, navPvtPacket } from "./ubx-bytes"; + +const execute = promisify(execFile); +const here = __dirname; +const output = resolve(process.env.XFRAMES_DIAGNOSTICS_DIR ?? "build/diagnostics/ubx-application/probe"); +mkdirSync(output, { recursive: true }); +const save = (name: string, data: unknown) => writeFileSync(resolve(output, `${name}.json`), JSON.stringify(data, null, 2)); +const delay = (ms: number) => new Promise(done => setTimeout(done, ms)); +let native: any; +let finalReport: any; + +async function main() { + check(process.env.XFRAMES_UBX_APP_DIR, "Set XFRAMES_UBX_APP_DIR to the isolated application"); + const app = resolve(process.env.XFRAMES_UBX_APP_DIR); + const requireApp = createRequire(resolve(app, "package.json")); + const { startResourceServer } = await import("./resource-server.mjs"); + const resources = await startResourceServer(resolve(here, ".."), output); + process.env.UBX_MONITOR_TILE_URL = `${resources.baseUrl}/asset?group=app&hold=1&z={z}&x={x}&y={y}`; + process.env.UBX_MONITOR_TILE_CACHE = resolve(output, "tile-cache"); + await fetch(`${resources.controlUrl}/release?group=app`); + process.chdir(app); + // This is an isolated checkout: each run starts with deterministic UI choices + // and never imports the original receiver's machine-local configuration. + writeFileSync(resolve(app, "config.json"), "{}\n"); + native = requireApp("@xframes/node/dist/xframes.node"); + const common = requireApp("@xframes/common"); + const manager = common.ReactNativePrivateInterface.nativeFabricUIManager; + let baseline: any; + const init = native.init; + native.init = (options: any) => init({ ...options, onInit: (...args: any[]) => { + baseline = readDiagnostics(native); + options.onInit(...args); + }, onBeforeExit: () => { + if (finalReport) { + try { + const terminal = readDiagnostics(native); + check(terminal.scheduler.status === "disposed" && terminal.scheduler.ownerCount === 0 + && terminal.scheduler.activeOwners === 0 && terminal.scheduler.deadlines === 0 + && !terminal.scheduler.wakeAttached && !terminal.scheduler.notificationPending, + "Native shutdown retained scheduler ownership"); + check(Object.values(terminal.platform).every(value => value === 0), "Native shutdown retained platform callbacks"); + check(terminal.resourceState.textures.liveTextures === 0 && terminal.resourceState.textures.retiredTextures === 0, + "Native shutdown retained textures"); + save("result", { ...finalReport, status: "passed", terminal }); + console.log("Ordinary ubx-monitor application: passed"); + } catch (error) { save("failure", { error: String(error), native: readDiagnostics(native) }); process.exit(1); } + } + options.onBeforeExit(); + } }); + const nodes = new Map(); + const operations: Record = {}; + const latest = new Map(); + const highWater: Record = {}; + const inputRetries: string[] = []; + const apply = native.applyCommit; + native.applyCommit = (wire: string) => { + const result = apply(wire); + const ack = JSON.parse(result); + check(ack.status === "applied", `Application publication failed: ${result}`); + for (const op of JSON.parse(wire).operations) { + if (op.op === "create") nodes.set(op.id, { type: op.elementType, ...op.props }); + if (op.op === "patch") Object.assign(nodes.get(op.id), op.props); + } + for (const id of ack.destroyedIds) { nodes.delete(id); latest.delete(id); } + return result; + }; + const internal = native.elementInternalOp; + native.elementInternalOp = (id: number, wire: string) => { + const result = internal(id, wire); + const op = JSON.parse(wire); + const key = `${nodes.get(id)?.type}:${op.op}`; + operations[key] = (operations[key] ?? 0) + 1; + latest.set(id, { ...latest.get(id), [op.op]: op }); + return result; + }; + const { serialManager } = await import(pathToFileURL(resolve(app, "src/connection/SerialManager.ts")).href); + const appIntervals = new Set(); + const originalSetInterval = globalThis.setInterval, originalClearInterval = globalThis.clearInterval; + globalThis.setInterval = ((...args: any[]) => { + const timer = (originalSetInterval as any)(...args); + if (new Error().stack?.replaceAll("\\", "/").includes(app.replaceAll("\\", "/") + "/src/")) appIntervals.add(timer); + return timer; + }) as typeof setInterval; + globalThis.clearInterval = ((timer: any) => { appIntervals.delete(timer); originalClearInterval(timer); }) as typeof clearInterval; + class Port extends EventEmitter { + isOpen = true; + writes: Buffer[] = []; + close(callback?: (error: Error | null) => void) { + this.isOpen = false; + queueMicrotask(() => { this.emit("close"); callback?.(null); }); + } + write(data: Buffer) { this.writes.push(Buffer.from(data)); return true; } + } + const ports: Port[] = []; + serialManager.setTransport({ list: async () => [{ path: "SYNTHETIC", manufacturer: "Deterministic UBX fixture" }], + open: (_options: unknown, callback: (error: Error | null) => void) => { + const port = new Port(); ports.push(port); queueMicrotask(() => callback(null)); return port; + } }); + const input = async (action: string, x = 50, y = 24, value?: string) => { + check(process.platform === "win32", "This bounded application fixture currently supports Windows input"); + if (action === "click") { await input("move", x, y); await delay(200); } + await execute("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolve(here, "native-window.ps1"), + "-ProcessId", String(process.pid), "-Action", action, "-X", String(x), "-Y", String(y), + ...(value === undefined ? [] : ["-Value", value])], { windowsHide: true, timeout: 10_000 }); + }; + const entry = await import(pathToFileURL(resolve(app, "src/index.tsx")).href); + await waitFor(() => manager.getDiagnostics(), (d: any) => d.appliedPublications > 0, "ordinary App publication"); + native.setDiagnosticsEnabled(true); + native.resizeWindow(1280, 900); + const initial = await observeNativeFrame(native, f => f.elementCount > 30, "complete App startup"); + const service = manager.widgetRegistrationService; + const semantic = () => ({ nodes: [...nodes].map(([id, props]) => ({ id, ...props })), latest: [...latest], operations, + serial: serialManager.eventNames().map((event: string) => [event, serialManager.listenerCount(event)]), + bridge: manager.getDiagnostics(), registrations: service.getDiagnostics() }); + const capture = async (name: string) => { + await new Promise((done, reject) => native.captureScreenshot(resolve(output, `${name}.png`), + (error: string) => error ? reject(new Error(error)) : done())); + save(name, { native: readDiagnostics(native), semantic: semantic() }); + }; + const visit = async (label: string, x: number) => { + const id = [...nodes].find(([, props]) => props.type === "tab-item" && props.label === label)?.[0]; + check(id !== undefined, `Missing application tab ${label}`); + for (let attempt = 1; attempt <= 3; ++attempt) { + await input("click", x, 43); + try { + await waitFor(() => readDiagnostics(native), f => (f.elements?.find(n => n.id === id)?.bounds[2] ?? 0) > 0, + `${label} visible content`, 2000); + return; + } catch (error) { + inputRetries.push(`${label}:${attempt}`); + console.log(`Input retry ${attempt}: ${label}`); + if (attempt === 3) throw error; + } + } + }; + const clickUntil = async (label: string, x: number, y: number, predicate: () => boolean) => { + for (let attempt = 1; attempt <= 3; ++attempt) { + if (predicate()) return; + await input("click", x, y); + try { await waitFor(predicate, Boolean, label, 2000); return; } + catch (error) { inputRetries.push(`${label}:${attempt}`); if (attempt === 3) throw error; } + } + }; + const snapshot = () => readDiagnostics(native); + const element = (type: string) => snapshot().elements?.find(node => node.type === type); + const hasText = (test: RegExp) => [...nodes.values()].some(props => props.type === "unformatted-text" && test.test(props.text)); + if (process.argv.includes("--scenario")) { + check(process.env.NODE_ENV === "production", "Acceptance scenario requires production React"); + console.log("Desktop interaction scenario: mouse and focus will be used; missed targets are retried."); + const receipts = { sat: new Map(), pvt: new Map() }; + const observations = { sat: new Map(), pvt: new Map() }; + let messageCount = 0, rawCallbacks = 0, rawBytes = 0, splitPairs = 0, coalescedPairs = 0; + const decoded = (kind: "sat" | "pvt") => (msg: any) => { + const sequence = msg.iTOW / 50; + check(Number.isInteger(sequence) && !receipts[kind].has(sequence), `Duplicate/invalid ${kind} sequence ${sequence}`); + receipts[kind].set(sequence, performance.now()); + }; + const onSat = decoded("sat"), onPvt = decoded("pvt"); + const onMessage = () => ++messageCount; + const onRaw = (chunk: Buffer) => { rawCallbacks++; rawBytes += chunk.length; }; + serialManager.on("NAV-SAT", onSat); serialManager.on("NAV-PVT", onPvt); + serialManager.on("message", onMessage); serialManager.on("rawdata", onRaw); + const listenerCounts = () => Object.fromEntries(serialManager.eventNames().map((event: string) => [event, serialManager.listenerCount(event)])); + const subscriptions = listenerCounts(); + const parsers: any[] = []; + const connect = async () => { + await visit("Connection", 46); + await clickUntil("Connect callback", 44, 140, () => serialManager.getStatus() === "connected"); + parsers.push(serialManager.parser); + check(JSON.stringify(listenerCounts()) === JSON.stringify(subscriptions), "Connection use accumulated application subscriptions"); + }; + const send = (sequence: number) => { + const port = ports.at(-1)!; + check(port.isOpen, "Telemetry source lost its connection"); + const sat = navSatPacket(sequence), pvt = navPvtPacket(sequence); + if (sequence % 2) { + splitPairs++; + port.emit("data", sat.subarray(0, 9)); + port.emit("data", Buffer.concat([sat.subarray(9), pvt])); + } else { coalescedPairs++; port.emit("data", Buffer.concat([sat, pvt])); } + }; + let observationError: unknown; + const inspect = () => { + try { + const frame = snapshot(); + for (const node of frame.elements ?? []) { + const state = node.state; + if (!state) continue; + if (node.type === "di-table") { check(state.rowCount <= 500, "Message history exceeded 500 rows"); highWater.messages = Math.max(highWater.messages ?? 0, state.rowCount); } + if (["plot-line", "plot-scatter"].includes(node.type)) { + check(state.pointCount <= state.pointsLimit * (state.seriesCount ?? 1), "Position history exceeded its declared limit"); + highWater[node.type] = Math.max(highWater[node.type] ?? 0, state.pointCount); + } + if (node.type === "clipped-multi-line-text-renderer") { + check(state.byteCount <= 3 * 65536, "Console history exceeded its UTF-8 bound"); + highWater.consoleBytes = Math.max(highWater.consoleBytes ?? 0, state.byteCount); + } + if (node.type === "map-view") { + check(state.polylinePoints <= 1000, "Map trail exceeded 1000 points"); + highWater.mapTrail = Math.max(highWater.mapTrail ?? 0, state.polylinePoints); + } + } + const series = frame.elements?.find(n => n.type === "plot-bar")?.state?.series; + const marker = frame.elements?.find(n => n.type === "map-view")?.state?.lastMarker; + const values: ["sat" | "pvt", number | undefined][] = [ + ["sat", series?.length === 4 && series.every((s: any) => s.count === 1) + ? series.reduce((sum: number, s: any, i: number) => sum + (s.lastY - 10 * (i + 1)) * 10 ** i, 0) : undefined], + ["pvt", marker ? Math.round((marker.lon + .12) * 1e6) : undefined], + ]; + for (const [kind, sequence] of values) { + if (sequence === undefined || !receipts[kind].has(sequence) || observations[kind].has(sequence)) continue; + observations[kind].set(sequence, { sequence, latencyMs: performance.now() - receipts[kind].get(sequence)!, frameId: frame.frameId }); + } + highWater.parserBytes = Math.max(highWater.parserBytes ?? 0, serialManager.parser?._buffer.length ?? 0); + } catch (error) { observationError = error; } + }; + const poll = setInterval(inspect, 10); + await connect(); + send(0); + const streamBefore = snapshot(); + const cpuBefore = process.cpuUsage(), memoryBefore = process.memoryUsage().rss; + const start = performance.now(); + const delivery: number[] = []; + // This source timer never waits for a frame, screenshot, or interaction. + const stream = new Promise((done, reject) => { + const tick = (sequence: number) => setTimeout(() => { + try { + send(sequence); delivery.push(performance.now() - start); + if (sequence === 1200) done(); else tick(sequence + 1); + } catch (error) { reject(error); } + }, Math.max(0, start + sequence * 50 - performance.now())); + tick(1); + }); + await visit("Signals", 505); + await observeNativeFrame(native, f => f.elements.some(n => n.type === "plot-bar" && n.state?.series.every((s: any) => s.count === 1)), "four CNO bands"); + await capture("signals"); + await clickUntil("CNO sort callback", 282, 84, () => { + const series = element("plot-bar")?.state?.series; + return series?.length === 4 && series.every((s: any, i: number) => s.lastX === 3 - i); + }); + await capture("signals-sorted"); + await visit("Messages", 188); + await observeNativeFrame(native, f => f.elements.some(n => n.type === "di-table" && n.state.rowCount > 0), "populated message table"); + await capture("messages"); + await visit("Map", 330); + await observeNativeFrame(native, f => f.elements.some(n => n.type === "map-view" && (n.resources?.loadedTextures ?? 0) > 0 + && n.state.markerCount === 1 && n.state.overlayCount === 1 && n.state.polylinePoints > 0), "populated local Map with overlays"); + await capture("map"); + const zoomBefore = element("map-view")?.state.zoom; + for (let attempt = 1; attempt <= 3; ++attempt) { + await input("move", 640, 450); await delay(200); await input("wheel", 640, 450); + try { await waitFor(() => element("map-view")?.state.zoom, z => z === zoomBefore + 1, "map wheel zoom", 2000); break; } + catch (error) { inputRetries.push(`map zoom:${attempt}`); if (attempt === 3) throw error; } + } + await observeNativeFrame(native, f => f.elements.some(n => n.type === "map-view" && (n.resources?.loadedTextures ?? 0) > 0 + && n.state.zoom === zoomBefore + 1 && n.state.markerCount === 1 && n.state.overlayCount === 1), "zoom retains map overlays"); + await capture("map-zoomed"); + await visit("Sky View", 445); + await observeNativeFrame(native, f => f.elements.some(n => n.type === "di-js-canvas" && n.bounds[2] > 0 && n.resources?.scriptReady), "actual sky script visible"); + await capture("sky"); + await stream; + const streamElapsedMs = performance.now() - start; + await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1200) && o.pvt.has(1200), "final telemetry in submitted state"); + if (observationError) throw observationError; + check(receipts.sat.size === 1201 && receipts.pvt.size === 1201 && messageCount === 2402, "Sustained input accounting failed"); + check(delivery.length === 1200 && delivery.at(-1)! >= 59990, "Sustained scheduling was shorter than 60 seconds"); + await delay(Math.max(0, start + 60000 - performance.now())); + const streamAfter = snapshot(); + save("stream", { requestedHzPerType: 20, requestedPerType: 1200, delivery, receipts: { sat: [...receipts.sat], pvt: [...receipts.pvt] }, + observations: { sat: [...observations.sat.values()], pvt: [...observations.pvt.values()] }, highWater, operations }); + await capture("sky-final"); + // Connected pause preserves stale seconds while static canvas activity stops. + await waitFor(() => hasText(/stale (?:[4-9]|[1-9][0-9]+)s/), Boolean, "connected stale status", 10000); + const pauseStart = snapshot(); + const staleBefore = [...nodes.values()].find(p => /stale \d+s/.test(p.text ?? ""))?.text; + await delay(2200); + check([...nodes.values()].some(p => /stale \d+s/.test(p.text ?? "") && p.text !== staleBefore), "Connected stale seconds stopped updating"); + const pauseEnd = snapshot(); + check(pauseEnd.scheduler.activeOwners === 0 && counterDelta(pauseEnd.scheduler.submitted, pauseStart.scheduler.submitted) < 20, + "Data-driven sky retained continuous frames during connected pause"); + await capture("paused-stale"); + const skySize = element("di-js-canvas")!.bounds[2]; + native.resizeWindow(1180, 820); + await observeNativeFrame(native, f => f.elements.some(n => n.type === "di-js-canvas" && n.bounds[2] !== skySize), "static sky resize wake"); + await capture("sky-resized"); + native.resizeWindow(1280, 900); + send(1201); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1201) && o.pvt.has(1201), "resumed input submitted"); + await capture("resumed"); + await visit("Connection", 46); + await clickUntil("Disconnect callback", 44, 140, () => serialManager.getStatus() === "disconnected"); + check(ports[0].eventNames().length === 0 && parsers[0].eventNames().length === 0, "Disconnected transport/parser retained listeners"); + await connect(); + send(1202); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1202) && o.pvt.has(1202), "reconnected telemetry submitted"); + await clickUntil("Final disconnect callback", 44, 140, () => serialManager.getStatus() === "disconnected"); + await visit("Sky View", 445); + await input("move", 1100, 875); + await delay(4500); + const idle = await waitForNativeIdle(native, "disconnected App settled"); + await delay(10000); + const afterIdle = snapshot(); + check(afterIdle.scheduler.submitted === idle.scheduler.submitted && afterIdle.scheduler.constructed === idle.scheduler.constructed, + "Disconnected App constructed/submitted continuing frames"); + await capture("disconnected"); + // Real resource work on the actual static sky canvas still wakes it. + const canvasId = element("di-js-canvas")!.id; + const canvasPublicId = service.getDiagnostics().mappings.find((mapping: any) => mapping.nativeId === canvasId)?.publicId; + check(canvasPublicId, "Canvas public registration missing"); + service.loadCanvasTexture(canvasPublicId, "qualification", resolve(resources.assets, "fixture.png")); + await observeNativeFrame(native, f => f.elements.some(n => n.id === canvasId && n.resources?.loadedTextures === 1), "static sky resource upload wake"); + // Queue new Map HTTP work and release it only after the ordinary disposer. + await visit("Map", 330); + await waitFor(async () => (await fetch(`${resources.controlUrl}/state?group=app`)).json(), s => s.pending === 0, "map requests settle before late fixture"); + await fetch(`${resources.controlUrl}/reset?group=app`); + await input("move", 640, 450); await delay(200); await input("wheel", 640, 450); + const held = await waitFor(async () => (await fetch(`${resources.controlUrl}/state?group=app`)).json(), s => s.pending > 0, "real late Map request held"); + clearInterval(poll); inspect(); + if (observationError) throw observationError; + const populated = { native: snapshot(), semantic: semantic() }; + await entry.dispose(); + serialManager.off("NAV-SAT", onSat); serialManager.off("NAV-PVT", onPvt); + serialManager.off("message", onMessage); serialManager.off("rawdata", onRaw); + await waitFor(() => serialManager.eventNames().length, (n: number) => n === 0, "all application passive subscriptions removed"); + check(appIntervals.size === 0, "Application retained interval handles after passive cleanup"); + const empty = await observeNativeFrame(native, f => f.elementCount === 0 && f.hierarchyCount === 1 && f.internalSubjectCount === 0, "ordinary disposer empty native baseline"); + check(ports.every(p => !p.isOpen && p.eventNames().length === 0) && parsers.every(p => p.eventNames().length === 0), "Port/parser lifetime leak"); + const generation = empty.scheduler.generation; + for (const port of ports) { port.emit("data", Buffer.concat([navSatPacket(1203), navPvtPacket(1203)])); port.emit("close"); } + await fetch(`${resources.controlUrl}/release?group=app`); + await waitFor(() => snapshot(), f => f.resourceState.mapWorkers?.active === 0 && f.resourceState.mapWorkers?.queued === 0, + "retired map workers finish late resources"); + const final = await waitForNativeIdle(native, "disposed App settles"); + check(final.scheduler.generation === generation, "Late transport/resource input revived the disposed App"); + check(final.scheduler.ownerCount === baseline.scheduler.ownerCount && final.scheduler.activeOwners === 0 && final.scheduler.deadlines === 0, + "App retained scheduler owners after disposal"); + check(final.resourceState.textures.liveTextures === 0 && final.resourceState.textures.retiredTextures === 0 + && final.resourceState.queuedPrefetchEvents === 0, "App retained native resources"); + const js = manager.getDiagnostics(), registrations = service.getDiagnostics(); + check(js.fiberCount === 0 && js.committedDescriptionCount === 0 && js.pendingEventCount === 0 && js.subscriptionClosed, + "App retained Fabric descriptions/events"); + check(["mappingCount", "tableCount", "mapCount", "reverseMappingCount", "nativeCount", "registrationCount"] + .every(key => registrations[key] === 0) && registrations.disposed, "App retained JS registrations"); + check(Number(messageCount) === 2406 && Number(receipts.sat.size) === 1203 && Number(receipts.pvt.size) === 1203, "Final message accounting differs"); + const distribution = (kind: "sat" | "pvt") => { + const samples = [...observations[kind].values()].filter(s => s.sequence >= 1 && s.sequence <= 1200).map(s => s.latencyMs).sort((a, b) => a - b); + const p = (fraction: number) => samples[Math.ceil(samples.length * fraction) - 1]; + return { observed: samples.length, coalescedOrUnobservedBetweenPolls: 1200 - samples.length, + p50: p(.5), p95: p(.95), p99: p(.99), maximum: p(1) }; + }; + finalReport = { status: "awaiting-native-shutdown", mode: "production", app, metadata: { node: process.version, + react: requireApp("react").version, os: `${platform()} ${release()}`, cpu: cpus()[0]?.model, backend: (initial as any).backend }, + input: { requestedPerType: 1200, requestedHzPerType: 20, scheduledDurationMs: 60000, deliveredElapsedMs: delivery.at(-1), + streamElapsedMs, actualHzPerType: 1200000 / delivery.at(-1)!, decodedPerTypeIncludingLifecycle: receipts.sat.size, + messageCount, rawCallbacks, rawBytes, splitPairs, coalescedPairs }, + receiptToObservedSubmissionMs: { sat: distribution("sat"), pvt: distribution("pvt") }, + frameCounts: { sustained: counterDelta(streamAfter.scheduler.submitted, streamBefore.scheduler.submitted), + connectedPause: counterDelta(pauseEnd.scheduler.submitted, pauseStart.scheduler.submitted), disconnected10s: 0 }, + highWater, operations, inputRetries, heldRequestsAtUnmount: held, populated, baseline, final, js, registrations, + applicationIntervalsAfterCleanup: appIntervals.size, + portsAfterCleanup: ports.map(p => ({ open: p.isOpen, listeners: p.eventNames().length })), + parserListenersAfterCleanup: parsers.map(p => p.eventNames().length), telemetryListenersAfterCleanup: serialManager.eventNames().length, + processMeasurements: { rssBefore: memoryBefore, rssAfter: process.memoryUsage().rss, cpuMicroseconds: process.cpuUsage(cpuBefore) }, + unavailable: ["physical serial", "network tile service", "GPU completion/presentation latency", "hardware WebGPU", "controlled performance"] }; + save("result", finalReport); + await resources.close(); + await input("close"); + return; + } + await capture("startup"); + const before = readDiagnostics(native); + await delay(3500); + save("disconnected-before-fixes", { before, after: readDiagnostics(native), semantic: semantic() }); + await input("click", 44, 140); + await waitFor(() => serialManager.getStatus(), (s: string) => s === "connected", "Connect button event"); + ports.at(-1)!.emit("data", Buffer.concat([navSatPacket(1), navPvtPacket(1)])); + await delay(500); + await visit("Signals", 505); + await delay(500); + await capture("signals"); + await visit("Sky View", 445); + await delay(500); + await capture("sky"); + const skyBefore = readDiagnostics(native); + await delay(1500); + save("sky-before-fixes", { before: skyBefore, after: readDiagnostics(native), semantic: semantic() }); + await visit("Map", 330); + await delay(1000); + await capture("map"); + await visit("Messages", 188); + await delay(500); + await capture("messages"); + serialManager.disconnect(); + // Probe remains a bounded development startup check, never sustained acceptance. + await entry.dispose(); + await waitFor(() => serialManager.eventNames().length, (n: number) => n === 0, "App passive subscriptions released"); + await observeNativeFrame(native, f => f.elementCount === 0, "ordinary disposer native empty tree"); + save("result", { status: "probe-passed", initial, final: readDiagnostics(native), semantic: semantic(), + framesDuringDisconnectedProbe: counterDelta(readDiagnostics(native).scheduler.submitted, before.scheduler.submitted) }); + await resources.close(); +} +const watchdog = setTimeout(() => { console.error("Application watchdog expired"); process.exit(1); }, 180_000); +main().then(() => { clearTimeout(watchdog); process.exit(0); }).catch(error => { + save("failure", { error: String(error), stack: error?.stack, native: native ? readDiagnostics(native) : null }); + console.error(error); clearTimeout(watchdog); process.exit(1); +}); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-bytes.ts b/packages/dear-imgui/npm/diagnostics/ubx-bytes.ts new file mode 100644 index 0000000..95519a2 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-bytes.ts @@ -0,0 +1,41 @@ +/** Deterministic UBX wire packets. Positions are synthetic, never receiver logs. */ +export function ubxPacket(messageId: number, payload: Buffer, messageClass = 1): Buffer { + const packet = Buffer.alloc(payload.length + 8); + packet.set([0xb5, 0x62, messageClass, messageId]); + packet.writeUInt16LE(payload.length, 4); + packet.set(payload, 6); + let a = 0, b = 0; + for (const byte of packet.subarray(2, -2)) { a = (a + byte) & 255; b = (b + a) & 255; } + packet[packet.length - 2] = a; packet[packet.length - 1] = b; + return packet; +} +export const cnoValues = (sequence: number) => [10 + sequence % 10, 20 + Math.floor(sequence / 10) % 10, + 30 + Math.floor(sequence / 100) % 10, 40 + Math.floor(sequence / 1000) % 10]; +export function navSatPacket(sequence: number) { + const payload = Buffer.alloc(8 + 4 * 12); + payload.writeUInt32LE(sequence * 50, 0); payload[4] = 1; payload[5] = 4; + const cno = cnoValues(sequence); + for (let i = 0; i < 4; ++i) { + const offset = 8 + i * 12; + payload[offset] = [0, 2, 3, 6][i]; payload[offset + 1] = i + 1; + payload[offset + 2] = cno[i]; payload[offset + 3] = 30 + i * 10; + payload.writeInt16LE(10 + i * 90 + sequence % 15, offset + 4); + payload.writeUInt32LE(8, offset + 8); + } + return ubxPacket(0x35, payload); +} +export function navPvtPacket(sequence: number) { + const payload = Buffer.alloc(92); + payload.writeUInt32LE(sequence * 50, 0); + payload.writeUInt16LE(2026, 4); payload[6] = 9; payload[7] = 11; + payload[8] = 12; payload[9] = Math.floor(sequence / 1200) % 60; payload[10] = Math.floor(sequence / 20) % 60; + payload[11] = 7; payload.writeUInt32LE(100, 12); + payload[20] = 3; payload[21] = 1; payload[23] = 4; + payload.writeInt32LE(-1200000 + sequence * 10, 24); + payload.writeInt32LE(515000000 + (sequence % 100) * 10, 28); + payload.writeInt32LE(80000 + sequence, 32); payload.writeInt32LE(50000 + sequence, 36); + payload.writeUInt32LE(30000, 40); payload.writeUInt32LE(40000, 44); + payload.writeInt32LE(1000 + sequence, 60); payload.writeInt32LE(9000000, 64); + payload.writeUInt32LE(100, 68); payload.writeUInt32LE(1000, 72); payload.writeUInt16LE(120, 76); + return ubxPacket(7, payload); +} From 880506ed5190f5375273d0dd77da903b2edcd8fa Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Fri, 11 Sep 2026 22:49:29 +0200 Subject: [PATCH 4/7] feat: qualify ubx-monitor UI pacing and streaming costs --- ROADMAP.md | 33 +- docs/README.md | 2 + .../ubx-monitor-application-2026-09.md | 16 +- .../ubx-monitor-pacing-cost-2026-09.md | 213 ++ .../ubx-monitor-streaming-2026-09.md | 361 +++ goal.txt | 60 +- packages/dear-imgui/npm/diagnostics/README.md | 102 + .../npm/diagnostics/ubx-application-setup.mjs | 37 +- .../npm/diagnostics/ubx-application.ts | 347 ++- .../npm/diagnostics/ubx-comparison-report.mjs | 96 + .../dear-imgui/npm/diagnostics/ubx-history.ts | 136 + .../npm/diagnostics/ubx-pacing.patch | 2255 +++++++++++++++++ .../npm/diagnostics/ubx-rate-select.ps1 | 16 + .../npm/diagnostics/ubx-source.test.ts | 36 + .../dear-imgui/npm/diagnostics/ubx-source.ts | 41 + 15 files changed, 3672 insertions(+), 79 deletions(-) create mode 100644 docs/engineering/ubx-monitor-pacing-cost-2026-09.md create mode 100644 docs/engineering/ubx-monitor-streaming-2026-09.md create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-comparison-report.mjs create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-history.ts create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-pacing.patch create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-rate-select.ps1 create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-source.test.ts create mode 100644 packages/dear-imgui/npm/diagnostics/ubx-source.ts diff --git a/ROADMAP.md b/ROADMAP.md index 3a27c2c..6c392c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,9 @@ See the [publication record](docs/engineering/fabric-publication-2026-09.md), [h The GPUIX assessment identifies bridge correctness, lifecycle discipline, automation, and performance evidence as gaps to close while concentrating product work on Plot, Table, Map, and Canvas. The August Fabric upgrade and screenshot smokes provide foundations for this work. End-to-end performance advantages still require measurement. -The [ordinary ubx-monitor application slice](docs/engineering/ubx-monitor-application-2026-09.md) passed on Windows: current packed packages, complete application startup/typechecking, sustained synthetic NAV-SAT/NAV-PVT input, visible Plot/Table/Map/Canvas behavior and interaction, pause/resume, disconnect/reconnect and cleanup. The existing CNO panel is retained; configurable UI update rates remain follow-up work. Review the milestone against the acceptance criteria below before expanding into durable replay and comprehensive automation (Stages 5–6). General shell and rich-text work remain driven by demonstrated application needs; additional language bindings remain out of scope. +The [ordinary ubx-monitor application slice](docs/engineering/ubx-monitor-application-2026-09.md) passed on Windows: current packed packages, complete application startup/typechecking, sustained synthetic NAV-SAT/NAV-PVT input, visible Plot/Table/Map/Canvas behavior and interaction, pause/resume, disconnect/reconnect and cleanup. The existing CNO panel is retained. Review the milestone against the acceptance criteria below before expanding into durable replay and comprehensive automation (Stages 5–6). General shell and rich-text work remain driven by demonstrated application needs; additional language bindings remain out of scope. + +The bounded [ubx-monitor UI update pacing slice](docs/engineering/ubx-monitor-streaming-2026-09.md) is complete: persisted 10/20/60 display rates, source-owned bounded histories, live rate-change and cleanup gates, final patch reproduction and three quiet-window paired comparisons. At 120 Hz/type, 20-Hz pacing reduced native snapshot work by 53–55% and instrumented process CPU by 25–30%, with higher observed latency. [Full paired results](docs/engineering/ubx-monitor-pacing-cost-2026-09.md) preserve coverage and evidence limits; broader framework performance targets remain open. [goal.txt](goal.txt) records the bounded task. --- @@ -95,9 +97,9 @@ Once fixed: ### Streaming Architecture -- [ ] Measure and improve the data pipeline: serial port → native parser → JS → XFrames render loop, using the Phase 12 baseline and instrumentation -- [ ] Configurable update rates (throttle UI updates independently of message rate) -- [ ] Validate ubx-monitor against repeatable telemetry input and the Phase 12 benchmark harness; distinguish measured data-to-frame latency from presentation timing where available +- [ ] Measure and improve the data pipeline: serial port → native parser → JS → XFrames render loop, using the Phase 12 baseline and instrumentation — next bounded application comparison in the [streaming plan](docs/engineering/ubx-monitor-streaming-2026-09.md) +- [ ] Configurable update rates (pace UI publication independently of message rate while preserving bounded histories) — next slice, planned 10/20/60 updates per second with default 20 +- [ ] Validate ubx-monitor against repeatable telemetry input and the Phase 12 benchmark harness; bounded ordinary-App correctness passed, while a matched streaming-cost comparison and broader performance targets remain open --- @@ -285,8 +287,27 @@ cleanup. Focused integration/ownership/tab/color fixes passed 380 Windows native tests, affected package/Fabric/lifecycle checks, rebuilt Node/Wasm short suites and 65-result parity. The original external checkout remains unchanged. Shared-host timings are informational; synthetic transport/local tiles do not -qualify physical serial or public services. Configurable UI rates, controlled -performance, broader hardware and subsequent hosted CI remain follow-ups. +qualify physical serial or public services. The separate pacing slice below adds +UI rates and a bounded quiet comparison; broader hardware, framework performance +and subsequent hosted CI remain follow-ups. + +### ubx-monitor UI Update Pacing (bounded slice complete) + +The [streaming record](docs/engineering/ubx-monitor-streaming-2026-09.md) delivers +persisted 10/20/60 UI rates (default 20), bounded shared publication cadence and +source-sample histories independent of React effects. It continues from XFrames +`54bc382` / application `e7de9e2` in an isolated reproducible patch, preserving the +original checkout. Final 20-Hz and high-input native rate-transition gates pass, +including Position/Console exact tails, reset, pause/reconnect and disposal. +Native rendering, input and resource wakes keep their scheduler contract. + +Three quiet-window pairs at 120 Hz/type passed with identical retained histories +using a retention-correct unpaced control: the qualified baseline already skipped +retained samples. Snapshot work fell 53–55%, measured process CPU 25–30%, and +frames only 1–2%; observed latency increased. The [cost report](docs/engineering/ubx-monitor-pacing-cost-2026-09.md) +includes every pair, source identities, coalescing, CPU/RSS, stage costs and +latency coverage. Framework p95/p99 targets remain open, as do broader milestone +criteria. Existing 1,000-cycle/extended gates and Stage 5–6 deferral remain intact. ### Milestone Review Gate — After Stages 0–4 diff --git a/docs/README.md b/docs/README.md index cc41c78..e7cd6a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ The live [xframes.dev](https://xframes.dev) website is maintained in a separate ## Engineering and verification +- [ubx-monitor UI Update Pacing and Streaming Measurements](engineering/ubx-monitor-streaming-2026-09.md) — completed bounded slice: persisted display rates, source-owned histories, production/lifetime gates and final reproduction. +- [ubx-monitor Paired Pacing Costs](engineering/ubx-monitor-pacing-cost-2026-09.md) — all three quiet-window pairs, exact retention, publication/CPU reductions and the measured freshness tradeoff; broader framework targets remain open. - [ubx-monitor Ordinary Application Qualification](engineering/ubx-monitor-application-2026-09.md) — completed bounded Windows slice: current-package full-App integration, sustained synthetic telemetry across Plot/Table/Map/Canvas, connection lifecycle, cleanup and reproducible setup. - [Stage 4: Invalidation Scheduling](engineering/fabric-invalidation-2026-09.md) — delivered scheduler MVP, producer and lifetime boundaries, local validation, measurements, completed hosted CI stabilization and deferred qualification. - [Stage 3: Atomic Fabric Publication](engineering/fabric-publication-2026-09.md) — schema-v2 publication, committed ownership, visibility guarantees and historical acceptance evidence. diff --git a/docs/engineering/ubx-monitor-application-2026-09.md b/docs/engineering/ubx-monitor-application-2026-09.md index 21268e2..2d0c5e5 100644 --- a/docs/engineering/ubx-monitor-application-2026-09.md +++ b/docs/engineering/ubx-monitor-application-2026-09.md @@ -1,8 +1,10 @@ # ubx-monitor ordinary application qualification Status: bounded Windows Node/OpenGL application slice passed on 11 September -2026. The executable task is in [goal.txt](../../goal.txt). Broader milestone and -controlled performance qualification remain open. +2026. This record describes that completed slice. The subsequent completed +[UI update pacing slice](ubx-monitor-streaming-2026-09.md) is recorded separately, +with its [paired cost results](ubx-monitor-pacing-cost-2026-09.md). Broader milestone and framework performance +qualification remain open. ## Purpose and starting boundary @@ -345,3 +347,13 @@ serial, public tile services, other desktop environments, controlled UI update rates/performance, hardware WebGPU, mixed DPI, GPU completion/presentation, equivalent Electron/GPUIX comparisons and consistently green CI over later changes remain unqualified here. Stages 5–6 and the broader milestone remain open. + +## Subsequent bounded slice + +The completed [UI update pacing slice](ubx-monitor-streaming-2026-09.md) continues +from XFrames `54bc382` and application `e7de9e2`, adding source-owned histories, +persisted 10/20/60 display rates, native rate-change/lifetime checks and a final +reproducible patch. Its [three paired cost runs](ubx-monitor-pacing-cost-2026-09.md) +use a retention-correct unpaced control because this qualified baseline skipped +intermediate position samples at high input. The evidence above remains the +historical integration result, not equivalent pacing or controlled-cost evidence. diff --git a/docs/engineering/ubx-monitor-pacing-cost-2026-09.md b/docs/engineering/ubx-monitor-pacing-cost-2026-09.md new file mode 100644 index 0000000..cd2d81f --- /dev/null +++ b/docs/engineering/ubx-monitor-pacing-cost-2026-09.md @@ -0,0 +1,213 @@ +# ubx-monitor pacing: three paired cost measurements + +Executed 11 September 2026 on a user-confirmed quiet laptop window. All six runs +passed input-rate, exact retention, trailing sample and public/native teardown +checks. This is one Windows Node/OpenGL application comparison, with instrumented +costs and incomplete submission observation; it does not qualify the broader +framework performance targets. + +## Workload and equivalent control + +The qualified `e7de9e2` application was characterized first. Although all 7,200 +scheduled packets of each type arrived at 119.997 Hz/type, its 3,000-point altitude +history omitted **1,986** samples from the expected final tail (4,201–7,200). +The retained sequence started at 156. Evidence is +`packages/dear-imgui/npm/build/diagnostics/ubx-pacing/qualified-characterization-01`. +That baseline is not an equivalent retained-data workload. + +The paired control therefore uses the candidate's corrected ingestion and bounded +histories with diagnostic-only immediate publication. Both modes run the same +source, including cached message-row formatting; the only selected policy is +unpaced versus the ordinary 20-Hz UI cadence. This control is selected before App +mount and is neither a persisted setting nor a product option. + +Each run sends checksummed NAV-SAT and NAV-PVT at 120 Hz/type for 60 seconds: +7,200 scheduled pairs, plus one separately counted warm-up pair. Identical packets +alternate split and coalesced transport callbacks. Every run decoded 14,402 +messages from 10,801 callbacks and 1,180,964 bytes, with no sequence gaps. Actual +rates were 119.948–119.997 Hz/type, exceeding the 95% acceptance threshold. + +Signals remains visible at 1280 × 900; all normal App subscriptions stay mounted. +Each process uses a fresh local tile cache, one warm-up pair, 4.5 seconds of +settling and verified resource/native inactivity before measurement. No desktop +helpers, captures, builds or tests execute in the measured interval. Order was +U1, P1, P2, U2, U3, P3. The earlier shared-host +`comparison-pair-1-unpaced` run remains retained and is excluded from these pairs +because the user subsequently provided a quiet window, not because of its result. + +Host: AMD Ryzen 7 5700U / AMD Radeon Graphics, Windows 10.0.26200, OpenGL 4.6 +driver 23.19.23.13.250826, Node v24.14.0, React 19.2.3, production mode. Quiet +conditions were user-confirmed; OS scheduling, GC and thermal variation were not +eliminated. Every `source-identity.json` is identical across the six runs, including +application source, package lock, fonts, native/common binaries and diagnostic +hashes. The [streaming record](ubx-monitor-streaming-2026-09.md) lists reproduction +and package identities. + +## Counts and process costs + +U = retention-correct unpaced; P = 20-Hz pacing. These counters end at **source +completion**, before trailing publication, screenshot and teardown. CPU is total +process user + system time, so it can exceed 60 seconds on this multicore host. +RSS is a pair of process endpoints, including diagnostic storage, not a peak or +application-only memory measurement. + +| Run | Hz/type | CPU seconds | RSS MiB before → after | Applied Fabric publications | Each snapshot operation | Submitted frames | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| U1 | 119.948 | 83.048 | 198.09 → 556.80 | 2,104 | 2,104 | 3,601 | +| P1 | 119.997 | 58.406 | 198.52 → 451.21 | 1,031 | 988 | 3,531 | +| U2 | 119.984 | 81.532 | 197.30 → 535.07 | 2,155 | 2,154 | 3,600 | +| P2 | 119.986 | 59.578 | 197.55 → 463.71 | 1,046 | 984 | 3,544 | +| U3 | 119.968 | 82.407 | 199.00 → 549.63 | 2,135 | 2,135 | 3,602 | +| P3 | 119.997 | 61.734 | 198.68 → 445.69 | 1,004 | 951 | 3,555 | + +“Each snapshot operation” means each of Table rows, Map trail, Map markers, Map +overlays, Sky data, position scatter and Signals bar-series replacement; they +have equal counts within a run. Console append calls also equal this column. +Applied Fabric publications are acknowledged atomic React/Fabric publications, +not a count of React component renders. Status and Console rotation can create +additional structural work independently of telemetry snapshots. + +Three line plots append retained source samples rather than decimating them. +Their combined source-boundary append counts were U1/P1 21,585/21,582, +U2/P2 21,582/21,591, U3/P3 21,591/21,585. The few final appends after this boundary +are included in the exact-tail check. No line reset occurred during measurement; +the Map render and Sky script/continuous-mode setup counts were zero during it. + +| Paired reduction | Pair 1 | Pair 2 | Pair 3 | Median | +| --- | ---: | ---: | ---: | ---: | +| Process CPU | 29.67% | 26.93% | 25.09% | 26.93% | +| Snapshot operations | 53.04% | 54.32% | 55.46% | 54.32% | +| Applied Fabric publications | 51.00% | 51.46% | 52.97% | 51.46% | +| Submitted frames | 1.94% | 1.56% | 1.30% | 1.56% | + +Pacing reduces snapshot work and measured CPU in every pair, with higher observed +latency and fewer observed intermediate states. It does not produce a 20-FPS +renderer. Frame count remains near 60/sec under this continuously active workload. + +## Eligibility, batching and retained data + +Counts here extend **through the final sample** and subtract settled warm-up. +Each run has 111,600 owner requests: Console 10,800; each of two message owners +25,200; each of three PVT owners 7,200; each of two SAT owners 7,200; position and +trail owners 7,200 each. Hardware, DOP and NAV-STATUS owners remain mounted but +receive no corresponding packets in this cell. Requests include both raw and +decoded notifications, so they are not packet counts. + +| Mode/run | Logical flushes | Owner publications | Coalesced owner requests | Cancelled | +| --- | ---: | ---: | ---: | ---: | +| Each U | 111,600 immediate owner flushes | 111,600 | 0 | 0 | +| P1 | 989 shared flushes | 9,890 | 101,710 | 0 | +| P2 | 985 shared flushes | 9,850 | 101,750 | 0 | +| P3 | 952 shared flushes | 9,520 | 102,080 | 0 | + +Unpaced immediate owner flushes are not shared timer ticks. The paced mode has +ten eligible owners per flush, one shared pending timer, and zero pending work +after final delivery. Observed paced median flush intervals were +61.992/61.992/62.107 ms: actual publication is below the configured maximum because +timers and synchronous work can delay callbacks. No missed-tick replay occurs. +First-owner instrumentation minima were 50.005/49.987/49.996 ms (small clock-read +offsets relative to the cadence anchor); deterministic clock tests prove the +strict 50-ms scheduling bound and continuous/trailing delivery. + +All six runs retain exactly positions 4,201–7,200 (3,000 samples), trail +6,201–7,200 (1,000 points), message arrival ordinals 13,903–14,402 (500 rows), and +57,234 Console UTF-16 code units / 59,170 UTF-8 bytes at wire offset 1,180,964. +Declared evictions are 4,201 positions, 6,201 trail points, 13,902 messages and +6,718,907 Console code units. The position/scatter/three-line values, ordered trail, +message ordinals and complete retained Console text match independent oracles. +This proves ordered data sent through successful native widget API calls; +submitted native summaries corroborate bounds and final values. It does not +claim a full native-buffer readback. Source timestamps remain arrival times. + +## Freshness and observation coverage + +Receipt-to-observed-submission includes application batching, React/native work +and polling delay. SAT is correlated from submitted bar values and PVT from the +submitted Map marker. Each type has 7,200 eligible source sequences. Missing +observations combine intentionally coalesced snapshots and states skipped between +polls; they are not decoded-packet loss. The final sequence is observed in all runs. +Quantiles use nearest rank without interpolation; units are milliseconds. + +| Run | SAT observed / 7,200 | SAT p50 / p95 / p99 / max | PVT observed / 7,200 | PVT p50 / p95 / p99 / max | +| --- | ---: | --- | ---: | --- | +| U1 | 1,924 | 49.582 / 76.010 / 90.430 / 105.842 | 1,796 | 34.175 / 51.843 / 65.762 / 104.206 | +| P1 | 989 | 63.683 / 89.611 / 110.206 / 168.569 | 986 | 49.421 / 68.023 / 79.282 / 91.020 | +| U2 | 1,962 | 50.237 / 71.571 / 84.981 / 117.818 | 1,794 | 32.655 / 50.153 / 62.868 / 87.286 | +| P2 | 985 | 63.825 / 89.941 / 118.568 / 144.966 | 983 | 50.438 / 73.543 / 88.028 / 105.665 | +| U3 | 1,971 | 48.990 / 73.961 / 85.839 / 109.064 | 1,846 | 33.952 / 51.158 / 64.342 / 73.022 | +| P3 | 952 | 63.774 / 110.005 / 121.976 / 171.784 | 952 | 50.220 / 90.220 / 98.843 / 105.931 | + +The separate publication observer records preparation start/end and React enqueue, +not React commit. SAT publication covers 7,200 unique samples per U run versus +989/985/952 per P run, with two owners per sample. PVT has three owners per +published sample; position and trail each have one. Receipt-to-publication for +the selected latest sample differs from the time the oldest pending work waits: + +| Run | SAT receipt → enqueue p50 / p95 / p99 / max | PVT receipt → enqueue p50 / p95 / p99 / max | +| --- | --- | --- | +| U1 | 0.010 / 0.023 / 0.036 / 0.363 | 0.007 / 1.470 / 2.428 / 18.652 | +| P1 | 13.971 / 16.920 / 18.639 / 21.458 | 14.217 / 17.781 / 19.695 / 23.666 | +| U2 | 0.010 / 0.022 / 0.035 / 0.240 | 0.007 / 1.409 / 2.235 / 7.177 | +| P2 | 13.985 / 16.928 / 19.131 / 34.305 | 14.269 / 17.714 / 20.059 / 36.017 | +| U3 | 0.009 / 0.022 / 0.036 / 0.434 | 0.007 / 1.444 / 2.465 / 12.748 | +| P3 | 13.538 / 16.368 / 18.336 / 23.428 | 13.728 / 17.306 / 19.225 / 24.858 | + +Paced position first-pending-to-start p50/p95/p99/max were +58.989/62.453/64.639/69.479, 58.928/62.571/67.531/93.538 and +59.860/75.006/83.018/89.374 ms. This includes deliberate cadence waiting and timer +lateness; it is not renderer latency. Position preparation plus React enqueue +totals fell from 7.068/6.753/7.008 seconds to 1.204/1.222/1.371 seconds. Per-call +p95 rose from 1.975/1.858/1.983 to 2.622/2.580/3.889 ms. Message-owner totals fell +from 2.373/2.408/2.301 seconds to 0.675/0.694/0.683 seconds, despite larger batches. +Full per-label p50/p95/p99/max, eligibility, latest-request wait and preparation +distributions are retained in `quiet-comparison-summary.json`; raw/message owners +have no invented single-packet receipt correlation. + +## Instrumentation cost and limits + +All intervals include instrumentation. Explicit observer bookkeeping, fixture +preparation and polling totals are reported separately from measured native-call +boundary time; they are not subtracted from CPU. Observer record retention is +bounded at 250,000 and dropped zero records. U runs retain roughly 111,614 stage +events versus about 9,530–9,900 for P, contributing to unequal RSS and observation +cost even though instrumentation policy is identical. + +| Run | Stage bookkeeping ms | Widget bookkeeping ms | Fixture preparation ms | Polling ms / count | Native commit / internal / Console boundary ms | +| --- | ---: | ---: | ---: | --- | --- | +| U1 | 23.346 | 3,342.148 | 1,036.037 | 2,261.527 / 2,307 | 1,986.160 / 28,484.156 / 107.060 | +| P1 | 4.427 | 2,128.594 | 1,266.233 | 3,227.315 / 2,621 | 1,160.498 / 19,574.493 / 126.129 | +| U2 | 29.964 | 3,407.511 | 1,032.346 | 2,183.787 / 2,322 | 1,986.304 / 28,894.813 / 107.265 | +| P2 | 5.915 | 2,177.777 | 1,332.266 | 3,283.001 / 2,561 | 1,216.422 / 19,783.931 / 119.974 | +| U3 | 20.658 | 3,371.908 | 1,041.026 | 2,239.117 / 2,330 | 2,029.707 / 28,686.032 / 110.871 | +| P3 | 4.732 | 2,220.807 | 1,350.075 | 3,125.031 / 2,490 | 1,163.251 / 20,717.919 / 117.603 | + +Polling is nominally 10 ms but only executes about 38–44 times/sec under this +workload; actual per-poll intervals were not recorded. Its query/parse/bounds +checks are included above. Native boundary durations include blocking and +scheduling, not just compute. Uninstrumented production CPU, peak memory, GPU +completion and physical presentation latency are unavailable. No subtraction of +quantiles or assignment of a latest timestamp to older retained samples is used. + +These results do not meet or close the separate framework observed-frame +p95 < 50 ms / p99 < 100 ms targets. The application deliberately batches, the +observed population differs by mode, and paced SAT p99 exceeds 100 ms in every +pair. The bounded application publication/retention comparison is complete; +broader performance qualification remains open. + +## Recompute and inspect + +All run directories are under +`packages/dear-imgui/npm/build/diagnostics/ubx-pacing/quiet-pair-{1,2,3}-{unpaced,paced}`. +They contain source identities/diffs, independent source delivery, raw receipts +and observations, publication stages, source-boundary measurement, exact retained +data/oracle reports, final capture and terminal result. From the npm workspace: + +```powershell +node diagnostics/ubx-comparison-report.mjs +``` + +This offline script verifies identical source/build/observer identities, rates, +counts, exact retention reports, zero lost stage records and trailing samples; +it writes `build/diagnostics/ubx-pacing/quiet-comparison-summary.json`. It never +launches an app or repeats a measurement. Reproduction commands for the App and +all harness modes are in the [diagnostics guide](../../packages/dear-imgui/npm/diagnostics/README.md). diff --git a/docs/engineering/ubx-monitor-streaming-2026-09.md b/docs/engineering/ubx-monitor-streaming-2026-09.md new file mode 100644 index 0000000..469e2ca --- /dev/null +++ b/docs/engineering/ubx-monitor-streaming-2026-09.md @@ -0,0 +1,361 @@ +# ubx-monitor UI update pacing and streaming measurements + +Status: bounded application slice completed, 11 September 2026. Configurable +pacing, source-owned bounded histories, final-source functional/lifetime checks, +fresh patch reproduction and three quiet-window cost pairs passed. The +[paired report](ubx-monitor-pacing-cost-2026-09.md) records lower publication work +and CPU with a measured freshness tradeoff. Broader framework performance and +milestone qualification remain open. The executable task is [goal.txt](../../goal.txt). + +## Starting point and purpose + +The [ordinary application slice](ubx-monitor-application-2026-09.md) is complete +in XFrames `54bc3827c57c8d8e3a02026ce5998c752e0d1783` and ubx-monitor +`e7de9e25ff3f608e8bb2613e471d0b97f47d58f2`. The latter is available on +`qualification/current-xframes-application` in the original application repository. +Its `main`, local serial settings and dependency graph remain untouched. + +That slice established real App integration and lifetime correctness with current +local packages. Its 60-second, 20-Hz-per-type stream submitted 3,498 frames on an +actively used Windows laptop. Those frames include input, status and resource work; +the count does not identify the cause of application cost. The timing samples are +informational and include native observation delay. + +The delivered application feature is a UI update rate independent of receiver +arrival, with publication and derived-data work paced at the application owner +without losing retained data. The contract and acceptance criteria below are +preserved; executed outcomes follow them. +This advances [Streaming Architecture](../../ROADMAP.md#streaming-architecture). +It does not close the complete Stage 0 benchmark or Stages 0–4 performance gate. + +Inspection of the qualified source explains the ownership risk: `useNavSat` and +`useNavPvt` publish React state for each packet; `useUbxMessages` updates rows/rates +on arrival; `usePositionHistory` derives samples/statistics from an effect over +the latest PVT state; Map appends its trail from another effect. Console appends +formatted bytes through its native handle. A generic debounce around the current +hooks would therefore change history semantics and leave some native work unpaced. + +## Application contract + +Provide an ordinary persisted selector for 10, 20 and 60 UI updates/second, +defaulting to 20; invalid configuration falls back to the documented default. +Explain it as the display update rate. Serial baud and receiver configuration +remain independent. Reuse existing components and configuration ownership. + +Use a shared application cadence for pending telemetry presentation work. The +implementation may use bounded stores/hooks, but a second renderer, a general +state-management framework or a native scheduling policy is unnecessary. Pace +expensive snapshot preparation/publication as well as React notification where +measurement shows those costs. Do not merely delay a render after doing all the +same per-message derivations. + +| Data or action | Required semantics | +| --- | --- | +| Raw transport and decoded packets | Every valid packet reaches the real parser/subscribers; sequence and byte accounting precede presentation coalescing. | +| Latest satellite/fix/marker/accuracy/sky display | Newest eligible value at each publication; intermediate snapshots may coalesce and are counted. All views converge to the final packet after input stops. | +| Message and Console history | Preserve ordered bounded retention and truthful arrival-based counters, byte rates and offsets. UI cadence must not become the definition of a received message. | +| Position plots, map trail and statistics | Retain each eligible source sample within the declared history window, independent of React effects. Batch or replay the retained tail through existing widget operations; distinguish deliberate old-history eviction from skipped samples. Preserve fix filtering and reset behavior. | +| Rate change | Takes effect without reconnecting, duplicate timers or catch-up bursts. Pending work remains owned and is flushed under the new cadence; test both faster and slower changes. | +| Connection, errors, reset and user interaction | Remain prompt. Pacing telemetry must not postpone controls, keep stale pre-reset work, limit native interaction or hide errors. | +| Pause and trailing data | Deliver the last pending state without requiring another packet or mouse movement. Cancel pending-only pacing work when drained; real stale-time/status deadlines may continue. | +| Disconnect, reconnect and disposal | Clear or finalize pending work under an explicit policy; old connection samples cannot overwrite a new connection or cleared history. Release subscriptions/timers/buffers and reject late callbacks after public disposal. | + +Declare limits for all retained data and pending work. Start from existing caps: +500 message rows, 1,000 trail points, 3,000 position/plot samples and the bounded +Console policy. Avoid an unbounded queue behind a low UI rate. A burst may evict +the oldest data under that policy, but the retained tail must match an independent +sequence-based expectation. Counter and summary data must account for all input. +Do not replace every packet with only the last packet before history ingestion. + +## Functional acceptance + +Use deterministic clock tests for scheduling boundaries, continuous input, +trailing delivery, no-work idle, rate changes with pending work, reset, old +connection callbacks and disposal. Test a burst larger than retention capacity +and the exact retained sequence tail, including derived position results. These +tests must assert the public behavior rather than mirror the implementation. + +For an unchanged rate over elapsed time T in milliseconds, pending-only cadence tests permit at +most `1 + floor(T * rate / 1000)` telemetry flushes, including a leading flush. +Status/control publications are recorded separately. A delayed timer publishes +current work once; it must not replay a backlog of missed timer ticks. Under +continuous eligible input, the deterministic test must also prove scheduled +publication and trailing delivery, so passing only an upper bound is insufficient. + +Retain the production full-App 20-Hz-per-type, 60-second correctness scenario. +Add a 60-second 120-Hz-per-type session (7,200 packets of each type), switching UI +rates 20 → 10 → 60 → 20 through ordinary native input while delivery continues. +Confirm persisted configuration in the isolated app. Inspect populated Signals, +Messages, Map, Sky, Position and Console state/captures, with a sort or filter, +map navigation and position reset. Reuse the actual Canvas script and local tiles. + +Assert all packet sequences, snapshot eligibility/coalescing, exact retained +histories and final native state. At each rate transition and at stream end, use +bounded waits for the relevant sample in a submitted frame, not just a newer +unrelated frame. Include connected pause/stale-time advance, resumed input, +disconnect/reconnect, ten settled disconnected seconds without constructed or +submitted frames, awaited public disposal, late packets and pending resources. +Both application pacing owners and existing JS/native lifetime counts must reach +their documented baselines before ordinary native shutdown. + +## Bounded before/after measurement + +Compare application `e7de9e2` with the candidate on the same XFrames/native package +build whenever possible. If a necessary runtime fix changes that build, identify +the comparison as combined application/runtime work or add the minimal control +needed to attribute the difference. Preserve the original successful evidence. + +First characterize the baseline's retained histories against the same source +oracle. If its existing React coalescing already skips history samples, preserve +that finding; the candidate must satisfy the source-based retention contract. +Do not call those variants equivalent. For a pacing-only cost claim, use a +minimal unpaced control with the candidate's corrected ingestion/history logic +for the paired comparison, and label `e7de9e2` as the historical characterization. +This control may be diagnostic-only; an unpaced product setting is not required. + +Run three paired repetitions of the following one comparison cell: + +| Parameter | Baseline and candidate | +| --- | --- | +| Receiver source | Checksummed synthetic NAV-SAT and NAV-PVT, 120 Hz each; identical packet contents, splits/coalesced callbacks and sequence ranges. | +| Measured duration | 60 seconds; 7,200 packets of each type, plus separately counted warm-up/lifecycle packets. | +| Presentation policy | Unpaced qualified baseline (or the retention-correct control described above) versus candidate UI rate 20 Hz. | +| Visible state | Signals stays selected during measurement; all ordinary application subscriptions remain mounted. No navigation, screenshots or desktop input inside the measured interval. | +| Preparation | Identical dimensions, fonts, theme, history initialization, local resource/cache policy and declared warm-up. Wait for outstanding resource work to settle before starting. | +| Execution | Optimized Node/OpenGL, production React, same host/adapter and observation settings; sequential runs with no task-owned builds or other tests running concurrently. | + +Alternate baseline/candidate ordering across pairs and retain every run, including +interrupted or failed ones. A clean measurement should deliver at least 95% of +requested source rate while accounting for all packets; otherwise identify source +timer lag or application saturation and classify that cell as unmet. Delivery +uses monotonic scheduling independent of frame waits. Do not turn a late source +into apparent success by reporting only the requested rate. + +Report source delivery, decoding, logical presentation flushes, actual React +publications/structural calls, native snapshot replacements and history appends +separately. At high input, the candidate must demonstrate lower eligible telemetry +snapshot publication work and the declared cadence with equivalent retained data +in the comparison control. Total native frame count is not the pacing contract. +Do not claim improvement +from dropping a panel, suppressing status or ceasing history ingestion. CPU and +RSS are measured outcomes, not assumed consequences of fewer React publications. + +Record p50/p95/p99/maximum for receipt-to-publication and +receipt-to-observed-submission where supported, with sample coverage and separate +coalescing/eviction counts. Record intentional waiting time versus compute/boundary +time where instrumentation can distinguish them. Use sequence-to-publication-to- +submitted-state correlation; do not assign the latest sample's timestamp to older +samples merely because they share a frame. Report observation interval, observer +cost/limits, process CPU per wall-clock interval and comparable RSS checkpoints. +GPU completion and physical presentation remain unavailable unless directly measured. + +The [historical framework targets](fabric-baseline-2026-09.md#proposed-targets-separate-from-observations) +remain unchanged: at least 95% of requested 20/60/120-Hz input, observed-frame p95 +below 50 ms and p99 below 100 ms at 60/120 Hz, and the stated idle target under +its declared workload. This app comparison uses different histories and deliberate +UI waiting, so it must not close those framework targets. Explain the measured +freshness/cost tradeoff for 20-Hz UI pacing without relabeling batching delay as +renderer latency or claiming a universal rendered frame rate. + +The user uses the laptop during work. Announce each desktop run and its approximate +duration; existing authorization permits it without another permission question. +Verify click targets and retry misses. Arrange a quiet comparison window if the +host is in use, while proceeding with independent work. If quiet conditions cannot +be obtained, retain shared-host results as informational and explicitly leave +controlled comparison open. Never infer a quiet window from silence or elapsed time. + +## Reproduction, validation and stopping rule + +Continue in an isolated checkout from the qualified application branch, preserving +the original repository's checked-out files. `ubx-application-setup.mjs` now +supports `--base=qualified` at `e7de9e2`, an incremental `--patch` and reusable +`--packages`. Its default migration mode remains available for historical +reproduction. Do not apply the old full migration twice. Record both repo +revisions/diffs, command parameters, tarball/native identities and original-file +hashes; use new evidence directories rather than replacing completed reports. + +Run whole-app typecheck, ordinary development startup, existing serial lifecycle +and new pacing/history tests, and required production scenarios. Changes to XFrames +source require affected package/Fabric/diagnostic/native checks; a shared runtime +change requires relevant Node and Wasm coverage. Reuse native caches and earlier +valid results for unchanged code. Preserve the required 1,000-cycle gates and +optional extended mode. A changed workflow needs its affected hosted checks; +neither the old `fa12b22` hosted pass nor local checks prove new hosted coverage. + +Stop after the rate control, independent bounded history ownership, reproduction, +functional/lifetime checks and bounded measurement report are complete. Fix proven +correctness blockers; do not widen into a generic performance rewrite. Report +unmet rates, unavailable quiet-host measurements or regressions explicitly and +leave their acceptance open. A repeated cell needs a recorded interruption, code +change or specific diagnostic reason; do not repeat until a favorable result occurs. + +Stage 5 replay, Stage 6 general automation, a native frame cap, a renderer or serial +stack replacement, new widgets, registry releases, original-checkout migration, +physical hardware validation, public tile-service qualification, a full Node/Wasm +benchmark matrix, hardware WebGPU and Electron/GPUIX comparisons are outside this +slice. The broader milestone and subsequent consistently green CI remain open. + +## Delivered implementation and identities + +Connection now includes **Display updates/sec: 10 / 20 / 60**, default 20. It +stays usable while connected. Invalid or malformed persisted values fall back +to 20; the existing exit-save configuration policy persists valid choices while +preserving receiver settings. No baud or receiver command changes occur when +selecting a display rate. + +`PublicationCadence` owns one pending bit per live presentation owner and at most +one timer. Late timers publish current work once, anchored to actual execution; +an empty queue has no timer. The shared connection-status subscription clears +histories and pending publication on transitions away from connected. Controls, +reset and stale-status deadlines remain independent. The ordinary App registers +13 owners, ten eligible for the SAT/PVT workload. The diagnostic unpaced switch +must be chosen before mount and is absent from persisted product settings. + +| Owner | Source semantics and publication | +| --- | --- | +| PVT/SAT/DOP/NAV-STATUS | Bounded latest raw value; selectors and React enqueue run on publication. | +| Position | Every eligible fix enters a 3,000-sample ring. CEP/scatter and mean retained horizontal accuracy derive from that ring; the previously mislabeled latest accuracy is now an actual retained mean. Each line handle replays unseen retained sequence values, including skipped React snapshots. | +| Map | Independent 1,000-point source trail; full retained snapshot plus latest marker/accuracy overlays. No-fix clears live marker/accuracy; reconnect preserves current zoom when recentering. | +| Messages/status bar | Each existing owner retains 500 ordered messages, arrival ordinals and lazily cached immutable rows. Fixed 1,001-ms slots preserve arrival-based byte/message rates; one-second stale/rate-decay status stays separate. | +| Console | Existing formatter and offsets ingest all wire data. 65,536 UTF-16-unit cap rotates to the final 32,768 units; incomplete input is copied into bounded pending storage. Snapshot generations batch native appends and preserve exact text across skipped React updates. | +| Hardware | Existing MON-HW/RF/HW3 filtering, fields and a 3,000-sample jamming ring; source ingestion precedes one cadence owner. | + +Position Reset clears only position history. Reconnect starts fresh histories; +late old-port/publication callbacks cannot repopulate them. Rings and pending +ownership are bounded; older source data is evicted explicitly, never replaced +by the latest packet before ingestion. Public render/disposal, native scheduling, +atomic Fabric publication, map resources and the actual non-continuous Sky script +remain in use. No shared runtime/native/workflow source was changed. + +The reviewable artifact is +[`ubx-pacing.patch`](../../packages/dear-imgui/npm/diagnostics/ubx-pacing.patch), +SHA-256 `2a2dc7b044427497e4063d2a2339e373450332445d3bf46e901d70018453e8b0`, +based directly on app `e7de9e25ff3f608e8bb2613e471d0b97f47d58f2`. +XFrames base is `54bc3827c57c8d8e3a02026ce5998c752e0d1783` plus the reviewable +diagnostic/documentation changes. The working app is on isolated branch +`qualification/ui-pacing` under `packages/dear-imgui/npm/build/diagnostics/ubx-pacing/app`. +The final fresh reproduction is `build/diagnostics/ubx-pacing-reproduction-final/app`. +The app changes were subsequently committed as +`817f9316e0c14d9bdf8a17c4d8d962d31de4851f` on that isolated +`qualification/ui-pacing` branch. The original application checkout remains +unchanged; the incremental patch is the portable reproduction artifact. +The completion audit verified source equivalence after CRLF normalization: +fresh Git patch application changed line endings in 12 source files and +package.json, while package lock, fonts, native build and diagnostic source +matched byte-for-byte. All six quiet runs have identical raw source identities. + +| Reused artifact | SHA-256 | +| --- | --- | +| common 0.1.7 tarball | `ce6c9956a5894973ea3fe11f2992b21d905ffd3425ce8590ae01d1b231b7a816` | +| Node 0.1.14 tarball | `103d9089affa1817d0de424066ee24b8ae1bfaed14e5f2ae9af888c3aea12d1d` | +| Installed/current Release native | `d9347b36fa7d95455cc4a18e9cf288b911931108e46d85c5114d13fa11f56380` | +| Unchanged Sky script | `59759add2610a5ec8b2e9777657631f5ea7ae9d12d8e92f90c87c9e415279df7` | + +Node v24.14.0 / React 19.2.3 were used throughout. Setup provenance records font, +package-lock, source/diff, tarball and native identities and verifies the installed +native matches current Release. The original `C:/dev/ubx-monitor` remains on main +`571f5569bb923c3d4a8f37db8f8ada555323667a`, with only its pre-existing untracked +AGENTS.md. Its AGENTS, package.json, package-lock.json and config hashes match +the preserved original record. No original settings or dependencies were migrated. + +## Executed correctness and lifetime gates + +Fresh final reproduction applied the incremental patch to `e7de9e2` and passed +whole-App `typecheck`, `test:serial` and `test:pacing`. The latter includes +deterministic clocks at all rates, continuous/trailing delivery, live faster/slower +changes, delayed callbacks/no catch-up, owner cancellation/disposal, exact rings, +Console text, position filtering/CEP/mean, hardware jamming and rate-window decay. +The real-parser regression decodes 7,200 pairs plus reconnect, checks exact +tails/bytes/ordinals, lazy-row cache reuse and old-port/late-work rejection. +Config regressions use child process restart/exit to prove defaults, malformed +input and persisted 10 → 60 → 20 while retaining receiver fields. + +`ubx-source.test.ts` proves monotonic 20/120-Hz source delivery with simulated +16-ms timers, bounded debt batches (maximum 32 pairs/turn), no early delivery and +exact sequences. The source driver is independent of frame observations. The +diagnostic typecheck passed; unchanged framework/native gates from the prior +application qualification remain valid without another native rebuild. + +| Production gate | Input | Result | +| --- | --- | --- | +| `production-20-final` from fresh reproduction | 1,200/type in 60,010.030 ms, 19.996657 Hz/type; 1,203/type with lifecycle, 2,406 messages, 197,292 bytes | Passed all final-state, interaction, history and lifetime assertions; no input retries. | +| `production-120-transitions-03` | 7,200/type in 60,029.341 ms, 119.941347 Hz/type; 7,203/type with lifecycle, 14,406 messages, 1,181,292 bytes | Passed native 20 → 10 → 60 → 20 changes, all populated panels, reset/reconnect and lifetime checks; no input retries. | +| Six `quiet-pair-*` runs | 7,200/type each, 119.948–119.997 Hz/type | Passed equivalent exact histories, trailing delivery and public/native cleanup; [full costs and latency](ubx-monitor-pacing-cost-2026-09.md). | + +The high-input native selections occurred at 4.425 seconds (sequence 517, 10 Hz), +8.853 seconds (1,048, 60 Hz) and 14.622 seconds (1,729, 20 Hz), all while packets +continued. Config and cadence agreed, serial status/baud/write counts did not +change, and submitted state reached samples at or after each selection. Final +panel navigation finished after source delivery; its 66.595-second stream-await +wall time is not the source duration. +Its saved application patch differs from the final patch only by the subsequently +added configuration regression and its test script entry; runtime source is the +same. No performance run was repeated for these test-only additions. + +The full gates exercised Signals CNO sort, Messages, map wheel/overlays, actual +Sky script/resize, Position and Console, with populated captures inspected. +The final 20-Hz gate retained positions 0–1,200 (1,201), trail 201–1,200 (1,000), +message ordinals 1,903–2,402 (500), and exact Console text (43,119 UTF-16 units, +44,575 bytes, wire offset 196,964). Its evictions were 0/201/1,902 positions, +trail points and messages, plus 1,087,022 Console units. The high gate and paired +runs retained exact 3,000/1,000/500 source tails and Console as detailed in the +cost report. Native API-call history is checked in full against independent +oracles, while submitted summaries corroborate bounds/final values; full native +buffers are not read back. + +Position Reset followed by one source sample leaves one position/line value but +preserves the trail/message/Console histories. Reconnect leaves one position, +one trail point, two messages and exactly 941 Console units / 973 bytes at fresh +wire offset 164. Both full gates pass pause/stale-time advance, resumed final +sample, and ten settled disconnected seconds with zero constructed/submitted +frames. In the final 20-Hz run the connected pause submitted three frames and +the sustained scenario submitted 3,098; these functional shared-host counts are +not the paired measurement. + +Four local HTTP Map requests were held at unmount and released after awaited +public disposal. Ports/parser/telemetry listeners, application intervals, +presentation owners/pending/timers, JS registrations/Fabric ownership and native +resources returned to asserted baselines. Native terminal scheduler owners, +active owners and deadlines reached zero and wake delivery detached. Ordinary +development `npm start` from the final reproduction opened the native window, +remained visible for five seconds and exited 0 after normal WM_CLOSE, without +diagnostic TSX overrides or forced termination (`ubx-pacing/ordinary-startup.json`). + +## Retained attempts and evidence limits + +Evidence directories below are under the npm workspace's +`build/diagnostics/ubx-pacing`; none was overwritten or selected away: + +- `development-probe`: passed startup/populated views; a listener warning led + to the shared connection-status multiplexer, subsequently covered by final gates. +- `production-20-01`: failed the held-resource fixture after reconnect reset zoom + to 15 and reused cached tiles. Map now preserves user zoom on recenter. +- `production-20-02`: incomplete native-shutdown observation despite exit 0. + The harness had exited before the terminal callback; it now waits for that + callback. `production-20-03` passed the intermediate source. Later lazy-row + caching is covered by the final-source runs, not retroactively by this one. +- `production-120-transitions-01`: functional assertions passed but input rate + was unmet (62.366 Hz/type over 115.448 seconds). A one-pair timer met Windows + timer granularity limits; the bounded monotonic source driver fixed delivery. +- `production-120-transitions-02`: failed because interspersed panel navigation + placed the last rate selection after source completion. Consecutive selections + on Connection fixed the fixture; `-03` passed without weakening assertions. +- `qualified-characterization-01`: complete legacy lifecycle passed but the exact + altitude source tail lost 1,986 retained samples. This required the equivalent + retention-correct unpaced control, not a favorable baseline choice. +- `comparison-pair-1-unpaced`: passed shared-host run before the user confirmed + a quiet window. Retained separately from the six fresh quiet-window runs. + +The initial Console test literal had one padding space wrong; its oracle was +corrected without changing the formatter. An ad hoc Console-oracle import probe +failed on CJS/ESM syntax; the corrected probe passed exact 7,201-pair text equality. +Final whole-App evidence carries per-run source identities or saved patch/harness +copies. `ubx-pacing-reproduction-final/provenance.json` and `commands.json` +identify the final reproduction, preserved originals and headless checks. + +The bounded goal is complete. The observed-frame framework p95/p99 targets remain +open and are not met by these deliberately batched application observations. +Quiet laptop comparisons do not establish uninstrumented performance, GPU +presentation, physical serial, public tiles, hardware WebGPU, mixed DPI, other +platform backends or future CI stability. Stages 0–4 milestone review and Stage +5/6 deferral remain unchanged. See the [diagnostics reproduction guide](../../packages/dear-imgui/npm/diagnostics/README.md). diff --git a/goal.txt b/goal.txt index 6e05839..b0b6902 100644 --- a/goal.txt +++ b/goal.txt @@ -1,45 +1,49 @@ /goal -Deliver the next Stages 0-4 qualification slice: sustained ordinary ubx-monitor application use with current XFrames packages. +Deliver the next Stages 0-4 slice: configurable ubx-monitor UI update pacing with preserved telemetry histories and measured streaming cost. Primary outcome: -Make the complete desktop application reproducibly launch through its ordinary @xframes/node render entry, consume repeatable synthetic receiver bytes, and demonstrate its existing Plot, Table, Map and Canvas panels together. Verify interaction, pause/resume, disconnect/reconnect and cleanup in one runtime. Fix only demonstrated integration or lifecycle prerequisites. This is a bounded application correctness slice, not closure of the broader milestone or a controlled performance qualification. +Let the complete application publish telemetry views at a selected UI rate independently of receiver message arrival. Reduce redundant React and snapshot-widget work while accounting for every decoded packet and preserving the bounded history users see. Prove rate changes, final-sample delivery, ordinary interaction and cleanup through the real App. This is an application pacing and measurement slice, not a renderer frame-rate cap or closure of the broader performance milestone. Read first: -- docs/engineering/ubx-monitor-application-2026-09.md for the source snapshot, required scenario, acceptance matrix and evidence limits. -- ROADMAP.md, especially the next application slice and the Stages 0-4 milestone review gate. -- docs/engineering/fabric-invalidation-2026-09.md, especially isolated ubx-monitor validation, the MVP boundary and completed CI stabilization. -- packages/dear-imgui/npm/diagnostics/README.md, diagnostics/ubx-telemetry.ts and the ordinary Node render/disposal path. -- The actual ubx-monitor checkout's AGENTS.md, package manifest, entry point, App, SerialManager, hooks and affected panels before choosing migrations or a transport seam. - -Starting evidence (recorded on 11 September 2026; inspect current checkouts before work): -- Stage 4 scheduler MVP: 1de105f1d8479337e78a083b8c6645416c301c23. CI stabilization: fa12b2255fd1fed5a366c137f4003323c69475ee. Evidence documentation: 5b76c83. -- Application workflow https://github.com/xframes-project/xframes/actions/runs/34623926577 passed all five jobs on fa12b22, including both 378-test native suites, required 1,000-cycle runtime workloads, inactivity/activity gates, App smokes and 65-result Node/Wasm parity. Reuse this evidence for unchanged code; the startup and SwiftShader issues are resolved. -- The previous external-app check passed 202 NAV-SAT messages through the real parser/subscription/CNO panel, including 200 samples over about 10 seconds, idle/resume and cleanup. It used a custom diagnostic host and an isolated migrated panel import graph. It did not launch the complete ordinary App or pass its whole-project typecheck. -- C:/dev/ubx-monitor was at 571f556 with an untracked AGENTS.md. Its manifest still specifies React 18 and older published XFrames packages. SignalStrengthPanel already contains four CNO bands and a sort toggle; do not implement that feature again. The full App also contains MessageView, MapPanel and SkyViewPanel. +- docs/engineering/ubx-monitor-streaming-2026-09.md for the proposed pacing contract, bounded workload, acceptance gates and stopping rule. +- docs/engineering/ubx-monitor-application-2026-09.md for the completed application integration, reproduction artifacts and evidence limits. +- ROADMAP.md, especially Streaming Architecture, the next pacing slice and the Stages 0-4 milestone review gate. +- docs/engineering/fabric-invalidation-2026-09.md and fabric-baseline-2026-09.md for scheduler guarantees, historical measurements and the separate framework streaming targets. +- packages/dear-imgui/npm/diagnostics/README.md, ubx-application.ts, ubx-bytes.ts and ubx-application-setup.mjs; inspect their actual supported parameters before extending them. +- The external application's AGENTS.md and qualified source: SerialManager/SerialTransport, telemetry hooks, usePositionHistory, ConsoleView, MessageView, MapPanel, PositionTrackingPanel, SkyViewPanel, Signals and configuration. + +Starting evidence (recorded on 11 September 2026; verify current repositories before work): +- XFrames qualification commit: 54bc3827c57c8d8e3a02026ce5998c752e0d1783. Companion ubx-monitor commit: e7de9e25ff3f608e8bb2613e471d0b97f47d58f2 on qualification/current-xframes-application, also available in C:/dev/ubx-monitor. That repository's checked-out main remains at 571f556 with an untracked AGENTS.md and unchanged local settings/dependencies. +- The qualified app already uses React 19.2.3 and local common 0.1.7 / Node 0.1.14 packages. Whole-app typecheck, ordinary development startup and a complete production Windows Node/OpenGL scenario passed. Do not redo the compatibility migration or implement its existing CNO bands/sort control again. +- The scenario decoded 1,200 NAV-SAT and 1,200 NAV-PVT scheduled packets over 60.016 seconds at 19.995 Hz per type, plus lifecycle samples. All four populated panels, sort/map zoom, pause/resume, reconnect, ten disconnected seconds without frames and public disposal/late-work/native shutdown passed. +- Its 3,498 sustained submitted frames and receipt-to-observed-submission timings are shared-host observations, not a controlled performance baseline. NAV-SAT p95/p99 were 53.652/67.507 ms; NAV-PVT 50.295/65.926 ms, with explicit incomplete observation/coalescing coverage. +- Current hooks publish per decoded message; Map and position histories also depend on React effects. Delaying those snapshots alone can skip intermediate samples. Console formatting, message accounting and derived position statistics must be inspected separately from latest-value display state. +- Changed-source checks passed 380 Windows native tests, common/Fabric/ImGui ABI checks, 21 lifecycle scenarios in each React mode, rebuilt Node/Wasm short suites and 65-result parity. Hosted run 34623926577 on fa12b22 covers earlier unchanged code; no hosted pass on 54bc382 is claimed. Preserve existing 1,000-cycle gates and optional extended mode. Bounded work: -1. Prepare a reproducible application integration. - Preserve the original external checkout, its untracked instructions, settings and dependency graph. Use an isolated checkout with locally packed current-source common/Node packages and matching React/types. Resolve the complete application's actual type/API/configuration mismatches, then pass its whole-project typecheck and ordinary startup. Retain a reviewable, reproducible setup and application patch; do not leave required migrations only in ignored local files. Record both repositories' revisions, dirty diffs and package/build provenance. +1. Continue from the qualified application reproducibly. + Work in an isolated checkout/branch derived from e7de9e2, preserving the original main checkout, AGENTS, settings and dependency graph. Reuse current-source packed packages and valid native caches. The existing setup targets 571f556 plus a complete migration patch: do not apply that patch again over e7de9e2. Retain a clearly based incremental or composed patch/branch and a setup path that reproduces the final application, with both repository revisions/diffs and package/build identities. -2. Exercise the ordinary complete App. - Extend or accompany the existing telemetry harness using the public Node render entry and its returned disposer. Keep the actual App, event callbacks, SerialManager connection/parser/subscription path and widget operations. Substitute only the physical serial transport with a bounded deterministic source; do not inject decoded hook state or use a second custom Fabric host as full-app evidence. Supply checksummed NAV-SAT and NAV-PVT bytes, including chunk splits and coalesced packets, and exercise connect/disconnect/reconnect without opening a physical receiver. +2. Define and implement application-owned publication pacing. + Add a validated, persisted UI update setting with 10, 20 and 60 updates/second and a default of 20. Change it through an ordinary existing UI control while connected. The setting must not change serial baud/message configuration, throttle parser delivery, cap native input/resource/animation frames or require a new widget. Use a bounded shared application cadence for pending telemetry presentation work, with no catch-up bursts, duplicate timers or recurring pacing work when no telemetry is pending. Connection/error/reset actions remain prompt and stale-time/status semantics remain truthful. -3. Run the bounded application scenario in the linked plan. - Use one production Node/OpenGL session with at least 60 seconds of scheduled telemetry at a requested 20 Hz for each of NAV-SAT and NAV-PVT (1,200 messages of each type). Drive input independently of frame observation; report actual delivery rate and explicit coalescing. Visit and verify populated Signals, Messages, Map and Sky View panels through ordinary input, including a sort/filter action and a map navigation action. Use deterministic local map resources and the actual Canvas script. Pause/resume, disconnect/reconnect, then unmount and deliver late input/resource work. Assert bounded histories, listeners, scheduler/resource owners and native/JS lifetime cleanup, with a 10-second disconnected inactivity observation after genuine work settles. +3. Preserve data ownership and retained histories. + Record every valid source packet and byte count through the real transport/parser/subscription path. Latest-value views may coalesce; counters, retained message/Console history, map trail, position samples and derived statistics must retain their documented source-sample semantics independently of React render frequency. Accumulate into bounded application-owned state and publish snapshots/batches through existing widget APIs. Declare eviction/coalescing counts and limits; do not silently discard intermediate retained samples, reuse stale connection data, or create an unbounded backlog. Prove the exact retained tail and final sample, not just maximum sizes. -4. Repair demonstrated blockers at their owner. - Preserve visible stale-time/status behavior while avoiding redundant unchanged publications. A data-driven sky canvas may explicitly use the existing non-continuous mode if its real script has no animation; verify data, resize and resource wakes. Do not force zero frames while visible time-dependent state or genuine resource/activity work is changing. Keep fixes focused on the application, package integration or a proven runtime defect, with a targeted regression check where justified. +4. Exercise correctness and the bounded comparison in the linked plan. + Add meaningful deterministic clock/lifecycle tests for cadence, trailing flush, live rate changes, history retention, reset, disconnect/reconnect, disposal and late callbacks. Extend the existing ordinary-App harness with explicit receiver/UI rates and sample accounting. Retain the existing 60-second 20-Hz full-App gate; add a high-input session changing UI rates through native input and checking all affected panels, including Position and Console. Compare unpaced presentation against 20-Hz UI pacing using three paired 60-second repetitions at 120 Hz per packet type, a fixed populated view and matched host/resource/observer conditions. Start with the qualified baseline; if it already skips history samples, retain that finding and use the minimal retention-correct unpaced control described in the plan for an equivalent pacing comparison. Delivery must remain independent of frame observation. Do not expand this into a full cross-platform rate/benchmark matrix without a demonstrated need. -5. Record and validate the result. - Capture semantic state, populated screenshots, exact commands, messages/updates/frames, unavailable metrics, cleanup counts and failures in the application engineering record. Run the full app typecheck, ordinary development startup and production scenario. Run affected XFrames package/Fabric/diagnostic/native checks for any source changes, rebuilding affected targets when needed. A shared runtime change requires relevant Node/Wasm regression coverage; a workflow change requires its affected hosted gates. Preserve the existing 1,000-cycle gates and optional extended mode. Update roadmap status only to the extent the new evidence supports it. +5. Measure, validate and hand off honestly. + Report decoded input/rate, eligible and coalesced snapshots, logical UI flushes, React publications, native snapshot replacements versus history appends, submitted frames, CPU/RSS and stage/receipt-to-observed-submission quantiles with coverage. Separate deliberate batching delay, observer overhead and presentation-unavailable metrics. Preserve the historical framework targets; a lower UI rate is not evidence those targets are met. Run full app typecheck, ordinary development startup, serial/pacing regressions and production interaction/cleanup. Rebuild and test only affected XFrames targets; shared runtime changes require relevant Node/Wasm coverage, workflow changes their affected hosted gates. Update the engineering record, reproduction guide and roadmap only for executed results. -Scope and stopping rule: -Complete one reproducible full-app scenario on the available Windows Node/OpenGL environment; another desktop platform is useful only if needed to reproduce a failure. Record platform/backend limits. Synthetic transport and local map resources establish application behavior, not physical serial, network-service or hardware WebGPU qualification. Timings are informational on a shared host: distinguish receipt-to-observed-submission latency from presentation and report sample/coalescing coverage. Requested input rate is not a rendered-frame-rate promise. +Desktop and measurement coordination: +The user actively uses this laptop. Announce every run that moves the mouse or takes focus, including its approximate duration. Existing authorization allows desktop tests without asking again; verify target state and retry missed clicks. Prefer headless checks and avoid desktop helpers during measured intervals. Arrange a quiet measurement window if needed while continuing independent implementation/testing; elapsed time does not imply the laptop is idle. Label interrupted/contended runs and retain them. Do not call shared-host measurements controlled qualification or select only favorable reruns. -Reuse valid build caches and prior CI evidence. Do not repeat full native/Wasm builds or benchmark matrices without a relevant change or unresolved failure. Stop when the integration, required scenario, focused fixes and evidence are complete; if a required gate is blocked, report it explicitly without claiming this slice passed. Do not expand the task to remove every historical limitation. +Scope and stopping rule: +Complete the configurable pacing feature, independent bounded history accounting, reproducible source/setup, required functional/lifetime checks and the bounded before/after report. Fix demonstrated blockers at their owner. An unavailable quiet run or unmet performance objective must be reported as open; it must not erase completed functional evidence or trigger endless benchmark repetition. Repeat a failed measurement cell only for a recorded interruption, an implementation change or a specific unresolved diagnosis. Do not claim full acceptance while a required correctness gate is blocked. -Keep the existing Fabric publication/ownership contract, scheduler semantics and resource/lifetime guarantees. No registry release, original external-checkout migration, new widget, general automation API, serial-stack redesign, renderer replacement, permanent polling fallback, fake invalidations or weakened assertions. Configurable UI update rates and controlled streaming performance, broader Map/Canvas benchmarks, physical serial, hardware WebGPU, mixed-DPI/presentation evidence and consistently green CI over subsequent changes remain follow-ups. Stage 5 replay and Stage 6 general automation stay deferred until the milestone review. +Keep public render/disposal, atomic Fabric publication, scheduler wake/ownership semantics, ordinary event dispatch, local map resources and the actual non-continuous sky script. No physical serial redesign, raw data loss, new native frame cap, permanent polling fallback, general automation API, registry release, original-checkout migration, renderer replacement or blanket hidden-panel disabling. Broader framework performance qualification, hardware WebGPU, physical serial, public tile services, mixed DPI/presentation, Electron/GPUIX comparisons and consistently green CI over subsequent changes remain separate. Stage 5 replay and Stage 6 general automation stay deferred until milestone review. Handoff: -Summarize the concrete application integration, source/package identities, commands and executed gates, bounded telemetry/interaction/cleanup evidence, any narrow fixes and remaining milestone gaps. Distinguish completed checks from planned work and attach a reproducible application patch rather than relying on the validation checkout alone. +Summarize the user-facing rate setting, telemetry/history ownership, source/package identities and reproducible commands. Attach exact input/retention/publication/frame accounting, before/after costs and latency coverage, executed lifecycle/interaction checks, preserved original files and any blocked or unmet measurement gates. Distinguish planned work from delivered functionality and leave broader milestone criteria open. diff --git a/packages/dear-imgui/npm/diagnostics/README.md b/packages/dear-imgui/npm/diagnostics/README.md index 7b1b042..75fc162 100644 --- a/packages/dear-imgui/npm/diagnostics/README.md +++ b/packages/dear-imgui/npm/diagnostics/README.md @@ -385,6 +385,108 @@ milestone limits. For ordinary development startup, run `npm start` from the prepared application directory with `NODE_ENV=development` and no diagnostic `TSX_TSCONFIG_PATH` override; close the native window normally. +### Application pacing and paired costs + +The completed [streaming slice](../../../../docs/engineering/ubx-monitor-streaming-2026-09.md) +implements persisted 10/20/60 display rates, exact source-owned histories and +production interaction/lifetime checks. [All three quiet-window cost pairs](../../../../docs/engineering/ubx-monitor-pacing-cost-2026-09.md) +report fewer snapshots and lower measured CPU with higher observed latency; +broader framework performance targets remain open. The preceding qualified app is +committed as `e7de9e2` on `qualification/current-xframes-application`. Setup now +supports `--base=qualified`, checking out that exact revision without reapplying +the migration, and an optional `--patch=absolute/path/to/ubx-pacing.patch` for +the incremental candidate. `--packages=directory` reuses the two existing local +tarballs, recording their hashes and verifying the installed native binary matches +the current Release target; omit it to build/pack the JavaScript packages. +The default `--base=migration` preserves the old reproduction command. +Keep historical acceptance artifacts separate from new measurements. Announce desktop runs, +retry verified input misses, and arrange quiet measurement periods as needed. + +```powershell +node diagnostics/ubx-application-setup.mjs --base=qualified --source=C:/dev/ubx-monitor --output=C:/path/to/fresh/pacing-reproduction --packages=C:/dev/xframes/packages/dear-imgui/npm/build/diagnostics/ubx-application/packages --patch=C:/dev/xframes/packages/dear-imgui/npm/diagnostics/ubx-pacing.patch +``` + +Setup executes `typecheck`, `test:serial` and, when present, `test:pacing` (including +real-parser retention and restart/persistence regressions). Final reproduction +`build/diagnostics/ubx-pacing-reproduction-final` passed these checks and ordinary +development `npm start`. The incremental patch SHA-256 is +`2a2dc7b044427497e4063d2a2339e373450332445d3bf46e901d70018453e8b0`. +It applies to `e7de9e2`, not the original main; setup refuses an existing app +output and records original-file preservation and package/native identities. + +From the npm workspace, set the prepared app and choose a **new evidence +directory for each run**. Announce every desktop run: full functional scenarios +use mouse/focus for about three minutes; fixed-view comparisons take about +90 seconds each, with input only before/after the measured interval. + +```powershell +$env:NODE_ENV='production' +$env:TSX_TSCONFIG_PATH='diagnostics/tsconfig.json' +$env:XFRAMES_UBX_APP_DIR='C:/path/to/fresh/pacing-reproduction/app' +$env:XFRAMES_DIAGNOSTICS_DIR='C:/path/to/new/20hz-evidence' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario --receiver-hz=20 --ui-hz=20 --check-histories + +$env:XFRAMES_DIAGNOSTICS_DIR='C:/path/to/new/rate-transition-evidence' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario --receiver-hz=120 --ui-hz=20 --rate-changes --check-histories +``` + +The rate-transition mode selects 20 → 10 → 60 → 20 through ordinary connected +UI input while receiver delivery continues. It verifies config/cadence agreement, +unchanged serial settings/writes and submitted source samples. Both full modes +exercise populated panels, sort, map zoom, Sky resize, Position Reset, Console, +pause/resume, reconnect, ten disconnected seconds without frames, held resources, +public disposal and verified native terminal shutdown. `--receiver-hz=20|120` +and `--ui-hz=10|20|60` default to 20. `--check-histories` enables exact ordered +widget API-call tails plus native submitted bounds/final-state checks. It is not +a complete native-buffer readback. Source packets use a monotonic clock and +bounded debt batches independent of frame waits; unmet delivery rates are +explicitly reported even when functional assertions pass. + +For the bounded comparison, first prepare a second `--base=qualified` app **without +the pacing patch** and run `--comparison=qualified` at 120/20. Its retained +history characterization established source-tail loss, so use the patched app's +diagnostic-only `--comparison=unpaced` control for equivalent paired costs: + +```powershell +$env:XFRAMES_DIAGNOSTICS_DIR='C:/path/to/new/unpaced-evidence' +# Describe actual host conditions; claim quiet only after user confirmation. +$env:XFRAMES_UBX_HOST_CONDITIONS='shared-host; quiet window not confirmed' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario --receiver-hz=120 --ui-hz=20 --comparison=unpaced + +$env:XFRAMES_DIAGNOSTICS_DIR='C:/path/to/new/paced-evidence' +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-application.ts --scenario --receiver-hz=120 --ui-hz=20 --comparison=paced +``` + +Run three pairs sequentially in U/P, P/U, U/P order, using identical host/resource/ +observer conditions. Signals stays visible and all App owners remain mounted; +no screenshots, desktop helpers, builds or other tests run during measurement. +Comparison mode performs a shorter public/native teardown after measuring; +the full functional gates separately cover reset/reconnect and late resources. +Do not combine comparison mode with rate transitions. Unpaced mode is selected +before mount and never persisted as a product setting. + +Each run records source/build/observer hashes and diffs, source delivery, receipts, +publication preparation/React enqueue, observed submitted samples, CPU/RSS, +native API counts, exact retained histories, final screenshot and terminal +ownership. CPU/native counters stop at source completion; trailing delivery has +separate endpoints. Stage/observation coverage and instrumentation costs are +explicit, and GPU completion/presentation is unavailable. + +The executed six-run dataset lives at +`build/diagnostics/ubx-pacing/quiet-pair-{1,2,3}-{unpaced,paced}`. After all desktop +runs stop, recompute its checked summary with: + +```powershell +node diagnostics/ubx-comparison-report.mjs +# Optional first argument overrides the evidence root, preserving that naming. +node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-source.test.ts +``` + +The offline report validates matched identities, all packets/rates, exact retention +and trailing samples, then writes `quiet-comparison-summary.json`. It does not +launch or repeat measurements. Retain failed/interrupted runs and diagnostic +reasons for any repeat; the six quiet-window runs did not require repeats. + ### Legacy panel-only command Read the external checkout's `AGENTS.md`; clone it into an ignored validation diff --git a/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs b/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs index 61a6418..8f5e3c7 100644 --- a/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs +++ b/packages/dear-imgui/npm/diagnostics/ubx-application-setup.mjs @@ -8,13 +8,18 @@ const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, ".."); const repository = resolve(root, "../../.."); const args = Object.fromEntries(process.argv.slice(2).map(arg => { - const match = /^--(source|output)=(.+)$/.exec(arg); + const match = /^--(source|output|base|packages|patch)=(.+)$/.exec(arg); if (!match) throw new Error(`Unknown argument: ${arg}`); return [match[1], match[2]]; })); const source = resolve(args.source ?? "C:/dev/ubx-monitor"); const output = resolve(args.output ?? resolve(root, "build/diagnostics/ubx-application-reproduction")); const app = resolve(output, "app"); +const base = args.base ?? "migration"; +if (!["migration", "qualified"].includes(base)) throw new Error("base must be migration or qualified"); +if (args.patch && base !== "qualified") throw new Error("An incremental patch requires --base=qualified"); +const baseRevision = base === "qualified" ? "e7de9e25ff3f608e8bb2613e471d0b97f47d58f2" + : "571f5569bb923c3d4a8f37db8f8ada555323667a"; if (existsSync(app)) throw new Error(`Refusing to replace an existing checkout: ${app}`); if (output === source || output.startsWith(source + "/") || output.startsWith(source + "\\")) throw new Error("The validation output must be outside the original application"); @@ -39,7 +44,7 @@ const preservedFiles = ["AGENTS.md", "package.json", "package-lock.json", "confi const original = Object.fromEntries(preservedFiles.filter(file => existsSync(resolve(source, file))) .map(file => [file, hash(resolve(source, file))])); const sourceRevision = git(["rev-parse", "HEAD"], source, "original-revision").trim(); -if (sourceRevision !== "571f5569bb923c3d4a8f37db8f8ada555323667a") +if (base === "migration" && sourceRevision !== baseRevision) throw new Error(`Application patch targets 571f556; inspect changed source ${sourceRevision} before migrating`); const originalStatus = git(["status", "--porcelain=v1"], source, "original-status"); git(["diff", "--binary", "HEAD"], source, "original-diff"); @@ -53,18 +58,30 @@ const xframesUntracked = Object.fromEntries(untracked.map(file => [file, hash(re // substitute a registry binary or refresh native builds just for a timestamp. const native = resolve(root, "node/build/Release/xframes.node"); if (!existsSync(native)) throw new Error("Build the current Release Node target before setup"); +let packed; +if (args.packages) { + packed = ["xframes-common-0.1.7.tgz", "xframes-node-0.1.14.tgz"].map(filename => { + const from = resolve(args.packages, filename); + cpSync(from, resolve(packages, filename)); + return { filename, reusedFrom: from, sha256: hash(from) }; + }); +} else { npm(["run", "build:common"], root, "build-common"); npm(["run", "build:node"], root, "build-node-package"); -const packed = JSON.parse(npm(["pack", "--workspace", "@xframes/common", "--workspace", "@xframes/node", +packed = JSON.parse(npm(["pack", "--workspace", "@xframes/common", "--workspace", "@xframes/node", "--pack-destination", packages, "--json"], root, "pack")); +} git(["clone", "--no-hardlinks", source, app], repository, "clone"); +git(["checkout", "-b", base === "qualified" ? "qualification/ui-pacing" : "qualification/application", baseRevision], app, "checkout-base"); if (existsSync(resolve(source, "AGENTS.md"))) cpSync(resolve(source, "AGENTS.md"), resolve(app, "AGENTS.md")); -const patch = resolve(here, "ubx-application.patch"); -const patchSha256 = hash(patch); +const patch = base === "migration" ? resolve(here, "ubx-application.patch") : args.patch ? resolve(args.patch) : null; +const patchSha256 = patch ? hash(patch) : null; +if (patch) { git(["apply", "--check", patch], app, "patch-check"); git(["apply", patch], app, "patch-apply"); // Include newly added application sources in the retained dirty diff as well. -git(["add", "--intent-to-add", "src/connection/SerialTransport.ts", "tests/serial-lifecycle.ts"], app, "patch-new-files"); +git(["add", "--intent-to-add", "src", "tests", "package.json", "package-lock.json"], app, "patch-new-files"); +} // Keep the committed lock's dependency selections; refresh only local tarball // integrity because a rebuilt native artifact may have a different build identity. npm(["install", "--package-lock-only", "--ignore-scripts", "--save-exact", @@ -72,6 +89,10 @@ npm(["install", "--package-lock-only", "--ignore-scripts", "--save-exact", npm(["ci"], app, "install"); npm(["run", "typecheck"], app, "typecheck"); npm(["run", "test:serial"], app, "serial-lifecycle"); +if (JSON.parse(readFileSync(resolve(app, "package.json"), "utf8")).scripts["test:pacing"]) + npm(["run", "test:pacing"], app, "pacing"); +if (hash(resolve(app, "node_modules/@xframes/node/dist/xframes.node")) !== hash(native)) + throw new Error("Installed package native binary does not match current Release target"); git(["diff", "--binary", "HEAD"], app, "application-diff"); for (const [file, before] of Object.entries(original)) { if (hash(resolve(source, file)) !== before) throw new Error(`Original ${file} changed during setup`); @@ -81,8 +102,8 @@ if (git(["status", "--porcelain=v1"], source, "original-status-after") !== origi const identities = {}; for (const file of ["assets/fonts/roboto-regular.ttf", "assets/fonts/roboto-mono.ttf", "src/scripts/sky-view.js", "package-lock.json", "node_modules/@xframes/node/dist/xframes.node"]) identities[file] = hash(resolve(app, file)); -writeFileSync(resolve(output, "provenance.json"), JSON.stringify({ source, sourceRevision, original, +writeFileSync(resolve(output, "provenance.json"), JSON.stringify({ source, sourceRevision, baseRevision, base, original, xframesRevision, xframesUntracked, patchSha256, nativeSha256: hash(native), packages: packed, identities, node: process.version, nativeBuildRequirement: "Current-source Release build; retain build logs alongside this record" }, null, 2)); -if (hash(patch) !== patchSha256) throw new Error("Application patch changed during setup; repeat in a fresh output directory"); +if (patch && hash(patch) !== patchSha256) throw new Error("Application patch changed during setup; repeat in a fresh output directory"); console.log(`Prepared ${app}; typecheck and serial lifecycle passed. Desktop execution is a separate explicit run.`); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-application.ts b/packages/dear-imgui/npm/diagnostics/ubx-application.ts index 273b801..73de242 100644 --- a/packages/dear-imgui/npm/diagnostics/ubx-application.ts +++ b/packages/dear-imgui/npm/diagnostics/ubx-application.ts @@ -1,7 +1,8 @@ import { createRequire } from "node:module"; +import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { EventEmitter } from "node:events"; @@ -9,6 +10,28 @@ import { cpus, platform, release } from "node:os"; import { check, waitFor } from "./assertions"; import { observeNativeFrame, readDiagnostics, counterDelta, waitForNativeIdle } from "./frames"; import { navSatPacket, navPvtPacket } from "./ubx-bytes"; +import { deliverScheduledUbx } from "./ubx-source"; +import { UbxHistoryLedger, UbxConsoleOracle, verifyUbxHistories } from "./ubx-history"; + +const argumentsByName = new Map(); +for (const argument of process.argv.slice(2)) { + if (["--scenario", "--rate-changes", "--check-histories"].includes(argument)) continue; + const match = /^--(receiver-hz|ui-hz|comparison)=(.+)$/.exec(argument); + check(match, `Unknown argument: ${argument}`); + check(!argumentsByName.has(match[1]), `Duplicate argument: ${match[1]}`); + argumentsByName.set(match[1], match[2]); +} +const receiverHz = Number(argumentsByName.get("receiver-hz") ?? 20); +const uiHz = Number(argumentsByName.get("ui-hz") ?? 20); +check([20, 120].includes(receiverHz), "receiver-hz must be 20 or 120 for this bounded fixture"); +check([10, 20, 60].includes(uiHz), "ui-hz must be 10, 20 or 60"); +const packetCount = receiverHz * 60; +const rateChanges = process.argv.includes("--rate-changes"); +const comparison = argumentsByName.get("comparison"); +check(!comparison || ["paced", "unpaced", "qualified"].includes(comparison), "comparison must be paced, unpaced or qualified"); +check(!comparison || (receiverHz === 120 && uiHz === 20 && !rateChanges), "comparison requires receiver 120, UI 20 and no rate changes"); +const checkHistories = process.argv.includes("--check-histories") || rateChanges || comparison === "paced" || comparison === "unpaced"; +check(!rateChanges || (receiverHz === 120 && uiHz === 20), "rate-changes requires receiver 120 Hz and initial UI 20 Hz"); const execute = promisify(execFile); const here = __dirname; @@ -18,11 +41,34 @@ const save = (name: string, data: unknown) => writeFileSync(resolve(output, `${n const delay = (ms: number) => new Promise(done => setTimeout(done, ms)); let native: any; let finalReport: any; +let nativeShutdownVerified = false; async function main() { check(process.env.XFRAMES_UBX_APP_DIR, "Set XFRAMES_UBX_APP_DIR to the isolated application"); const app = resolve(process.env.XFRAMES_UBX_APP_DIR); const requireApp = createRequire(resolve(app, "package.json")); + const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const sourceHashes: Record = {}; + const collect = (directory: string, relative = "src") => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const key = `${relative}/${entry.name}`, path = resolve(directory, entry.name); + if (entry.isDirectory()) collect(path, key); else sourceHashes[key] = hash(path); + } + }; + collect(resolve(app, "src")); + const git = async (args: string[], cwd: string) => (await execute("git", args, + { cwd, windowsHide: true, maxBuffer: 32 * 1024 * 1024 })).stdout; + writeFileSync(resolve(output, "application.diff"), await git(["diff", "--binary", "HEAD"], app)); + writeFileSync(resolve(output, "xframes.diff"), await git(["diff", "--binary", "HEAD"], resolve(here, "../../../.."))); + save("source-identity", { appRevision: (await git(["rev-parse", "HEAD"], app)).trim(), + xframesRevision: (await git(["rev-parse", "HEAD"], resolve(here, "../../../.."))).trim(), sourceHashes, + files: Object.fromEntries(["package.json", "package-lock.json", "assets/fonts/roboto-regular.ttf", "assets/fonts/roboto-mono.ttf"] + .map(file => [file, hash(resolve(app, file))])), + nativeSha256: hash(requireApp.resolve("@xframes/node/dist/xframes.node")), + commonEntrySha256: hash(requireApp.resolve("@xframes/common")), + diagnosticHashes: Object.fromEntries(["ubx-application.ts", "ubx-source.ts", "ubx-source.test.ts", "ubx-history.ts", + "ubx-bytes.ts", "ubx-rate-select.ps1", "native-window.ps1", "frames.ts", "resource-server.mjs"] + .map(file => [file, hash(resolve(here, file))])) }); const { startResourceServer } = await import("./resource-server.mjs"); const resources = await startResourceServer(resolve(here, ".."), output); process.env.UBX_MONITOR_TILE_URL = `${resources.baseUrl}/asset?group=app&hold=1&z={z}&x={x}&y={y}`; @@ -31,7 +77,7 @@ async function main() { process.chdir(app); // This is an isolated checkout: each run starts with deterministic UI choices // and never imports the original receiver's machine-local configuration. - writeFileSync(resolve(app, "config.json"), "{}\n"); + writeFileSync(resolve(app, "config.json"), JSON.stringify({ uiUpdateRate: uiHz }) + "\n"); native = requireApp("@xframes/node/dist/xframes.node"); const common = requireApp("@xframes/common"); const manager = common.ReactNativePrivateInterface.nativeFabricUIManager; @@ -52,35 +98,59 @@ async function main() { check(terminal.resourceState.textures.liveTextures === 0 && terminal.resourceState.textures.retiredTextures === 0, "Native shutdown retained textures"); save("result", { ...finalReport, status: "passed", terminal }); + nativeShutdownVerified = true; console.log("Ordinary ubx-monitor application: passed"); } catch (error) { save("failure", { error: String(error), native: readDiagnostics(native) }); process.exit(1); } } options.onBeforeExit(); } }); + const observerCosts = { stageMs: 0, widgetMs: 0, commitNativeMs: 0, internalNativeMs: 0, + consoleNativeMs: 0, sourcePreparationMs: 0, pollMs: 0, polls: 0 }; const nodes = new Map(); + const ledger = new UbxHistoryLedger(); + const consoleOracle = new UbxConsoleOracle(); const operations: Record = {}; const latest = new Map(); const highWater: Record = {}; const inputRetries: string[] = []; const apply = native.applyCommit; native.applyCommit = (wire: string) => { + const nativeStart = performance.now(); const result = apply(wire); + observerCosts.commitNativeMs += performance.now() - nativeStart; + const observerStart = performance.now(); const ack = JSON.parse(result); check(ack.status === "applied", `Application publication failed: ${result}`); for (const op of JSON.parse(wire).operations) { if (op.op === "create") nodes.set(op.id, { type: op.elementType, ...op.props }); if (op.op === "patch") Object.assign(nodes.get(op.id), op.props); } - for (const id of ack.destroyedIds) { nodes.delete(id); latest.delete(id); } + for (const id of ack.destroyedIds) { nodes.delete(id); latest.delete(id); ledger.destroy(id); } + observerCosts.widgetMs += performance.now() - observerStart; return result; }; const internal = native.elementInternalOp; native.elementInternalOp = (id: number, wire: string) => { + const nativeStart = performance.now(); const result = internal(id, wire); + observerCosts.internalNativeMs += performance.now() - nativeStart; + const observerStart = performance.now(); const op = JSON.parse(wire); const key = `${nodes.get(id)?.type}:${op.op}`; operations[key] = (operations[key] ?? 0) + 1; latest.set(id, { ...latest.get(id), [op.op]: op }); + ledger.operation(id, nodes.get(id)?.type, op); + observerCosts.widgetMs += performance.now() - observerStart; + return result; + }; + const appendConsole = native.appendTextToClippedMultiLineTextRenderer; + native.appendTextToClippedMultiLineTextRenderer = (id: number, text: string) => { + const nativeStart = performance.now(); + const result = appendConsole(id, text); + observerCosts.consoleNativeMs += performance.now() - nativeStart; + const observerStart = performance.now(); + ledger.appendConsole(id, text); + observerCosts.widgetMs += performance.now() - observerStart; return result; }; const { serialManager } = await import(pathToFileURL(resolve(app, "src/connection/SerialManager.ts")).href); @@ -113,6 +183,23 @@ async function main() { "-ProcessId", String(process.pid), "-Action", action, "-X", String(x), "-Y", String(y), ...(value === undefined ? [] : ["-Value", value])], { windowsHide: true, timeout: 10_000 }); }; + const presentationPath = resolve(app, "src/telemetry/presentation.ts"); + const presentation = existsSync(presentationPath) + ? (await import(pathToFileURL(presentationPath).href)).presentation : null; + if (comparison === "unpaced") { + check(presentation, "Unpaced control requires the retention-correct application"); + presentation.setUnpacedForComparison(true); + } + if (comparison === "paced") check(presentation, "Paced comparison requires the candidate application"); + if (comparison === "qualified") check(!presentation, "Qualified characterization must use the e7de9e2 application"); + const publicationStages: any[] = []; + let droppedStageObservations = 0; + if (presentation) presentation.observe((event: any) => { + const observedAt = performance.now(); + if (publicationStages.length < 250000) publicationStages.push(event); + else droppedStageObservations++; + observerCosts.stageMs += performance.now() - observedAt; + }); const entry = await import(pathToFileURL(resolve(app, "src/index.tsx")).href); await waitFor(() => manager.getDiagnostics(), (d: any) => d.appliedPublications > 0, "ordinary App publication"); native.setDiagnosticsEnabled(true); @@ -162,17 +249,19 @@ async function main() { let messageCount = 0, rawCallbacks = 0, rawBytes = 0, splitPairs = 0, coalescedPairs = 0; const decoded = (kind: "sat" | "pvt") => (msg: any) => { const sequence = msg.iTOW / 50; - check(Number.isInteger(sequence) && !receipts[kind].has(sequence), `Duplicate/invalid ${kind} sequence ${sequence}`); + check(Number.isInteger(sequence) && sequence === receipts[kind].size && !receipts[kind].has(sequence), `Duplicate/invalid ${kind} sequence ${sequence}`); receipts[kind].set(sequence, performance.now()); }; const onSat = decoded("sat"), onPvt = decoded("pvt"); const onMessage = () => ++messageCount; const onRaw = (chunk: Buffer) => { rawCallbacks++; rawBytes += chunk.length; }; - serialManager.on("NAV-SAT", onSat); serialManager.on("NAV-PVT", onPvt); - serialManager.on("message", onMessage); serialManager.on("rawdata", onRaw); + serialManager.prependListener("NAV-SAT", onSat); serialManager.prependListener("NAV-PVT", onPvt); + serialManager.prependListener("message", onMessage); serialManager.prependListener("rawdata", onRaw); const listenerCounts = () => Object.fromEntries(serialManager.eventNames().map((event: string) => [event, serialManager.listenerCount(event)])); const subscriptions = listenerCounts(); const parsers: any[] = []; + const rateTransitions: any[] = []; + const retentionChecks: any[] = []; const connect = async () => { await visit("Connection", 46); await clickUntil("Connect callback", 44, 140, () => serialManager.getStatus() === "connected"); @@ -182,7 +271,10 @@ async function main() { const send = (sequence: number) => { const port = ports.at(-1)!; check(port.isOpen, "Telemetry source lost its connection"); + const preparedAt = performance.now(); const sat = navSatPacket(sequence), pvt = navPvtPacket(sequence); + consoleOracle.packet(sat, "NAV-SAT"); consoleOracle.packet(pvt, "NAV-PVT"); + observerCosts.sourcePreparationMs += performance.now() - preparedAt; if (sequence % 2) { splitPairs++; port.emit("data", sat.subarray(0, 9)); @@ -191,6 +283,7 @@ async function main() { }; let observationError: unknown; const inspect = () => { + const observedAt = performance.now(); try { const frame = snapshot(); for (const node of frame.elements ?? []) { @@ -223,24 +316,90 @@ async function main() { } highWater.parserBytes = Math.max(highWater.parserBytes ?? 0, serialManager.parser?._buffer.length ?? 0); } catch (error) { observationError = error; } + finally { observerCosts.pollMs += performance.now() - observedAt; observerCosts.polls++; } + }; + const setRateThroughInput = async (rate: number, streamStart: number) => { + check(presentation, "Rate changes require the pacing application"); + const connectionId = [...nodes].find(([, props]) => props.type === "tab-item" && props.label === "Connection")?.[0]; + if (!snapshot().elements?.some(node => node.id === connectionId && node.bounds[2] > 0)) + await visit("Connection", 46); + const baud = serialManager.getBaudRate(); + const writes = ports.at(-1)!.writes.length; + for (let attempt = 1; attempt <= 3; attempt++) { + await execute("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + resolve(here, "ubx-rate-select.ps1"), "-ProcessId", String(process.pid), "-Rate", String(rate)], + { windowsHide: true, timeout: 10000 }); + try { + await waitFor(() => presentation.getDiagnostics().rate, value => value === rate, `native UI rate ${rate}`, 2000); + break; + } catch (error) { + inputRetries.push(`UI rate ${rate}:${attempt}`); + await capture(`rate-${rate}-miss-${attempt}`); + if (attempt === 3) throw error; + } + } + check(serialManager.getStatus() === "connected" && serialManager.getBaudRate() === baud + && ports.at(-1)!.writes.length === writes, "UI rate changed receiver configuration"); + const config = (await import(pathToFileURL(resolve(app, "src/connection/config.ts")).href)).getConfig(); + check(config.uiUpdateRate === rate, "UI rate did not reach persisted configuration owner"); + const sequence = receipts.pvt.size - 1; + check(sequence < packetCount, "Rate change occurred after scheduled input ended"); + await waitFor(() => { inspect(); return observations; }, value => + [...value.pvt.keys()].some(key => key >= sequence) && [...value.sat.keys()].some(key => key >= sequence), + `submitted samples after rate ${rate}`); + rateTransitions.push({ rate, elapsedMs: performance.now() - streamStart, sequence, + pacing: presentation.getDiagnostics() }); + save("rate-transitions", rateTransitions); + }; + const verifyRetention = (label: string, sequence: number, positionFirst = 0, connectionFirst = 0) => { + const report = verifyUbxHistories(ledger, nodes, sequence, consoleOracle, positionFirst, connectionFirst); + retentionChecks.push({ label, ...report }); + save(`retention-${label}`, { report, calls: ledger.snapshot(), native: snapshot() }); + return report; }; const poll = setInterval(inspect, 10); await connect(); send(0); + if (comparison) { + await visit("Signals", 505); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has(0) && o.pvt.has(0), "comparison warm-up submitted"); + await input("move", 1100, 875); + await delay(4500); + await waitFor(async () => (await fetch(`${resources.controlUrl}/state?group=app`)).json(), state => state.pending === 0, + "comparison resources settled"); + await waitForNativeIdle(native, "comparison warm-up idle"); + } const streamBefore = snapshot(); + const presentationBefore = presentation?.getDiagnostics(); + const publicationsBefore = manager.getDiagnostics().appliedPublications; const cpuBefore = process.cpuUsage(), memoryBefore = process.memoryUsage().rss; + const costsBefore = { ...observerCosts }; + const operationsBefore = { ...operations }; + const consoleAppendsBefore = ledger.consoleAppends; const start = performance.now(); - const delivery: number[] = []; - // This source timer never waits for a frame, screenshot, or interaction. - const stream = new Promise((done, reject) => { - const tick = (sequence: number) => setTimeout(() => { - try { - send(sequence); delivery.push(performance.now() - start); - if (sequence === 1200) done(); else tick(sequence + 1); - } catch (error) { reject(error); } - }, Math.max(0, start + sequence * 50 - performance.now())); - tick(1); - }); + const source = deliverScheduledUbx(packetCount, receiverHz, start, send); + const delivery = source.deliveredAt; + const stream = source.completion; + let measurement: any; + void stream.then(() => { + measurement = { mode: comparison ?? "functional", wallMs: performance.now() - start, + cpuMicroseconds: process.cpuUsage(cpuBefore), rssBefore: memoryBefore, rssAfter: process.memoryUsage().rss, + submittedFrames: counterDelta(snapshot().scheduler.submitted, streamBefore.scheduler.submitted), + boundary: "source delivery completion; trailing presentation recorded separately", + costsMs: Object.fromEntries(Object.entries(observerCosts).map(([key, value]) => [key, value - costsBefore[key as keyof typeof costsBefore]])), + operations: Object.fromEntries(Object.entries(operations).map(([key, value]) => [key, value - (operationsBefore[key] ?? 0)])), + consoleAppends: ledger.consoleAppends - consoleAppendsBefore, + appliedFabricPublications: manager.getDiagnostics().appliedPublications - publicationsBefore, + observerRecordsRetained: publicationStages.length, observerRecordLimit: 250000 }; + save("source-delivery", { deliveredAt: delivery, source: source.diagnostics(), + deadlineLatenessMs: source.deadlineLatenessMs, actualHzPerType: packetCount * 1000 / delivery.at(-1)!, measurement }); + }).catch(() => {}); + if (!comparison) { + if (rateChanges) { + await setRateThroughInput(10, start); + await setRateThroughInput(60, start); + await setRateThroughInput(20, start); + } await visit("Signals", 505); await observeNativeFrame(native, f => f.elements.some(n => n.type === "plot-bar" && n.state?.series.every((s: any) => s.count === 1)), "four CNO bands"); await capture("signals"); @@ -268,17 +427,110 @@ async function main() { await visit("Sky View", 445); await observeNativeFrame(native, f => f.elements.some(n => n.type === "di-js-canvas" && n.bounds[2] > 0 && n.resources?.scriptReady), "actual sky script visible"); await capture("sky"); + } await stream; const streamElapsedMs = performance.now() - start; - await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1200) && o.pvt.has(1200), "final telemetry in submitted state"); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has(packetCount) && o.pvt.has(packetCount), "final telemetry in submitted state"); if (observationError) throw observationError; - check(receipts.sat.size === 1201 && receipts.pvt.size === 1201 && messageCount === 2402, "Sustained input accounting failed"); - check(delivery.length === 1200 && delivery.at(-1)! >= 59990, "Sustained scheduling was shorter than 60 seconds"); + check(receipts.sat.size === (packetCount + 1) && receipts.pvt.size === (packetCount + 1) && messageCount === (2 * (packetCount + 1)), "Sustained input accounting failed"); + check(delivery.length === packetCount && delivery.at(-1)! >= 59990, "Sustained scheduling was shorter than 60 seconds"); await delay(Math.max(0, start + 60000 - performance.now())); const streamAfter = snapshot(); - save("stream", { requestedHzPerType: 20, requestedPerType: 1200, delivery, receipts: { sat: [...receipts.sat], pvt: [...receipts.pvt] }, - observations: { sat: [...observations.sat.values()], pvt: [...observations.pvt.values()] }, highWater, operations }); + const presentationAfter = presentation?.getDiagnostics(); + measurement.throughFinalSample = { wallMs: performance.now() - start, cpuMicroseconds: process.cpuUsage(cpuBefore), + rss: process.memoryUsage().rss, submittedFrames: counterDelta(streamAfter.scheduler.submitted, streamBefore.scheduler.submitted) }; + save("measurement", measurement); + save("publication-stages", { events: publicationStages, droppedStageObservations, + scope: "Session publication preparation and React enqueue; not React commit or native submission", + before: presentationBefore, after: presentationAfter, + appliedFabricPublications: manager.getDiagnostics().appliedPublications - publicationsBefore }); + save("stream", { requestedHzPerType: receiverHz, uiHz, requestedPerType: packetCount, delivery, receipts: { sat: [...receipts.sat], pvt: [...receipts.pvt] }, + observations: { sat: [...observations.sat.values()], pvt: [...observations.pvt.values()] }, highWater, operations, + source: source.diagnostics(), deadlineLatenessMs: source.deadlineLatenessMs }); + if (comparison) { + const expectedSequences = Array.from({ length: 3000 }, (_, i) => packetCount - 2999 + i); + const altitudeId = [...nodes].find(([, node]) => node.type === "plot-line" && node.yAxisLabel === "Altitude (m)")?.[0]; + const actualSequences = (ledger.lines.get(altitudeId!) ?? []).map(point => Math.round(point.y * 1000 - 50000)); + save("history-characterization", { comparison, expectedSequences, actualSequences, + exactPositionTail: JSON.stringify(expectedSequences) === JSON.stringify(actualSequences), + missingExpected: expectedSequences.filter(sequence => !actualSequences.includes(sequence)), calls: ledger.snapshot(), native: snapshot() }); + if (checkHistories) { + const report = verifyRetention("comparison-final", packetCount); + await observeNativeFrame(native, frame => frame.elements.some(node => node.type === "plot-scatter" + && node.state?.pointCount === report.retained.positions) + && frame.elements.some(node => node.type === "map-view" && node.state?.polylinePoints === report.retained.trail) + && frame.elements.some(node => node.type === "di-table" && node.state?.rowCount === report.retained.messages) + && frame.elements.some(node => node.type === "clipped-multi-line-text-renderer" + && node.state?.byteCount === report.retained.consoleBytes), "comparison final native retention sizes"); + } + await capture("signals-measured-final"); + const populated = snapshot(); + // Functional scenarios cover native controls, held resources and + // long disconnected idle. Each cost repetition only needs its own + // public teardown and terminal ownership checks after measurement. + serialManager.disconnect(); + await entry.dispose(); + serialManager.off("NAV-SAT", onSat); serialManager.off("NAV-PVT", onPvt); + serialManager.off("message", onMessage); serialManager.off("rawdata", onRaw); + await waitFor(() => serialManager.eventNames().length, (count: number) => count === 0, "comparison passive cleanup"); + check(appIntervals.size === 0 && ports.every(port => !port.isOpen && !port.eventNames().length) + && parsers.every(parser => !parser.eventNames().length), "comparison retained application/serial ownership"); + if (presentation) { + const pacing = presentation.getDiagnostics(); + check(pacing.owners === 0 && pacing.pending === 0 && pacing.timers === 0, "comparison retained pacing work"); + presentation.observe(null); + } + await observeNativeFrame(native, frame => frame.elementCount === 0 && frame.internalSubjectCount === 0 + && frame.hierarchyCount === 1, "comparison public disposer empty tree"); + const final = await waitForNativeIdle(native, "comparison disposed App idle"); + check(final.scheduler.ownerCount === baseline.scheduler.ownerCount && final.scheduler.activeOwners === 0 + && final.scheduler.deadlines === 0 && final.resourceState.textures.liveTextures === 0 + && final.resourceState.textures.retiredTextures === 0, "comparison retained native ownership"); + const js = manager.getDiagnostics(), registrations = service.getDiagnostics(); + check(js.fiberCount === 0 && js.committedDescriptionCount === 0 && js.pendingEventCount === 0 && js.subscriptionClosed, + "comparison retained Fabric ownership"); + check(["mappingCount", "tableCount", "mapCount", "reverseMappingCount", "nativeCount", "registrationCount"] + .every(key => registrations[key] === 0) && registrations.disposed, "comparison retained registrations"); + check(receipts.sat.size === packetCount + 1 && receipts.pvt.size === packetCount + 1 + && messageCount === 2 * (packetCount + 1), "comparison source accounting changed after measurement"); + finalReport = { status: "awaiting-native-shutdown", comparison, app, measurement, + metadata: { node: process.version, react: requireApp("react").version, os: `${platform()} ${release()}`, + cpu: cpus()[0]?.model, backend: (initial as any).backend, dimensions: [1280, 900], + observerIntervalMs: 10, resources: "fresh per-run local fixture cache; warm-up settled before measurement" }, + hostConditions: process.env.XFRAMES_UBX_HOST_CONDITIONS ?? "shared host; quiet window not confirmed", + input: { requestedPerType: packetCount, requestedHzPerType: receiverHz, uiHz, + deliveredElapsedMs: delivery.at(-1), actualHzPerType: packetCount * 1000 / delivery.at(-1)!, + requestedRateMet: packetCount * 1000 / delivery.at(-1)! >= receiverHz * .95, + decodedPerTypeIncludingWarmup: receipts.sat.size, messageCount, rawCallbacks, rawBytes, splitPairs, coalescedPairs, + source: source.diagnostics() }, + presentation: { before: presentationBefore, after: presentationAfter, disposed: presentation?.getDiagnostics(), + droppedStageObservations, observationCapacity: 250000 }, + retentionChecks, highWater, operations, inputRetries, populated, baseline, final, js, registrations, + applicationIntervalsAfterCleanup: appIntervals.size, telemetryListenersAfterCleanup: serialManager.eventNames().length, + unavailable: ["GPU completion/presentation", "physical serial", "controlled framework performance qualification"] }; + save("result", finalReport); + await resources.close(); + await input("close"); + await waitFor(() => nativeShutdownVerified, Boolean, "comparison native shutdown callback", 10000); + return; + } await capture("sky-final"); + if (checkHistories) { + let retentionError: unknown; + await waitFor(() => { try { verifyUbxHistories(ledger, nodes, packetCount, consoleOracle); return true; } + catch (error) { retentionError = error; return false; } }, Boolean, "final ordered retained histories") + .catch(error => { save("retention-failure", { error: String(retentionError), calls: ledger.snapshot(), native: snapshot() }); throw error; }); + verifyRetention("stream-final", packetCount); + await visit("Position", 380); + await observeNativeFrame(native, frame => frame.elements.some(node => node.type === "plot-scatter" + && node.state?.pointCount === Math.min(3000, packetCount + 1)), "populated Position retained scatter"); + await capture("position-final"); + await visit("Console", 119); + await observeNativeFrame(native, frame => frame.elements.some(node => node.type === "clipped-multi-line-text-renderer" + && node.state?.byteCount === Buffer.byteLength(consoleOracle.text)), "final Console retained native bytes"); + await capture("console-final"); + await visit("Sky View", 445); + } // Connected pause preserves stale seconds while static canvas activity stops. await waitFor(() => hasText(/stale (?:[4-9]|[1-9][0-9]+)s/), Boolean, "connected stale status", 10000); const pauseStart = snapshot(); @@ -294,15 +546,25 @@ async function main() { await observeNativeFrame(native, f => f.elements.some(n => n.type === "di-js-canvas" && n.bounds[2] !== skySize), "static sky resize wake"); await capture("sky-resized"); native.resizeWindow(1280, 900); - send(1201); - await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1201) && o.pvt.has(1201), "resumed input submitted"); + if (checkHistories) { + await visit("Position", 380); + await clickUntil("Position reset callback", 40, 84, () => hasText(/No data/) + && ![...nodes.values()].some(node => node.type === "plot-scatter")); + await capture("position-reset"); + await visit("Sky View", 445); + } + send((packetCount + 1)); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has((packetCount + 1)) && o.pvt.has((packetCount + 1)), "resumed input submitted"); await capture("resumed"); + if (checkHistories) verifyRetention("after-position-reset", packetCount + 1, packetCount + 1); await visit("Connection", 46); await clickUntil("Disconnect callback", 44, 140, () => serialManager.getStatus() === "disconnected"); check(ports[0].eventNames().length === 0 && parsers[0].eventNames().length === 0, "Disconnected transport/parser retained listeners"); await connect(); - send(1202); - await waitFor(() => { inspect(); return observations; }, o => o.sat.has(1202) && o.pvt.has(1202), "reconnected telemetry submitted"); + consoleOracle.reset(); + send((packetCount + 2)); + await waitFor(() => { inspect(); return observations; }, o => o.sat.has((packetCount + 2)) && o.pvt.has((packetCount + 2)), "reconnected telemetry submitted"); + if (checkHistories) verifyRetention("reconnect", packetCount + 2, packetCount + 2, packetCount + 2); await clickUntil("Final disconnect callback", 44, 140, () => serialManager.getStatus() === "disconnected"); await visit("Sky View", 445); await input("move", 1100, 875); @@ -333,10 +595,16 @@ async function main() { serialManager.off("message", onMessage); serialManager.off("rawdata", onRaw); await waitFor(() => serialManager.eventNames().length, (n: number) => n === 0, "all application passive subscriptions removed"); check(appIntervals.size === 0, "Application retained interval handles after passive cleanup"); + if (presentation) { + const pacing = presentation.getDiagnostics(); + check(pacing.owners === 0 && pacing.pending === 0 && pacing.timers === 0, + "Application retained pacing ownership after passive cleanup"); + presentation.observe(null); + } const empty = await observeNativeFrame(native, f => f.elementCount === 0 && f.hierarchyCount === 1 && f.internalSubjectCount === 0, "ordinary disposer empty native baseline"); check(ports.every(p => !p.isOpen && p.eventNames().length === 0) && parsers.every(p => p.eventNames().length === 0), "Port/parser lifetime leak"); const generation = empty.scheduler.generation; - for (const port of ports) { port.emit("data", Buffer.concat([navSatPacket(1203), navPvtPacket(1203)])); port.emit("close"); } + for (const port of ports) { port.emit("data", Buffer.concat([navSatPacket((packetCount + 3)), navPvtPacket((packetCount + 3))])); port.emit("close"); } await fetch(`${resources.controlUrl}/release?group=app`); await waitFor(() => snapshot(), f => f.resourceState.mapWorkers?.active === 0 && f.resourceState.mapWorkers?.queued === 0, "retired map workers finish late resources"); @@ -351,23 +619,28 @@ async function main() { "App retained Fabric descriptions/events"); check(["mappingCount", "tableCount", "mapCount", "reverseMappingCount", "nativeCount", "registrationCount"] .every(key => registrations[key] === 0) && registrations.disposed, "App retained JS registrations"); - check(Number(messageCount) === 2406 && Number(receipts.sat.size) === 1203 && Number(receipts.pvt.size) === 1203, "Final message accounting differs"); + check(Number(messageCount) === (2 * (packetCount + 3)) && Number(receipts.sat.size) === (packetCount + 3) && Number(receipts.pvt.size) === (packetCount + 3), "Final message accounting differs"); const distribution = (kind: "sat" | "pvt") => { - const samples = [...observations[kind].values()].filter(s => s.sequence >= 1 && s.sequence <= 1200).map(s => s.latencyMs).sort((a, b) => a - b); + const samples = [...observations[kind].values()].filter(s => s.sequence >= 1 && s.sequence <= packetCount).map(s => s.latencyMs).sort((a, b) => a - b); const p = (fraction: number) => samples[Math.ceil(samples.length * fraction) - 1]; - return { observed: samples.length, coalescedOrUnobservedBetweenPolls: 1200 - samples.length, + return { observed: samples.length, coalescedOrUnobservedBetweenPolls: packetCount - samples.length, p50: p(.5), p95: p(.95), p99: p(.99), maximum: p(1) }; }; finalReport = { status: "awaiting-native-shutdown", mode: "production", app, metadata: { node: process.version, react: requireApp("react").version, os: `${platform()} ${release()}`, cpu: cpus()[0]?.model, backend: (initial as any).backend }, - input: { requestedPerType: 1200, requestedHzPerType: 20, scheduledDurationMs: 60000, deliveredElapsedMs: delivery.at(-1), - streamElapsedMs, actualHzPerType: 1200000 / delivery.at(-1)!, decodedPerTypeIncludingLifecycle: receipts.sat.size, - messageCount, rawCallbacks, rawBytes, splitPairs, coalescedPairs }, + input: { requestedPerType: packetCount, requestedHzPerType: receiverHz, uiHz, scheduledDurationMs: 60000, deliveredElapsedMs: delivery.at(-1), + streamElapsedMs, actualHzPerType: (packetCount * 1000) / delivery.at(-1)!, decodedPerTypeIncludingLifecycle: receipts.sat.size, + messageCount, rawCallbacks, rawBytes, splitPairs, coalescedPairs, source: source.diagnostics(), + requestedRateMet: (packetCount * 1000) / delivery.at(-1)! >= receiverHz * .95 }, receiptToObservedSubmissionMs: { sat: distribution("sat"), pvt: distribution("pvt") }, frameCounts: { sustained: counterDelta(streamAfter.scheduler.submitted, streamBefore.scheduler.submitted), connectedPause: counterDelta(pauseEnd.scheduler.submitted, pauseStart.scheduler.submitted), disconnected10s: 0 }, - highWater, operations, inputRetries, heldRequestsAtUnmount: held, populated, baseline, final, js, registrations, + highWater, operations, inputRetries, rateTransitions, retentionChecks, + consoleOperations: { appends: ledger.consoleAppends, codeUnits: ledger.consoleAppendedCodeUnits }, heldRequestsAtUnmount: held, populated, baseline, final, js, registrations, applicationIntervalsAfterCleanup: appIntervals.size, + measurement, + presentation: { before: presentationBefore, after: presentationAfter, disposed: presentation?.getDiagnostics(), + observationCapacity: 250000, droppedStageObservations }, portsAfterCleanup: ports.map(p => ({ open: p.isOpen, listeners: p.eventNames().length })), parserListenersAfterCleanup: parsers.map(p => p.eventNames().length), telemetryListenersAfterCleanup: serialManager.eventNames().length, processMeasurements: { rssBefore: memoryBefore, rssAfter: process.memoryUsage().rss, cpuMicroseconds: process.cpuUsage(cpuBefore) }, @@ -375,6 +648,10 @@ async function main() { save("result", finalReport); await resources.close(); await input("close"); + // WM_CLOSE can return before the native thread-safe shutdown callback. + // Keep the JS loop alive for that callback; process exit 0 alone is not + // proof of terminal native ownership. The ordinary callback exits itself. + await waitFor(() => nativeShutdownVerified, Boolean, "ordinary native shutdown callback", 10000); return; } await capture("startup"); @@ -409,7 +686,7 @@ async function main() { framesDuringDisconnectedProbe: counterDelta(readDiagnostics(native).scheduler.submitted, before.scheduler.submitted) }); await resources.close(); } -const watchdog = setTimeout(() => { console.error("Application watchdog expired"); process.exit(1); }, 180_000); +const watchdog = setTimeout(() => { console.error("Application watchdog expired"); process.exit(1); }, 240_000); main().then(() => { clearTimeout(watchdog); process.exit(0); }).catch(error => { save("failure", { error: String(error), stack: error?.stack, native: native ? readDiagnostics(native) : null }); console.error(error); clearTimeout(watchdog); process.exit(1); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-comparison-report.mjs b/packages/dear-imgui/npm/diagnostics/ubx-comparison-report.mjs new file mode 100644 index 0000000..742ec8c --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-comparison-report.mjs @@ -0,0 +1,96 @@ +// Offline analysis only: run after the six desktop measurements have stopped. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(process.argv[2] ?? 'build/diagnostics/ubx-pacing'); +const read = (dir, file) => JSON.parse(readFileSync(resolve(root, dir, `${file}.json`), 'utf8')); +function distribution(values) { + const sorted = [...values].sort((a, b) => a - b); + const q = p => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)] ?? null; + return { count: sorted.length, p50: q(.5), p95: q(.95), p99: q(.99), max: q(1) }; +} +const runs = []; +let identity, retention; +for (let pair = 1; pair <= 3; pair++) for (const mode of ['unpaced', 'paced']) { + const dir = `quiet-pair-${pair}-${mode}`; + const r = read(dir, 'result'), stream = read(dir, 'stream'), stages = read(dir, 'publication-stages'); + const sourceIdentity = read(dir, 'source-identity'); + identity ??= sourceIdentity; + assert.deepEqual(sourceIdentity, identity, `${dir}: source/build/observer identity changed`); + assert.equal(r.status, 'passed'); + assert.equal(r.input.requestedRateMet, true); + assert.equal(r.input.messageCount, 14402); + assert.equal(r.input.rawBytes, 1180964); + assert.equal(stages.droppedStageObservations, 0); + assert.equal(stages.after.pending, 0); + const retained = read(dir, 'retention-comparison-final').report; + retention ??= retained; + assert.deepEqual(retained, retention, `${dir}: retained/evicted accounting changed`); + assert.equal(read(dir, 'history-characterization').exactPositionTail, true); + const delta = key => stages.after[key] - stages.before[key]; + const cadence = Object.fromEntries(['requests', 'coalesced', 'flushes', 'publications', 'cancelled'].map(k => [k, delta(k)])); + assert.equal(cadence.requests, cadence.coalesced + cadence.publications); + const events = stages.events.filter(e => e.flush > stages.before.flushes); + assert.equal(events.length, cadence.publications); + const firstByFlush = new Map(); + for (const e of events) if (!firstByFlush.has(e.flush)) firstByFlush.set(e.flush, e.startedAt); + const starts = [...firstByFlush.values()]; + const intervals = starts.slice(1).map((t, i) => t - starts[i]); + // Instrumented first-owner start follows the cadence clock by a few microseconds. + if (mode === 'paced') assert.ok(Math.min(...intervals) >= 49.9, `${dir}: cadence burst`); + const owners = stages.after.ownerCounts.map((owner, i) => Object.fromEntries( + Object.entries(owner).map(([key, value]) => [key, key === 'label' ? value : value - stages.before.ownerCounts[i][key]]))); + const labels = {}; + for (const label of new Set(events.map(e => e.label))) { + const es = events.filter(e => e.label === label); + const kind = label === 'NAV-SAT' ? 'sat' : ['NAV-PVT', 'position', 'map-trail'].includes(label) ? 'pvt' : null; + const receipts = kind ? new Map(stream.receipts[kind]) : null; + const correlated = receipts ? es.filter(e => e.sourceSample / 50 >= 1 && e.sourceSample / 50 <= 7200) : []; + labels[label] = { + publications: es.length, coalescedRequests: es.reduce((n, e) => n + e.coalesced, 0), + firstPendingToStartMs: distribution(es.map(e => e.startedAt - e.firstPendingAt)), + latestRequestToStartMs: distribution(es.map(e => e.startedAt - e.requestedAt)), + preparationAndReactEnqueueMs: distribution(es.map(e => e.finishedAt - e.startedAt)), + preparationAndReactEnqueueTotalMs: es.reduce((n, e) => n + e.finishedAt - e.startedAt, 0), + receiptToPublicationMs: distribution(correlated.map(e => e.finishedAt - receipts.get(e.sourceSample / 50))), + uniqueSourceSamplesPublished: new Set(correlated.map(e => e.sourceSample)).size, + correlation: kind ?? 'unavailable: raw/message owner has no single source sample', + }; + } + const observed = {}; + for (const kind of ['sat', 'pvt']) { + const samples = stream.observations[kind].filter(s => s.sequence >= 1 && s.sequence <= 7200); + assert.ok(samples.some(s => s.sequence === 7200), `${dir}: missing final ${kind}`); + observed[kind] = { ...distribution(samples.map(s => s.latencyMs)), eligible: 7200, + coalescedOrUnobserved: 7200 - samples.length, coveragePercent: 100 * samples.length / 7200 }; + } + const m = r.measurement; + runs.push({ dir, pair, mode, metadata: r.metadata, hostConditions: r.hostConditions, + input: r.input, cadenceThroughFinal: cadence, ownerDeltas: owners, + flushIntervalMs: { ...distribution(intervals), min: Math.min(...intervals) }, + actualFabricPublicationsThroughFinal: stages.appliedFabricPublications, + measurement: m, cpuSeconds: (m.cpuMicroseconds.user + m.cpuMicroseconds.system) / 1e6, + rssMiB: { before: m.rssBefore / 2 ** 20, after: m.rssAfter / 2 ** 20 }, + stageDistributions: labels, observedSubmissionMs: observed, + sourceLatenessMs: distribution(stream.deadlineLatenessMs), inputRetries: r.inputRetries }); +} +const pairs = [1, 2, 3].map(pair => { + const u = runs.find(r => r.pair === pair && r.mode === 'unpaced'); + const p = runs.find(r => r.pair === pair && r.mode === 'paced'); + const reduction = (a, b) => 100 * (1 - b / a); + return { pair, cpuReductionPercent: reduction(u.cpuSeconds, p.cpuSeconds), + snapshotReductionPercent: reduction(u.measurement.operations['di-table:setData'], p.measurement.operations['di-table:setData']), + fabricReductionPercent: reduction(u.measurement.appliedFabricPublications, p.measurement.appliedFabricPublications), + frameReductionPercent: reduction(u.measurement.submittedFrames, p.measurement.submittedFrames), + ownerPublicationReductionPercent: reduction(u.cadenceThroughFinal.publications, p.cadenceThroughFinal.publications) }; +}); +const summaries = Object.fromEntries(Object.keys(pairs[0]).filter(k => k !== 'pair').map(key => + [key, { median: distribution(pairs.map(p => p[key])).p50, min: Math.min(...pairs.map(p => p[key])), max: Math.max(...pairs.map(p => p[key])) }])); +const report = { quantiles: 'nearest rank; no interpolation', evidenceBoundary: 'Exact ordered successful widget API calls plus native submitted-state summaries; no full native-buffer readback or GPU presentation timing', + timingScope: 'CPU/RSS/operations at source completion; cadence and latency through final sample. Warm-up excluded from deltas and distributions. Same instrumentation, with separately reported overhead; no overhead subtraction.', + identity, retention, pairs, summaries, runs }; +writeFileSync(resolve(root, 'quiet-comparison-summary.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify({ summaries, runs: runs.map(r => ({ dir: r.dir, cpu: r.cpuSeconds, rssMiB: r.rssMiB, + frames: r.measurement.submittedFrames, snapshots: r.measurement.operations['di-table:setData'], + fabric: r.measurement.appliedFabricPublications, cadence: r.cadenceThroughFinal, observed: r.observedSubmissionMs })) }, null, 2)); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-history.ts b/packages/dear-imgui/npm/diagnostics/ubx-history.ts new file mode 100644 index 0000000..17031bc --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-history.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; + +/** Bounded record of successful ordinary widget calls. Native submitted summaries + * are checked separately: these arrays prove the ordered data sent across the + * boundary, rather than claiming full native buffers are directly readable. + */ +export class UbxHistoryLedger { + readonly lines = new Map(); + readonly consoles = new Map(); + readonly trails = new Map(); + readonly scatters = new Map(); + readonly tables = new Map(); + consoleAppends = 0; + consoleAppendedCodeUnits = 0; + + operation(id: number, type: string, op: any) { + if (type === "plot-line") { + if (op.op === "resetData") this.lines.set(id, []); + if (op.op === "appendData") { + const points = this.lines.get(id) ?? []; + points.push({ x: op.x, y: op.y }); + if (points.length > 3000) points.shift(); + this.lines.set(id, points); + } + } + if (type === "plot-scatter") { + if (op.op === "resetData") this.scatters.set(id, []); + if (op.op === "setData") this.scatters.set(id, op.data); + } + if (type === "di-table" && op.op === "setData") this.tables.set(id, op.data); + if (type === "map-view") { + if (op.op === "setPolylines") this.trails.set(id, (op.polylines[0]?.points ?? []).slice()); + if (op.op === "appendPolylinePoint") { + const points = this.trails.get(id) ?? []; + points.push({ lat: op.lat, lon: op.lon }); + if (points.length > 1000) points.shift(); + this.trails.set(id, points); + } + } + } + appendConsole(id: number, text: string) { + const retained = (this.consoles.get(id) ?? "") + text; + assert.ok(retained.length <= 65536, "Console native calls exceed code-unit retention bound"); + this.consoles.set(id, retained); + this.consoleAppends++; this.consoleAppendedCodeUnits += text.length; + } + destroy(id: number) { + this.lines.delete(id); this.consoles.delete(id); this.trails.delete(id); + this.scatters.delete(id); this.tables.delete(id); + } + snapshot() { + return { lines: [...this.lines], consoles: [...this.consoles], trails: [...this.trails], + scatters: [...this.scatters], tables: [...this.tables], consoleAppends: this.consoleAppends, + consoleAppendedCodeUnits: this.consoleAppendedCodeUnits }; + } +} + +/** Independent formatter for the two checksummed fixture packets. This oracle + * sees generated input, never the application's retained text or formatter. + */ +export class UbxConsoleOracle { + text = ""; + offset = 0; + evictedCodeUnits = 0; + packet(packet: Buffer, name: string) { + let rendered = `\n──── ${name} [01:${packet[3].toString(16).padStart(2, "0")}] ${packet.length - 8} bytes ────\n`; + for (let start = 0; start < packet.length; start += 16) { + const bytes = [...packet.subarray(start, start + 16)]; + const cells = Array.from({ length: 16 }, (_, index) => index < bytes.length + ? bytes[index].toString(16).padStart(2, "0") + " " : " "); + const ascii = Array.from({ length: 16 }, (_, index) => index >= bytes.length ? " " + : bytes[index] >= 32 && bytes[index] <= 126 ? String.fromCharCode(bytes[index]) : ".").join(""); + rendered += (this.offset + start).toString(16).padStart(8, "0") + " " + + cells.slice(0, 8).join("") + " " + cells.slice(8).join("") + ` |${ascii}|\n`; + } + this.offset += packet.length; + this.text += rendered; + if (this.text.length > 65536) { + this.evictedCodeUnits += this.text.length - 32768; + this.text = this.text.slice(-32768); + } + } + reset() { this.text = ""; this.offset = 0; this.evictedCodeUnits = 0; } +} + +export function expectedPosition(sequence: number) { + return { lat: (515000000 + sequence % 100 * 10) * 1e-7, + lon: (-1200000 + sequence * 10) * 1e-7, altitude: (50000 + sequence) / 1000, + speed: (1000 + sequence) * 0.0036, hAcc: 30 }; +} + +export function verifyUbxHistories(ledger: UbxHistoryLedger, nodes: Map, lastSequence: number, + consoleOracle: UbxConsoleOracle, positionFirst = 0, connectionFirst = 0) { + const id = (type: string, predicate: (node: any) => boolean = () => true) => { + const entry = [...nodes].find(([, node]) => node.type === type && predicate(node)); + assert.ok(entry, `Missing ${type}`); return entry[0]; + }; + const positions = Array.from({ length: Math.min(3000, lastSequence - positionFirst + 1) }, + (_, index) => Math.max(positionFirst, lastSequence - 2999) + index); + const trail = Array.from({ length: Math.min(1000, lastSequence - connectionFirst + 1) }, + (_, index) => Math.max(connectionFirst, lastSequence - 999) + index); + const actualTrail = ledger.trails.get(id("map-view")) ?? []; + assert.deepEqual(actualTrail.map(point => Math.round((point.lon + .12) * 1e6)), trail, + "Exact map trail sequence tail differs from source"); + actualTrail.forEach((point, index) => { + const expected = expectedPosition(trail[index]); + assert.ok(Math.abs(point.lat - expected.lat) < 1e-10 && Math.abs(point.lon - expected.lon) < 1e-10); + }); + const lineChecks = [["Altitude (m)", "altitude"], ["Speed (km/h)", "speed"], ["H Accuracy (m)", "hAcc"]] as const; + for (const [axis, field] of lineChecks) { + const points = ledger.lines.get(id("plot-line", node => node.yAxisLabel === axis)) ?? []; + assert.deepEqual(points.map(point => point.y), positions.map(sequence => expectedPosition(sequence)[field]), `Exact ${axis} tail`); + assert.ok(points.every((point, index) => index === 0 || point.x >= points[index - 1].x), "Position times went backwards"); + } + const sourcePositions = positions.map(expectedPosition); + const meanLat = sourcePositions.reduce((sum, p) => sum + p.lat, 0) / positions.length; + const meanLon = sourcePositions.reduce((sum, p) => sum + p.lon, 0) / positions.length; + const scatter = ledger.scatters.get(id("plot-scatter")) ?? []; + assert.equal(scatter.length, positions.length); + scatter.forEach((point, index) => { + const sample = sourcePositions[index]; + assert.ok(Math.abs(point.x - (sample.lon - meanLon) * Math.cos(meanLat * Math.PI / 180) * 111320) < 1e-6); + assert.ok(Math.abs(point.y - (sample.lat - meanLat) * 111320) < 1e-6); + }); + const rows = ledger.tables.get(id("di-table")) ?? []; + const received = (lastSequence - connectionFirst + 1) * 2; + assert.deepEqual(rows.map(row => row.sequence), Array.from({ length: Math.min(500, received) }, + (_, index) => Math.max(1, received - 499) + index), "Exact message arrival ordinals"); + const text = ledger.consoles.get(id("clipped-multi-line-text-renderer")); + assert.equal(text, consoleOracle.text, "Exact Console retained text and final byte offset"); + return { lastSequence, positionFirst, connectionFirst, retained: { positions: positions.length, trail: trail.length, + messages: rows.length, consoleCodeUnits: text!.length, consoleBytes: Buffer.byteLength(text!) }, + evicted: { positions: Math.max(0, lastSequence - positionFirst + 1 - 3000), + trail: Math.max(0, lastSequence - connectionFirst + 1 - 1000), messages: Math.max(0, received - 500), + consoleCodeUnits: consoleOracle.evictedCodeUnits }, consoleSourceBytes: consoleOracle.offset }; +} diff --git a/packages/dear-imgui/npm/diagnostics/ubx-pacing.patch b/packages/dear-imgui/npm/diagnostics/ubx-pacing.patch new file mode 100644 index 0000000..031f2b6 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-pacing.patch @@ -0,0 +1,2255 @@ +diff --git a/package.json b/package.json +index a237482..836cc7a 100644 +--- a/package.json ++++ b/package.json +@@ -9,7 +9,8 @@ + "scripts": { + "start": "tsx ./src/index.tsx", + "typecheck": "tsc --noEmit", +- "test:serial": "tsx ./tests/serial-lifecycle.ts" ++ "test:serial": "tsx ./tests/serial-lifecycle.ts", ++ "test:pacing": "tsx ./tests/pacing.ts && tsx ./tests/parser-retention.ts && tsx ./tests/config.ts" + }, + "dependencies": { + "@xframes/common": "file:../packages/xframes-common-0.1.7.tgz", +diff --git a/src/connection/config.ts b/src/connection/config.ts +index 244c1a9..fd78cdc 100644 +--- a/src/connection/config.ts ++++ b/src/connection/config.ts +@@ -1,10 +1,12 @@ + import { readFileSync, writeFileSync } from "fs"; ++import { validateUiUpdateRate, type UiUpdateRate } from "../telemetry/PublicationCadence"; + + interface AppConfig { + portPath?: string; + baudRate?: number; + coordFormat?: number; + signalSortByCno?: boolean; ++ uiUpdateRate?: UiUpdateRate; + } + + const CONFIG_PATH = "./config.json"; +@@ -14,16 +16,19 @@ let cache: AppConfig | null = null; + export function getConfig(): AppConfig { + if (!cache) { + try { +- cache = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) ?? {}; ++ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); ++ cache = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + cache = {}; + } + } ++ cache!.uiUpdateRate = validateUiUpdateRate(cache!.uiUpdateRate); + return cache!; + } + + export function updateConfig(partial: Partial): void { + Object.assign(getConfig(), partial); ++ getConfig(); + } + + process.on("exit", () => { +diff --git a/src/hooks/useMapHistory.ts b/src/hooks/useMapHistory.ts +new file mode 100644 +index 0000000..e3dcd2d +--- /dev/null ++++ b/src/hooks/useMapHistory.ts +@@ -0,0 +1,33 @@ ++import { onPresentationStatus } from "../telemetry/connectionStatus"; ++import { useEffect, useState } from "react"; ++import { serialManager } from "../connection"; ++import type { ConnectionStatus } from "../connection"; ++import type { NavPvt } from "ubx-parser"; ++import { BoundedHistory } from "../telemetry/BoundedHistory"; ++import { presentation } from "../telemetry/presentation"; ++ ++export function useMapHistory() { ++ const [points, setPoints] = useState<{ lat: number; lon: number }[]>([]); ++ useEffect(() => { ++ const history = new BoundedHistory<{ lat: number; lon: number }>(1000); ++ let active = true; ++ const owner = presentation.register(() => setPoints(history.snapshot()), "map-trail"); ++ const onPvt = (pvt: NavPvt) => { ++ if (!active || pvt.fixType < 2) return; ++ history.append({ lat: pvt.lat, lon: pvt.lon }); ++ owner.request(pvt.iTOW); ++ }; ++ const onStatus = (status: ConnectionStatus) => { ++ if (!active || status === "connected") return; ++ owner.cancel(); history.clear(); setPoints([]); ++ }; ++ serialManager.on("NAV-PVT", onPvt); ++ const removeStatus = onPresentationStatus(onStatus); ++ return () => { ++ active = false; owner.dispose(); history.clear(); ++ serialManager.off("NAV-PVT", onPvt); ++ removeStatus(); ++ }; ++ }, []); ++ return points; ++} +diff --git a/src/hooks/useMonHw.ts b/src/hooks/useMonHw.ts +index 5bfa7e5..a9c8e6a 100644 +--- a/src/hooks/useMonHw.ts ++++ b/src/hooks/useMonHw.ts +@@ -1,111 +1,34 @@ +-import { useState, useEffect, useRef } from "react"; ++import { useState, useEffect } from "react"; + import { serialManager } from "../connection"; + import type { MonHw, MonHw3, MonRf } from "ubx-parser"; ++import { HardwareHistory } from "../telemetry/HardwareHistory"; ++import { presentation } from "../telemetry/presentation"; ++import { onPresentationStatus } from "../telemetry/connectionStatus"; + +-export interface RfStatusData { +- noisePerMS: number; +- agcCnt: number; +- aStatus: number; +- aPower: number; +- jammingState: number; +- jamInd: number; +- rtcCalib?: boolean; +- safeBoot?: boolean; +- xtalAbsent?: boolean; +-} +- +-export interface HwStatusData { +- hwVersion: string; +- nPins: number; +- rtcCalib: boolean; +- safeBoot: boolean; +- xtalAbsent: boolean; +-} +- +-export function useRfStatus() { +- const [data, setData] = useState(null); +- +- useEffect(() => { +- const onMonHw = (msg: MonHw) => { +- if ("variant" in msg) return; +- setData({ +- noisePerMS: msg.noisePerMS, +- agcCnt: msg.agcCnt, +- aStatus: msg.aStatus, +- aPower: msg.aPower, +- jammingState: msg.flags.jammingState, +- jamInd: msg.jamInd, +- rtcCalib: msg.flags.rtcCalib, +- safeBoot: msg.flags.safeBoot, +- xtalAbsent: msg.flags.xtalAbsent, +- }); +- }; +- +- const onMonRf = (msg: MonRf) => { +- if ("variant" in msg) return; +- if (!msg.list || msg.list.length === 0) return; +- const rf = msg.list[0]; +- setData({ +- noisePerMS: rf.noisePerMS, +- agcCnt: rf.agcCnt, +- aStatus: rf.antStatus, +- aPower: rf.antPower, +- jammingState: rf.flags.jammingState, +- jamInd: rf.jamInd, +- }); +- }; +- +- serialManager.on("MON-HW", onMonHw); +- serialManager.on("MON-RF", onMonRf); +- return () => { +- serialManager.off("MON-HW", onMonHw); +- serialManager.off("MON-RF", onMonRf); +- }; +- }, []); +- +- return data; +-} +- +-export function useHwStatus() { +- const [data, setData] = useState(null); +- ++export function useHardwareTelemetry() { ++ const [snapshot, setSnapshot] = useState(() => new HardwareHistory().snapshot()); + useEffect(() => { +- const onMonHw3 = (msg: MonHw3) => { +- if ("variant" in msg) return; +- setData({ +- hwVersion: msg.hwVersion, +- nPins: msg.nPins, +- rtcCalib: msg.flags.rctCalib, +- safeBoot: msg.flags.safeBoot, +- xtalAbsent: msg.flags.xtalAbsent, +- }); ++ let active = true; ++ const history = new HardwareHistory(); ++ const owner = presentation.register(() => setSnapshot(history.snapshot()), "hardware"); ++ const onHw = (msg: MonHw) => { ++ if (active && history.ingestHw(msg, performance.now())) owner.request(); + }; +- +- serialManager.on("MON-HW3", onMonHw3); +- return () => { +- serialManager.off("MON-HW3", onMonHw3); ++ const onRf = (msg: MonRf) => { ++ if (active && history.ingestRf(msg)) owner.request(); + }; +- }, []); +- +- return data; +-} +- +-export function useJamTrend() { +- const [latest, setLatest] = useState<{ x: number; y: number } | null>(null); +- const startRef = useRef(null); +- +- useEffect(() => { +- const onMonHw = (msg: MonHw) => { +- if ("variant" in msg) return; +- const now = Date.now(); +- if (startRef.current === null) startRef.current = now; +- setLatest({ x: (now - startRef.current) / 1000, y: msg.jamInd }); ++ const onHw3 = (msg: MonHw3) => { ++ if (active && history.ingestHw3(msg)) owner.request(); + }; +- serialManager.on("MON-HW", onMonHw); ++ const removeStatus = onPresentationStatus(status => { ++ if (!active || status === "connected") return; ++ owner.cancel(); history.reset(); setSnapshot(history.snapshot()); ++ }); ++ serialManager.on("MON-HW", onHw); serialManager.on("MON-RF", onRf); serialManager.on("MON-HW3", onHw3); + return () => { +- serialManager.off("MON-HW", onMonHw); ++ active = false; owner.dispose(); removeStatus(); history.reset(); ++ serialManager.off("MON-HW", onHw); serialManager.off("MON-RF", onRf); serialManager.off("MON-HW3", onHw3); + }; + }, []); +- +- return latest; ++ return snapshot; + } +diff --git a/src/hooks/useNavDop.ts b/src/hooks/useNavDop.ts +index 9c00d06..2b570af 100644 +--- a/src/hooks/useNavDop.ts ++++ b/src/hooks/useNavDop.ts +@@ -1,6 +1,5 @@ +-import { useState, useEffect } from "react"; +-import { serialManager } from "../connection"; + import type { NavDop } from "ubx-parser"; ++import { usePacedValue } from "./usePacedValue"; + + export interface NavDopData { + gDOP: number; +@@ -12,27 +11,8 @@ export interface NavDopData { + eDOP: number; + } + +-export function useNavDop() { +- const [data, setData] = useState(null); +- +- useEffect(() => { +- const onNavDop = (msg: NavDop) => { +- setData({ +- gDOP: msg.gDOP, +- pDOP: msg.pDOP, +- tDOP: msg.tDOP, +- vDOP: msg.vDOP, +- hDOP: msg.hDOP, +- nDOP: msg.nDOP, +- eDOP: msg.eDOP, +- }); +- }; ++const select = (message: NavDop): NavDopData => message; + +- serialManager.on("NAV-DOP", onNavDop); +- return () => { +- serialManager.off("NAV-DOP", onNavDop); +- }; +- }, []); +- +- return data; ++export function useNavDop() { ++ return usePacedValue("NAV-DOP", select); + } +diff --git a/src/hooks/useNavPvt.ts b/src/hooks/useNavPvt.ts +index df64afa..014df17 100644 +--- a/src/hooks/useNavPvt.ts ++++ b/src/hooks/useNavPvt.ts +@@ -1,6 +1,5 @@ +-import { useState, useEffect } from "react"; +-import { serialManager } from "../connection"; + import type { NavPvt } from "ubx-parser"; ++import { usePacedValue } from "./usePacedValue"; + + export interface NavPvtData { + fixType: number; +@@ -22,37 +21,8 @@ export interface NavPvtData { + heading: number; + } + +-export function useNavPvt() { +- const [data, setData] = useState(null); +- +- useEffect(() => { +- const onNavPvt = (msg: NavPvt) => { +- setData({ +- fixType: msg.fixType, +- lat: msg.lat, +- lon: msg.lon, +- height: msg.height, +- hMSL: msg.hMSL, +- hAcc: msg.hAcc, +- vAcc: msg.vAcc, +- numSV: msg.numSV, +- year: msg.year, +- month: msg.month, +- day: msg.day, +- hour: msg.hour, +- min: msg.min, +- sec: msg.sec, +- tAcc: msg.tAcc, +- gSpeed: msg.gSpeed, +- heading: msg.heading, +- }); +- }; ++const select = (message: NavPvt): NavPvtData => message; + +- serialManager.on("NAV-PVT", onNavPvt); +- return () => { +- serialManager.off("NAV-PVT", onNavPvt); +- }; +- }, []); +- +- return data; ++export function useNavPvt() { ++ return usePacedValue("NAV-PVT", select); + } +diff --git a/src/hooks/useNavSat.ts b/src/hooks/useNavSat.ts +index 384eb1b..0624b15 100644 +--- a/src/hooks/useNavSat.ts ++++ b/src/hooks/useNavSat.ts +@@ -1,5 +1,5 @@ +-import { useState, useEffect } from "react"; +-import { serialManager } from "../connection"; ++import type { NavSat } from "ubx-parser"; ++import { usePacedValue } from "./usePacedValue"; + + export interface SatInfo { + gnssId: number; +@@ -10,28 +10,8 @@ export interface SatInfo { + svUsed: boolean; + } + +-export function useNavSat(): SatInfo[] | null { +- const [satellites, setSatellites] = useState(null); ++const select = (message: NavSat): SatInfo[] => message.list.map((sv) => ({ gnssId: sv.gnssId, svid: sv.svid, cno: sv.cno, elev: sv.elev, azim: sv.azim, svUsed: sv.flags.svUsed })); + +- useEffect(() => { +- const onNavSat = (msg: any) => { +- setSatellites( +- msg.list.map((sv: any) => ({ +- gnssId: sv.gnssId, +- svid: sv.svid, +- cno: sv.cno, +- elev: sv.elev, +- azim: sv.azim, +- svUsed: sv.flags.svUsed, +- })), +- ); +- }; +- +- serialManager.on("NAV-SAT", onNavSat); +- return () => { +- serialManager.off("NAV-SAT", onNavSat); +- }; +- }, []); +- +- return satellites; ++export function useNavSat() { ++ return usePacedValue("NAV-SAT", select); + } +diff --git a/src/hooks/useNavStatus.ts b/src/hooks/useNavStatus.ts +index 8d36c2f..d3d97d2 100644 +--- a/src/hooks/useNavStatus.ts ++++ b/src/hooks/useNavStatus.ts +@@ -1,6 +1,5 @@ +-import { useState, useEffect } from "react"; +-import { serialManager } from "../connection"; + import type { NavStatus } from "ubx-parser"; ++import { usePacedValue } from "./usePacedValue"; + + export interface NavStatusData { + gpsFix: number; +@@ -11,26 +10,8 @@ export interface NavStatusData { + carrSoln: number; + } + +-export function useNavStatus() { +- const [data, setData] = useState(null); +- +- useEffect(() => { +- const onNavStatus = (msg: NavStatus) => { +- setData({ +- gpsFix: msg.gpsFix, +- ttff: msg.ttff, +- msss: msg.msss, +- spoofDetState: msg.flags2.spoofDetState, +- diffCorr: msg.fixStat.diffCorr, +- carrSoln: msg.flags2.carrSoln, +- }); +- }; ++const select = (message: NavStatus): NavStatusData => ({ gpsFix: message.gpsFix, ttff: message.ttff, msss: message.msss, spoofDetState: message.flags2.spoofDetState, diffCorr: message.fixStat.diffCorr, carrSoln: message.flags2.carrSoln }); + +- serialManager.on("NAV-STATUS", onNavStatus); +- return () => { +- serialManager.off("NAV-STATUS", onNavStatus); +- }; +- }, []); +- +- return data; ++export function useNavStatus() { ++ return usePacedValue("NAV-STATUS", select); + } +diff --git a/src/hooks/usePacedValue.ts b/src/hooks/usePacedValue.ts +new file mode 100644 +index 0000000..5e1d888 +--- /dev/null ++++ b/src/hooks/usePacedValue.ts +@@ -0,0 +1,39 @@ ++import { onPresentationStatus } from "../telemetry/connectionStatus"; ++import { useEffect, useState } from "react"; ++import { serialManager } from "../connection"; ++import { presentation } from "../telemetry/presentation"; ++import type { ConnectionStatus } from "../connection"; ++ ++/** Latest-value displays only. Source histories subscribe before coalescing. */ ++export function usePacedValue(event: string, select: (message: M) => T) { ++ const [value, setValue] = useState(null); ++ useEffect(() => { ++ let active = true; ++ let latest: M | null = null; ++ const owner = presentation.register(() => { ++ if (latest !== null) setValue(select(latest)); ++ latest = null; ++ }, event); ++ const onMessage = (message: M) => { ++ if (!active) return; ++ latest = message; ++ owner.request(typeof (message as { iTOW?: number }).iTOW === "number" ? (message as { iTOW: number }).iTOW : undefined); ++ }; ++ const onStatus = (status: ConnectionStatus) => { ++ if (!active || status === "connected") return; ++ latest = null; ++ owner.cancel(); ++ setValue(null); ++ }; ++ serialManager.on(event, onMessage); ++ const removeStatus = onPresentationStatus(onStatus); ++ return () => { ++ active = false; ++ latest = null; ++ owner.dispose(); ++ serialManager.off(event, onMessage); ++ removeStatus(); ++ }; ++ }, [event, select]); ++ return value; ++} +diff --git a/src/hooks/usePositionHistory.ts b/src/hooks/usePositionHistory.ts +index 21bf046..4fd1fdf 100644 +--- a/src/hooks/usePositionHistory.ts ++++ b/src/hooks/usePositionHistory.ts +@@ -1,126 +1,47 @@ ++import { onPresentationStatus } from "../telemetry/connectionStatus"; + import { useState, useEffect, useRef, useCallback } from "react"; +-import { useNavPvt } from "./useNavPvt"; +- +-const MAX_SAMPLES = 3000; +-const DEG_TO_M = 111320; +- +-interface PositionSample { +- lat: number; +- lon: number; +- hMSL: number; +- gSpeed: number; +- elapsedSec: number; +-} +- +-export interface CepStats { +- cep50: number; +- cep95: number; +- meanHAcc: number; +- sampleCount: number; +-} +- +-export interface PositionHistory { +- scatterPoints: { x: number; y: number }[]; +- latestAltitude: { x: number; y: number } | null; +- latestSpeed: { x: number; y: number } | null; +- latestHAcc: { x: number; y: number } | null; +- cepStats: CepStats | null; +- sampleCount: number; +- elapsedSec: number; +- resetCounter: number; +-} ++import { serialManager } from "../connection"; ++import type { ConnectionStatus } from "../connection"; ++import type { NavPvt } from "ubx-parser"; ++import { PositionHistoryStore } from "../telemetry/PositionHistory"; ++import { presentation } from "../telemetry/presentation"; + + export function usePositionHistory() { +- const pvt = useNavPvt(); +- const samplesRef = useRef([]); +- const startTimeRef = useRef(null); +- +- const [history, setHistory] = useState({ +- scatterPoints: [], +- latestAltitude: null, +- latestSpeed: null, +- latestHAcc: null, +- cepStats: null, +- sampleCount: 0, +- elapsedSec: 0, +- resetCounter: 0, +- }); +- ++ const store = useRef(null); ++ if (!store.current) store.current = new PositionHistoryStore(); ++ const [history, setHistory] = useState(() => store.current!.snapshot()); ++ const cancel = useRef<(() => void) | null>(null); + const reset = useCallback(() => { +- samplesRef.current = []; +- startTimeRef.current = null; +- setHistory((prev) => ({ +- scatterPoints: [], +- latestAltitude: null, +- latestSpeed: null, +- latestHAcc: null, +- cepStats: null, +- sampleCount: 0, +- elapsedSec: 0, +- resetCounter: prev.resetCounter + 1, +- })); ++ cancel.current?.(); ++ store.current!.resetPosition(); ++ setHistory(store.current!.snapshot()); + }, []); + + useEffect(() => { +- if (!pvt || pvt.fixType < 2) return; +- +- const now = Date.now(); +- if (startTimeRef.current === null) startTimeRef.current = now; +- const elapsedSec = (now - startTimeRef.current) / 1000; +- +- const samples = samplesRef.current; +- samples.push({ +- lat: pvt.lat, +- lon: pvt.lon, +- hMSL: pvt.hMSL, +- gSpeed: pvt.gSpeed, +- elapsedSec, +- }); +- if (samples.length > MAX_SAMPLES) { +- samples.splice(0, samples.length - MAX_SAMPLES); +- } +- +- // Compute mean lat/lon +- let sumLat = 0; +- let sumLon = 0; +- for (const s of samples) { +- sumLat += s.lat; +- sumLon += s.lon; +- } +- const meanLat = sumLat / samples.length; +- const meanLon = sumLon / samples.length; +- +- // Convert to East/North offsets in meters +- const cosLat = Math.cos(meanLat * (Math.PI / 180)); +- const scatterPoints = samples.map((s) => ({ +- x: (s.lon - meanLon) * cosLat * DEG_TO_M, +- y: (s.lat - meanLat) * DEG_TO_M, +- })); +- +- // CEP computation +- const distances = scatterPoints.map((p) => +- Math.sqrt(p.x * p.x + p.y * p.y), +- ); +- distances.sort((a, b) => a - b); +- const cep50 = distances[Math.floor(distances.length * 0.5)] ?? 0; +- const cep95 = distances[Math.floor(distances.length * 0.95)] ?? 0; +- +- setHistory((prev) => ({ +- scatterPoints, +- latestAltitude: { x: elapsedSec, y: pvt.hMSL / 1000 }, +- latestSpeed: { x: elapsedSec, y: pvt.gSpeed * 0.0036 }, +- latestHAcc: { x: elapsedSec, y: pvt.hAcc / 1000 }, +- cepStats: { +- cep50, +- cep95, +- meanHAcc: pvt.hAcc / 1000, +- sampleCount: samples.length, +- }, +- sampleCount: samples.length, +- elapsedSec, +- resetCounter: prev.resetCounter, +- })); +- }, [pvt]); +- ++ let active = true; ++ const owner = presentation.register(() => setHistory(store.current!.snapshot()), "position"); ++ cancel.current = owner.cancel; ++ const onPvt = (pvt: NavPvt) => { ++ if (!active) return; ++ store.current!.ingest(pvt, performance.now()); ++ if (pvt.fixType >= 2) owner.request(pvt.iTOW); ++ }; ++ const onStatus = (status: ConnectionStatus) => { ++ if (!active || status === "connected") return; ++ owner.cancel(); ++ store.current!.resetConnection(); ++ setHistory(store.current!.snapshot()); ++ }; ++ serialManager.on("NAV-PVT", onPvt); ++ const removeStatus = onPresentationStatus(onStatus); ++ return () => { ++ active = false; ++ owner.dispose(); ++ cancel.current = null; ++ serialManager.off("NAV-PVT", onPvt); ++ removeStatus(); ++ store.current!.resetConnection(); ++ }; ++ }, []); + return { ...history, reset }; + } +diff --git a/src/hooks/useUbxMessages.ts b/src/hooks/useUbxMessages.ts +index fbb1396..ecb3d49 100644 +--- a/src/hooks/useUbxMessages.ts ++++ b/src/hooks/useUbxMessages.ts +@@ -1,111 +1,66 @@ +-import { useState, useEffect, useRef } from "react"; ++import { onPresentationStatus } from "../telemetry/connectionStatus"; ++import { useState, useEffect } from "react"; + import { serialManager } from "../connection"; + import type { ConnectionStatus } from "../connection"; + import type { UbxMessage } from "ubx-parser"; ++import { MessageHistory, type MessageRow } from "../telemetry/MessageHistory"; ++import { ArrivalWindow } from "../telemetry/ArrivalWindow"; ++import { presentation } from "../telemetry/presentation"; + +-interface MessageRow { +- timestamp: string; +- name: string; +- length: string; +-} +- +-const MAX_MESSAGES = 500; ++const initial = () => ({ messages: [] as MessageRow[], messageRate: 0, timeSinceLastMessage: 0, ++ bytesPerSec: 0, baudRate: null as number | null, receivedMessages: 0, receivedBytes: 0, evictedMessages: 0 }); + + export function useUbxMessages() { +- const [messages, setMessages] = useState([]); +- const [messageRate, setMessageRate] = useState(0); +- const [timeSinceLastMessage, setTimeSinceLastMessage] = useState(0); +- const [bytesPerSec, setBytesPerSec] = useState(0); +- const [baudRate, setBaudRate] = useState(null); +- const timestampsRef = useRef([]); +- const lastMessageTimeRef = useRef(0); +- const byteChunksRef = useRef<{ time: number; bytes: number }[]>([]); +- ++ const [state, setState] = useState(initial); + useEffect(() => { +- const onMessage = (msg: UbxMessage) => { ++ let active = true, lastMessage: number | null = null, receivedBytes = 0, rowsDirty = false; ++ const history = new MessageHistory(); ++ const messages = new ArrivalWindow(), bytes = new ArrivalWindow(); ++ const rates = () => { + const now = Date.now(); +- lastMessageTimeRef.current = now; +- const ts = new Date(now); +- const timeStr = +- ts.getHours().toString().padStart(2, "0") + +- ":" + +- ts.getMinutes().toString().padStart(2, "0") + +- ":" + +- ts.getSeconds().toString().padStart(2, "0") + +- "." + +- ts.getMilliseconds().toString().padStart(3, "0"); +- +- const row: MessageRow = { +- timestamp: timeStr, +- name: msg.name, +- length: String(JSON.stringify(msg).length), +- }; +- +- setMessages((prev) => { +- const next = [...prev, row]; +- if (next.length > MAX_MESSAGES) { +- return next.slice(next.length - MAX_MESSAGES); +- } +- return next; +- }); +- +- // Track message rate (rolling 1s window) +- timestampsRef.current.push(now); +- const cutoff = now - 1000; +- timestampsRef.current = timestampsRef.current.filter((t) => t > cutoff); +- setMessageRate(timestampsRef.current.length); ++ return { messageRate: messages.total(now), bytesPerSec: bytes.total(now), ++ timeSinceLastMessage: lastMessage === null ? 0 : Math.max(0, Math.floor((now - lastMessage) / 1000)) }; ++ }; ++ const owner = presentation.register(() => { ++ const rows = rowsDirty ? history.snapshot() : null; ++ rowsDirty = false; ++ const nextRates = rates(); ++ setState(previous => ({ ...previous, ...nextRates, messages: rows ?? previous.messages, ++ receivedMessages: history.received, receivedBytes, evictedMessages: history.evicted })); ++ }, "messages"); ++ const onMessage = (message: UbxMessage) => { ++ if (!active) return; ++ const now = Date.now(); lastMessage = now; ++ history.ingest(message, now); messages.add(now); ++ rowsDirty = true; owner.request(); + }; +- + const onRawData = (chunk: Buffer) => { +- const now = Date.now(); +- byteChunksRef.current.push({ time: now, bytes: chunk.length }); +- const cutoff = now - 1000; +- byteChunksRef.current = byteChunksRef.current.filter((c) => c.time > cutoff); +- const total = byteChunksRef.current.reduce((sum, c) => sum + c.bytes, 0); +- setBytesPerSec(total); ++ if (!active) return; ++ receivedBytes += chunk.length; bytes.add(Date.now(), chunk.length); owner.request(); + }; +- +- const onStatus = (s: ConnectionStatus) => { +- if (s === "disconnected") { +- timestampsRef.current = []; +- setMessageRate(0); +- lastMessageTimeRef.current = 0; +- setTimeSinceLastMessage(0); +- byteChunksRef.current = []; +- setBytesPerSec(0); +- } ++ const onStatus = (status: ConnectionStatus) => { ++ if (!active || status === "connected") return; ++ owner.cancel(); history.clear(); messages.clear(); bytes.clear(); ++ receivedBytes = 0; lastMessage = null; rowsDirty = false; ++ setState(initial()); + }; +- +- const onBaudRate = (rate: number | null) => { +- setBaudRate(rate); ++ const onBaudRate = (baudRate: number | null) => { ++ if (active) setState(previous => ({ ...previous, baudRate })); + }; +- ++ // Stale-time and rate decay are status work, independent of presentation ticks. + const interval = setInterval(() => { +- if (lastMessageTimeRef.current > 0) { +- setTimeSinceLastMessage(Math.floor((Date.now() - lastMessageTimeRef.current) / 1000)); +- } +- // Decay bytes/sec when no data arrives +- const now = Date.now(); +- const cutoff = now - 1000; +- timestampsRef.current = timestampsRef.current.filter((time) => time > cutoff); +- setMessageRate(timestampsRef.current.length); +- byteChunksRef.current = byteChunksRef.current.filter((c) => c.time > cutoff); +- setBytesPerSec(byteChunksRef.current.reduce((sum, c) => sum + c.bytes, 0)); ++ const next = rates(); ++ setState(previous => previous.messageRate === next.messageRate && previous.bytesPerSec === next.bytesPerSec ++ && previous.timeSinceLastMessage === next.timeSinceLastMessage ? previous : { ...previous, ...next }); + }, 1000); +- +- serialManager.on("message", onMessage); +- serialManager.on("rawdata", onRawData); +- serialManager.on("status", onStatus); +- serialManager.on("baudrate", onBaudRate); +- ++ serialManager.on("message", onMessage); serialManager.on("rawdata", onRawData); ++ const removeStatus = onPresentationStatus(onStatus); serialManager.on("baudrate", onBaudRate); + return () => { +- serialManager.off("message", onMessage); +- serialManager.off("rawdata", onRawData); +- serialManager.off("status", onStatus); +- serialManager.off("baudrate", onBaudRate); ++ active = false; owner.dispose(); history.clear(); messages.clear(); bytes.clear(); + clearInterval(interval); ++ serialManager.off("message", onMessage); serialManager.off("rawdata", onRawData); ++ removeStatus(); serialManager.off("baudrate", onBaudRate); + }; + }, []); +- +- return { messages, messageRate, timeSinceLastMessage, bytesPerSec, baudRate }; ++ return state; + } +diff --git a/src/panels/ConnectionPanel.tsx b/src/panels/ConnectionPanel.tsx +index 4f5831e..efe8a1c 100644 +--- a/src/panels/ConnectionPanel.tsx ++++ b/src/panels/ConnectionPanel.tsx +@@ -6,6 +6,8 @@ import { ComboChangeEvent } from "@xframes/common"; + import { useSerialConnection } from "../hooks/useSerialConnection"; + import { getConfig, updateConfig } from "../connection/config"; + import { themeColors } from "../themes"; ++import { UI_UPDATE_RATES } from "../telemetry/PublicationCadence"; ++import { setUiUpdateRate } from "../telemetry/presentation"; + + const BAUD_RATES = ["9600", "38400", "115200", "921600"]; + const DEFAULT_BAUD_INDEX = 1; // 38400 +@@ -131,6 +133,15 @@ export const ConnectionPanel = () => { + style={{ colors: { [ImGuiCol.Text]: "#e74c3c" } }} + /> + )} ++ ++ ++ setUiUpdateRate(UI_UPDATE_RATES[event.nativeEvent.value])} ++ style={{ width: 150 }} ++ /> ++ + + ); + }; +diff --git a/src/panels/ConsoleView.tsx b/src/panels/ConsoleView.tsx +index d9392d3..a9557f9 100644 +--- a/src/panels/ConsoleView.tsx ++++ b/src/panels/ConsoleView.tsx +@@ -1,3 +1,4 @@ ++import { onPresentationStatus } from "../telemetry/connectionStatus"; + import { ImGuiCol } from "@xframes/common"; + import * as React from "react"; + import { useEffect, useRef, useState } from "react"; +@@ -6,165 +7,55 @@ import { ClippedMultiLineTextRendererImperativeHandle } from "@xframes/common"; + import { serialManager } from "../connection"; + import { themeColors } from "../themes"; + +-const UBX_NAMES: Record = { +- "01:07": "NAV-PVT", +- "01:35": "NAV-SAT", +- "01:04": "NAV-DOP", +- "01:03": "NAV-STATUS", +- "0a:09": "MON-HW", +- "0a:37": "MON-HW3", +- "0a:38": "MON-RF", +- "05:01": "ACK-ACK", +- "05:00": "ACK-NAK", +-}; +- +-function ubxName(cls: number, id: number): string { +- const key = `${cls.toString(16).padStart(2, "0")}:${id.toString(16).padStart(2, "0")}`; +- return UBX_NAMES[key] ?? `UBX-${key.toUpperCase()}`; +-} +- +-function formatHexDump(chunk: Buffer, offset: number): string { +- let result = ""; +- for (let i = 0; i < chunk.length; i += 16) { +- const addr = (offset + i).toString(16).padStart(8, "0"); +- const bytes = chunk.subarray(i, Math.min(i + 16, chunk.length)); +- +- let hex = ""; +- let ascii = ""; +- for (let j = 0; j < 16; j++) { +- if (j === 8) hex += " "; +- if (j < bytes.length) { +- hex += bytes[j].toString(16).padStart(2, "0") + " "; +- ascii += bytes[j] >= 0x20 && bytes[j] <= 0x7e ? String.fromCharCode(bytes[j]) : "."; +- } else { +- hex += " "; +- ascii += " "; +- } +- } +- +- result += `${addr} ${hex} |${ascii}|\n`; +- } +- return result; +-} ++import { ConsoleHistory } from "../telemetry/ConsoleHistory"; ++import { presentation } from "../telemetry/presentation"; ++import type { ConnectionStatus } from "../connection"; + + export const ConsoleView = () => { + const textRef = useRef(null); +- const offsetRef = useRef(0); +- const frameBufferRef = useRef(Buffer.alloc(0)); +- const [hasData, setHasData] = useState(false); +- const historyRef = useRef(""); +- const replayRef = useRef(true); +- const [historyGeneration, setHistoryGeneration] = useState(0); +- +- // The native text handle is append-only. Rotate its lifetime when trimming +- // the console so both retained JS text and native text stay bounded. ++ const [snapshot, setSnapshot] = useState({ text: "", generation: 0 }); ++ const appended = useRef({ generation: -1, length: 0 }); + useEffect(() => { +- if (textRef.current && replayRef.current) { +- textRef.current.appendTextToClippedMultiLineTextRenderer(historyRef.current); +- replayRef.current = false; +- } +- }, [hasData, historyGeneration]); ++ if (!textRef.current) return; ++ if (appended.current.generation !== snapshot.generation) ++ appended.current = { generation: snapshot.generation, length: 0 }; ++ const tail = snapshot.text.slice(appended.current.length); ++ if (tail) textRef.current.appendTextToClippedMultiLineTextRenderer(tail); ++ appended.current.length = snapshot.text.length; ++ }, [snapshot]); + + useEffect(() => { ++ const history = new ConsoleHistory(); ++ let active = true; ++ const owner = presentation.register(() => setSnapshot(history.snapshot()), "console"); + const onRawData = (chunk: Buffer) => { +- if (!hasData) setHasData(true); +- const appendText = (text: string) => { +- historyRef.current += text; +- if (historyRef.current.length > 65536) { +- historyRef.current = historyRef.current.slice(-32768); +- replayRef.current = true; +- setHistoryGeneration((value) => value + 1); +- } +- if (!replayRef.current) textRef.current?.appendTextToClippedMultiLineTextRenderer(text); +- }; +- +- frameBufferRef.current = Buffer.concat([frameBufferRef.current, chunk]); +- const buf = frameBufferRef.current; +- let pos = 0; +- +- while (pos < buf.length) { +- // Find next UBX sync (0xB5 0x62) +- let syncPos = -1; +- for (let i = pos; i < buf.length - 1; i++) { +- if (buf[i] === 0xb5 && buf[i + 1] === 0x62) { +- syncPos = i; +- break; +- } +- } +- +- if (syncPos === -1) { +- // No sync found — dump remaining, keep last byte if it could be start of sync +- const keep = buf[buf.length - 1] === 0xb5 ? 1 : 0; +- if (buf.length - keep > pos) { +- appendText( +- formatHexDump(buf.subarray(pos, buf.length - keep), offsetRef.current), +- ); +- offsetRef.current += buf.length - keep - pos; +- } +- pos = buf.length - keep; +- break; +- } +- +- // Dump non-UBX bytes before sync +- if (syncPos > pos) { +- appendText( +- formatHexDump(buf.subarray(pos, syncPos), offsetRef.current), +- ); +- offsetRef.current += syncPos - pos; +- pos = syncPos; +- } +- +- // Need at least 6 bytes for header +- if (pos + 6 > buf.length) break; +- +- const cls = buf[pos + 2]; +- const id = buf[pos + 3]; +- const payloadLen = buf[pos + 4] | (buf[pos + 5] << 8); +- const frameLen = payloadLen + 8; +- +- // Need complete frame +- if (pos + frameLen > buf.length) break; +- +- // Complete frame — print separator header + hex dump +- const name = ubxName(cls, id); +- const separator = `\n──── ${name} [${cls.toString(16).padStart(2, "0")}:${id.toString(16).padStart(2, "0")}] ${payloadLen} bytes ────\n`; +- const frame = buf.subarray(pos, pos + frameLen); +- appendText( +- separator + formatHexDump(frame, offsetRef.current), +- ); +- offsetRef.current += frameLen; +- pos += frameLen; +- } +- +- // Save unprocessed bytes +- frameBufferRef.current = +- pos < buf.length ? buf.subarray(pos) : Buffer.alloc(0); ++ if (!active) return; ++ history.ingest(chunk); ++ owner.request(); ++ }; ++ const onStatus = (status: ConnectionStatus) => { ++ if (!active || status === "connected") return; ++ owner.cancel(); history.reset(); setSnapshot(history.snapshot()); + }; +- + serialManager.on("rawdata", onRawData); ++ const removeStatus = onPresentationStatus(onStatus); + return () => { ++ active = false; owner.dispose(); history.reset(); + serialManager.off("rawdata", onRawData); ++ removeStatus(); + }; +- }, [hasData]); +- +- if (!hasData) { +- return ( +- +- +- +- ); +- } ++ }, []); + ++ if (!snapshot.text) return ( ++ ++ ++ ++ ); + return ( + +- ++ + + ); + }; +diff --git a/src/panels/HardwareStatusPanel.tsx b/src/panels/HardwareStatusPanel.tsx +index d4a2ae4..b717dde 100644 +--- a/src/panels/HardwareStatusPanel.tsx ++++ b/src/panels/HardwareStatusPanel.tsx +@@ -3,7 +3,7 @@ import * as React from "react"; + import { useRef, useEffect } from "react"; + import { XFrames } from "@xframes/node"; + import type { PlotLineImperativeHandle } from "@xframes/common"; +-import { useRfStatus, useHwStatus, useJamTrend } from "../hooks/useMonHw"; ++import { useHardwareTelemetry } from "../hooks/useMonHw"; + import { themeColors } from "../themes"; + import { SectionHeader } from "../components/SectionHeader"; + import { LabelRow } from "../components/LabelRow"; +@@ -51,15 +51,22 @@ function jamIndColor(val: number): string { + } + + export const HardwareStatusPanel = () => { +- const rf = useRfStatus(); +- const hw = useHwStatus(); +- const latestJamInd = useJamTrend(); ++ const { rf, hw, jam, epoch } = useHardwareTelemetry(); + const jamRef = useRef(null); + ++ const appended = useRef({ epoch: -1, sequence: 0 }); + useEffect(() => { +- if (!jamRef.current || !latestJamInd) return; +- jamRef.current.appendData(latestJamInd.x, latestJamInd.y); +- }, [latestJamInd]); ++ if (!jamRef.current) return; ++ const cursor = appended.current; ++ if (cursor.epoch !== epoch || (jam.length && cursor.sequence < jam[0].sequence - 1)) { ++ jamRef.current.resetData(); cursor.epoch = epoch; cursor.sequence = 0; ++ } ++ for (const sample of jam) { ++ if (sample.sequence <= cursor.sequence) continue; ++ jamRef.current.appendData(sample.x, sample.y); ++ cursor.sequence = sample.sequence; ++ } ++ }, [jam, epoch]); + + if (!rf && !hw) { + return ( +diff --git a/src/panels/MapPanel.tsx b/src/panels/MapPanel.tsx +index 35d5db9..851e5f3 100644 +--- a/src/panels/MapPanel.tsx ++++ b/src/panels/MapPanel.tsx +@@ -4,6 +4,7 @@ import { useRef, useEffect, useState } from "react"; + import { XFrames } from "@xframes/node"; + import type { MapImperativeHandle } from "@xframes/common"; + import { useNavPvt } from "../hooks/useNavPvt"; ++import { useMapHistory } from "../hooks/useMapHistory"; + import { themeColors } from "../themes"; + + const FIX_COLORS: Record = { +@@ -25,22 +26,27 @@ export const MapPanel = () => { + const mapRef = useRef(null); + const data = useNavPvt(); + const hasCentered = useRef(false); +- const polylineReady = useRef(false); ++ const points = useMapHistory(); + const [zoom, setZoom] = useState(15); + + useEffect(() => { + if (!mapRef.current) return; + mapRef.current.setPolylines([ +- { points: [], color: "#FF8800", thickness: 2, pointsLimit: 1000 }, ++ { points, color: "#FF8800", thickness: 2, pointsLimit: 1000 }, + ]); +- polylineReady.current = true; +- }, []); ++ if (!points.length) hasCentered.current = false; ++ }, [points]); + + useEffect(() => { +- if (!mapRef.current || !data || data.fixType < 2) return; ++ if (!mapRef.current) return; ++ if (!data || data.fixType < 2) { ++ mapRef.current.setMarkers([]); ++ mapRef.current.setOverlays([]); ++ return; ++ } + + if (!hasCentered.current) { +- mapRef.current.render(data.lon, data.lat, 15); ++ mapRef.current.render(data.lon, data.lat, zoom); + hasCentered.current = true; + } + +@@ -60,9 +66,6 @@ export const MapPanel = () => { + }, + ]); + +- if (polylineReady.current) { +- mapRef.current.appendPolylinePoint(0, data.lat, data.lon); +- } + }, [data]); + + return ( +diff --git a/src/panels/MessageView.tsx b/src/panels/MessageView.tsx +index f99b050..de67294 100644 +--- a/src/panels/MessageView.tsx ++++ b/src/panels/MessageView.tsx +@@ -15,7 +15,7 @@ export const MessageView = () => { + const tableRef = useRef(null); + + useEffect(() => { +- if (tableRef.current && messages.length > 0) { ++ if (tableRef.current) { + tableRef.current.setTableData(messages); + } + }, [messages]); +diff --git a/src/panels/PositionTrackingPanel.tsx b/src/panels/PositionTrackingPanel.tsx +index 2711159..5257095 100644 +--- a/src/panels/PositionTrackingPanel.tsx ++++ b/src/panels/PositionTrackingPanel.tsx +@@ -19,9 +19,7 @@ export const PositionTrackingPanel = () => { + + const { + scatterPoints, +- latestAltitude, +- latestSpeed, +- latestHAcc, ++ samples, + cepStats, + sampleCount, + elapsedSec, +@@ -47,23 +45,24 @@ export const PositionTrackingPanel = () => { + scatterRef.current.setData(scatterPoints); + }, [scatterPoints]); + +- // Append to altitude plot ++ // Replay every unseen retained source sample, even if React skipped a snapshot. ++ const appended = useRef({ epoch: -1, sequence: 0 }); + useEffect(() => { +- if (!altitudeRef.current || !latestAltitude) return; +- altitudeRef.current.appendData(latestAltitude.x, latestAltitude.y); +- }, [latestAltitude]); +- +- // Append to speed plot +- useEffect(() => { +- if (!speedRef.current || !latestSpeed) return; +- speedRef.current.appendData(latestSpeed.x, latestSpeed.y); +- }, [latestSpeed]); +- +- // Append to hAcc plot +- useEffect(() => { +- if (!hAccRef.current || !latestHAcc) return; +- hAccRef.current.appendData(latestHAcc.x, latestHAcc.y); +- }, [latestHAcc]); ++ if (!altitudeRef.current || !speedRef.current || !hAccRef.current) return; ++ const cursor = appended.current; ++ if (cursor.epoch !== resetCounter || (samples.length && cursor.sequence < samples[0].sequence - 1)) { ++ altitudeRef.current.resetData(); speedRef.current.resetData(); hAccRef.current.resetData(); ++ cursor.epoch = resetCounter; ++ cursor.sequence = 0; ++ } ++ for (const sample of samples) { ++ if (sample.sequence <= cursor.sequence) continue; ++ altitudeRef.current.appendData(sample.elapsedSec, sample.hMSL / 1000); ++ speedRef.current.appendData(sample.elapsedSec, sample.gSpeed * 0.0036); ++ hAccRef.current.appendData(sample.elapsedSec, sample.hAcc / 1000); ++ cursor.sequence = sample.sequence; ++ } ++ }, [samples, resetCounter]); + + if (sampleCount === 0) { + return ( +diff --git a/src/panels/SkyViewPanel.tsx b/src/panels/SkyViewPanel.tsx +index 3fe9b51..77c958a 100644 +--- a/src/panels/SkyViewPanel.tsx ++++ b/src/panels/SkyViewPanel.tsx +@@ -18,8 +18,8 @@ export const SkyViewPanel = () => { + }, []); + + useEffect(() => { +- if (!canvasRef.current || !scriptLoaded.current || !satellites) return; +- canvasRef.current.setData(satellites); ++ if (!canvasRef.current || !scriptLoaded.current) return; ++ canvasRef.current.setData(satellites ?? []); + }, [satellites]); + + return ( +diff --git a/src/telemetry/ArrivalWindow.ts b/src/telemetry/ArrivalWindow.ts +new file mode 100644 +index 0000000..c49cd7a +--- /dev/null ++++ b/src/telemetry/ArrivalWindow.ts +@@ -0,0 +1,23 @@ ++/** Exact rolling one-second totals at the application's millisecond clock ++ * resolution. Storage is fixed even when a callback contains a large burst. ++ */ ++export class ArrivalWindow { ++ private times = new Float64Array(1001).fill(-Infinity); ++ private values = new Float64Array(1001); ++ add(now: number, value = 1): void { ++ const time = Math.floor(now); ++ const slot = ((time % 1001) + 1001) % 1001; ++ if (this.times[slot] !== time) { ++ this.times[slot] = time; ++ this.values[slot] = 0; ++ } ++ this.values[slot] += value; ++ } ++ total(now: number): number { ++ let result = 0; ++ for (let i = 0; i < 1001; i++) ++ if (this.times[i] > Math.floor(now) - 1000 && this.times[i] <= Math.floor(now)) result += this.values[i]; ++ return result; ++ } ++ clear(): void { this.times.fill(-Infinity); this.values.fill(0); } ++} +diff --git a/src/telemetry/BoundedHistory.ts b/src/telemetry/BoundedHistory.ts +new file mode 100644 +index 0000000..72fd21a +--- /dev/null ++++ b/src/telemetry/BoundedHistory.ts +@@ -0,0 +1,34 @@ ++/** Source-owned ordered retention. Eviction is explicit; snapshots cannot mutate it. */ ++export class BoundedHistory { ++ private slots: T[] = []; ++ private first = 0; ++ received = 0; ++ evicted = 0; ++ ++ constructor(readonly capacity: number) { ++ if (!Number.isInteger(capacity) || capacity < 1) throw new Error("Invalid history capacity"); ++ } ++ ++ append(value: T): void { ++ this.received++; ++ if (this.slots.length < this.capacity) this.slots.push(value); ++ else { ++ this.slots[this.first] = value; ++ this.first = (this.first + 1) % this.capacity; ++ this.evicted++; ++ } ++ } ++ ++ snapshot(): T[] { ++ return this.slots.slice(this.first).concat(this.slots.slice(0, this.first)); ++ } ++ ++ clear(): void { ++ this.slots = []; ++ this.first = 0; ++ this.received = 0; ++ this.evicted = 0; ++ } ++ ++ get size(): number { return this.slots.length; } ++} +diff --git a/src/telemetry/ConsoleHistory.ts b/src/telemetry/ConsoleHistory.ts +new file mode 100644 +index 0000000..a3754fc +--- /dev/null ++++ b/src/telemetry/ConsoleHistory.ts +@@ -0,0 +1,132 @@ ++// Console retention is defined by complete wire frames and byte offsets, not UI ticks. ++const UBX_NAMES: Record = { ++ "01:07": "NAV-PVT", ++ "01:35": "NAV-SAT", ++ "01:04": "NAV-DOP", ++ "01:03": "NAV-STATUS", ++ "0a:09": "MON-HW", ++ "0a:37": "MON-HW3", ++ "0a:38": "MON-RF", ++ "05:01": "ACK-ACK", ++ "05:00": "ACK-NAK", ++}; ++ ++function ubxName(cls: number, id: number): string { ++ const key = `${cls.toString(16).padStart(2, "0")}:${id.toString(16).padStart(2, "0")}`; ++ return UBX_NAMES[key] ?? `UBX-${key.toUpperCase()}`; ++} ++ ++function formatHexDump(chunk: Buffer, offset: number): string { ++ let result = ""; ++ for (let i = 0; i < chunk.length; i += 16) { ++ const addr = (offset + i).toString(16).padStart(8, "0"); ++ const bytes = chunk.subarray(i, Math.min(i + 16, chunk.length)); ++ ++ let hex = ""; ++ let ascii = ""; ++ for (let j = 0; j < 16; j++) { ++ if (j === 8) hex += " "; ++ if (j < bytes.length) { ++ hex += bytes[j].toString(16).padStart(2, "0") + " "; ++ ascii += bytes[j] >= 0x20 && bytes[j] <= 0x7e ? String.fromCharCode(bytes[j]) : "."; ++ } else { ++ hex += " "; ++ ascii += " "; ++ } ++ } ++ ++ result += `${addr} ${hex} |${ascii}|\n`; ++ } ++ return result; ++} ++ ++export class ConsoleHistory { ++ private frameBuffer: Buffer = Buffer.alloc(0); ++ offset = 0; ++ text = ""; ++ generation = 0; ++ receivedBytes = 0; ++ evictedCodeUnits = 0; ++ private appendText(text: string) { ++ this.text += text; ++ if (this.text.length > 65536) { ++ this.evictedCodeUnits += this.text.length - 32768; ++ this.text = this.text.slice(-32768); ++ this.generation++; ++ } ++ } ++ ingest(chunk: Buffer) { ++ this.receivedBytes += chunk.length; ++ this.frameBuffer = Buffer.concat([this.frameBuffer, chunk]); ++ const buf = this.frameBuffer; ++ let pos = 0; ++ ++ while (pos < buf.length) { ++ // Find next UBX sync (0xB5 0x62) ++ let syncPos = -1; ++ for (let i = pos; i < buf.length - 1; i++) { ++ if (buf[i] === 0xb5 && buf[i + 1] === 0x62) { ++ syncPos = i; ++ break; ++ } ++ } ++ ++ if (syncPos === -1) { ++ // No sync found — dump remaining, keep last byte if it could be start of sync ++ const keep = buf[buf.length - 1] === 0xb5 ? 1 : 0; ++ if (buf.length - keep > pos) { ++ this.appendText( ++ formatHexDump(buf.subarray(pos, buf.length - keep), this.offset), ++ ); ++ this.offset += buf.length - keep - pos; ++ } ++ pos = buf.length - keep; ++ break; ++ } ++ ++ // Dump non-UBX bytes before sync ++ if (syncPos > pos) { ++ this.appendText( ++ formatHexDump(buf.subarray(pos, syncPos), this.offset), ++ ); ++ this.offset += syncPos - pos; ++ pos = syncPos; ++ } ++ ++ // Need at least 6 bytes for header ++ if (pos + 6 > buf.length) break; ++ ++ const cls = buf[pos + 2]; ++ const id = buf[pos + 3]; ++ const payloadLen = buf[pos + 4] | (buf[pos + 5] << 8); ++ const frameLen = payloadLen + 8; ++ ++ // Need complete frame ++ if (pos + frameLen > buf.length) break; ++ ++ // Complete frame — print separator header + hex dump ++ const name = ubxName(cls, id); ++ const separator = `\n──── ${name} [${cls.toString(16).padStart(2, "0")}:${id.toString(16).padStart(2, "0")}] ${payloadLen} bytes ────\n`; ++ const frame = buf.subarray(pos, pos + frameLen); ++ this.appendText( ++ separator + formatHexDump(frame, this.offset), ++ ); ++ this.offset += frameLen; ++ pos += frameLen; ++ } ++ ++ // Save unprocessed bytes ++ this.frameBuffer = ++ pos < buf.length ? Buffer.from(buf.subarray(pos)) : Buffer.alloc(0); ++ } ++ snapshot() { return { text: this.text, generation: this.generation }; } ++ get pendingBytes() { return this.frameBuffer.length; } ++ reset() { ++ this.frameBuffer = Buffer.alloc(0); ++ this.offset = 0; ++ this.text = ""; ++ this.receivedBytes = 0; ++ this.evictedCodeUnits = 0; ++ this.generation++; ++ } ++} +diff --git a/src/telemetry/HardwareHistory.ts b/src/telemetry/HardwareHistory.ts +new file mode 100644 +index 0000000..a88279a +--- /dev/null ++++ b/src/telemetry/HardwareHistory.ts +@@ -0,0 +1,59 @@ ++import type { MonHw, MonHw3, MonRf } from "ubx-parser"; ++import { BoundedHistory } from "./BoundedHistory"; ++ ++export interface RfStatusData { ++ noisePerMS: number; ++ agcCnt: number; ++ aStatus: number; ++ aPower: number; ++ jammingState: number; ++ jamInd: number; ++ rtcCalib?: boolean; ++ safeBoot?: boolean; ++ xtalAbsent?: boolean; ++} ++ ++export interface HwStatusData { ++ hwVersion: string; ++ nPins: number; ++ rtcCalib: boolean; ++ safeBoot: boolean; ++ xtalAbsent: boolean; ++} ++ ++export class HardwareHistory { ++ rf: RfStatusData | null = null; ++ hw: HwStatusData | null = null; ++ readonly jam = new BoundedHistory<{ sequence: number; x: number; y: number }>(3000); ++ private start: number | null = null; ++ private sequence = 0; ++ epoch = 0; ++ ++ ingestHw(msg: MonHw, now: number): boolean { ++ if ("variant" in msg) return false; ++ this.rf = { noisePerMS: msg.noisePerMS, agcCnt: msg.agcCnt, aStatus: msg.aStatus, ++ aPower: msg.aPower, jammingState: msg.flags.jammingState, jamInd: msg.jamInd, ++ rtcCalib: msg.flags.rtcCalib, safeBoot: msg.flags.safeBoot, xtalAbsent: msg.flags.xtalAbsent }; ++ if (this.start === null) this.start = now; ++ this.jam.append({ sequence: ++this.sequence, x: (now - this.start) / 1000, y: msg.jamInd }); ++ return true; ++ } ++ ingestRf(msg: MonRf): boolean { ++ if ("variant" in msg || !msg.list?.length) return false; ++ const rf = msg.list[0]; ++ this.rf = { noisePerMS: rf.noisePerMS, agcCnt: rf.agcCnt, aStatus: rf.antStatus, ++ aPower: rf.antPower, jammingState: rf.flags.jammingState, jamInd: rf.jamInd }; ++ return true; ++ } ++ ingestHw3(msg: MonHw3): boolean { ++ if ("variant" in msg) return false; ++ this.hw = { hwVersion: msg.hwVersion, nPins: msg.nPins, rtcCalib: msg.flags.rctCalib, ++ safeBoot: msg.flags.safeBoot, xtalAbsent: msg.flags.xtalAbsent }; ++ return true; ++ } ++ snapshot() { return { rf: this.rf, hw: this.hw, jam: this.jam.snapshot(), epoch: this.epoch }; } ++ reset() { ++ this.rf = null; this.hw = null; this.start = null; ++ this.jam.clear(); this.epoch++; ++ } ++} +diff --git a/src/telemetry/MessageHistory.ts b/src/telemetry/MessageHistory.ts +new file mode 100644 +index 0000000..93ad20a +--- /dev/null ++++ b/src/telemetry/MessageHistory.ts +@@ -0,0 +1,43 @@ ++import type { UbxMessage } from "ubx-parser"; ++import { BoundedHistory } from "./BoundedHistory"; ++ ++export interface MessageRow { ++ timestamp: string; ++ name: string; ++ length: string; ++ // Arrival ordinal within the connection. The ordinary table keeps its three ++ // visible columns; this identity makes retained-source accounting unambiguous. ++ sequence: number; ++} ++interface Entry { message: UbxMessage; time: number; sequence: number; row?: MessageRow; } ++ ++export class MessageHistory { ++ private entries = new BoundedHistory(500); ++ private formatted = 0; ++ ingest(message: UbxMessage, time: number): void { ++ this.entries.append({ message, time, sequence: this.entries.received + 1 }); ++ } ++ snapshot(): MessageRow[] { ++ return this.entries.snapshot().map(entry => { ++ if (!entry.row) { ++ const date = new Date(entry.time); ++ entry.row = { ++ timestamp: [date.getHours(), date.getMinutes(), date.getSeconds()] ++ .map(value => String(value).padStart(2, "0")).join(":") ++ + "." + String(date.getMilliseconds()).padStart(3, "0"), ++ name: entry.message.name, length: String(JSON.stringify(entry.message).length), sequence: entry.sequence, ++ }; ++ this.formatted++; ++ } ++ return entry.row; ++ }); ++ } ++ get received() { return this.entries.received; } ++ get evicted() { return this.entries.evicted; } ++ getDiagnostics() { ++ const retainedFormatted = this.entries.snapshot().filter(entry => entry.row !== undefined).length; ++ return { received: this.received, retained: this.entries.size, evicted: this.evicted, ++ formatted: this.formatted, evictedBeforeFormatting: this.evicted - (this.formatted - retainedFormatted) }; ++ } ++ clear(): void { this.entries.clear(); this.formatted = 0; } ++} +diff --git a/src/telemetry/PositionHistory.ts b/src/telemetry/PositionHistory.ts +new file mode 100644 +index 0000000..a42a7de +--- /dev/null ++++ b/src/telemetry/PositionHistory.ts +@@ -0,0 +1,67 @@ ++import type { NavPvtData } from "../hooks/useNavPvt"; ++import { BoundedHistory } from "./BoundedHistory"; ++ ++export interface PositionSample { ++ sequence: number; ++ lat: number; ++ lon: number; ++ hMSL: number; ++ gSpeed: number; ++ hAcc: number; ++ elapsedSec: number; ++} ++ ++/** Ingest before presentation. Reset creates a new epoch, with no old sample replay. */ ++export class PositionHistoryStore { ++ readonly positions = new BoundedHistory(3000); ++ readonly trail = new BoundedHistory(1000); ++ private start: number | null = null; ++ private sequence = 0; ++ resetCounter = 0; ++ received = 0; ++ filtered = 0; ++ ++ ingest(pvt: NavPvtData, now: number): void { ++ this.received++; ++ if (pvt.fixType < 2) { this.filtered++; return; } ++ if (this.start === null) this.start = now; ++ const sample: PositionSample = { sequence: ++this.sequence, lat: pvt.lat, lon: pvt.lon, ++ hMSL: pvt.hMSL, gSpeed: pvt.gSpeed, hAcc: pvt.hAcc, elapsedSec: (now - this.start) / 1000 }; ++ this.positions.append(sample); ++ this.trail.append(sample); ++ } ++ ++ resetPosition(): void { ++ this.positions.clear(); ++ this.start = null; ++ this.resetCounter++; ++ } ++ ++ resetConnection(): void { ++ this.resetPosition(); ++ this.trail.clear(); ++ this.received = 0; ++ this.filtered = 0; ++ } ++ ++ snapshot() { ++ const samples = this.positions.snapshot(); ++ const count = samples.length; ++ const last = samples.at(-1); ++ const meanLat = count ? samples.reduce((sum, s) => sum + s.lat, 0) / count : 0; ++ const meanLon = count ? samples.reduce((sum, s) => sum + s.lon, 0) / count : 0; ++ const cosLat = Math.cos(meanLat * Math.PI / 180); ++ const scatterPoints = samples.map(s => ({ x: (s.lon - meanLon) * cosLat * 111320, ++ y: (s.lat - meanLat) * 111320 })); ++ const distances = scatterPoints.map(p => Math.hypot(p.x, p.y)).sort((a, b) => a - b); ++ return { samples, scatterPoints, sampleCount: count, elapsedSec: last?.elapsedSec ?? 0, ++ resetCounter: this.resetCounter, ++ latestAltitude: last ? { x: last.elapsedSec, y: last.hMSL / 1000 } : null, ++ latestSpeed: last ? { x: last.elapsedSec, y: last.gSpeed * 0.0036 } : null, ++ latestHAcc: last ? { x: last.elapsedSec, y: last.hAcc / 1000 } : null, ++ cepStats: count ? { cep50: distances[Math.floor(count * 0.5)], ++ cep95: distances[Math.floor(count * 0.95)], ++ meanHAcc: samples.reduce((sum, s) => sum + s.hAcc, 0) / count / 1000, ++ sampleCount: count } : null }; ++ } ++} +diff --git a/src/telemetry/PublicationCadence.ts b/src/telemetry/PublicationCadence.ts +new file mode 100644 +index 0000000..7fd5a5c +--- /dev/null ++++ b/src/telemetry/PublicationCadence.ts +@@ -0,0 +1,170 @@ ++export const UI_UPDATE_RATES = [10, 20, 60] as const; ++export type UiUpdateRate = typeof UI_UPDATE_RATES[number]; ++export const validateUiUpdateRate = (value: unknown): UiUpdateRate => ++ value === 10 || value === 20 || value === 60 ? value : 20; ++ ++export interface CadenceClock { ++ now(): number; ++ schedule(callback: () => void, delayMs: number): unknown; ++ cancel(handle: unknown): void; ++} ++ ++const realClock: CadenceClock = { ++ now: () => performance.now(), ++ schedule: (callback, delay) => setTimeout(callback, delay), ++ cancel: (handle) => clearTimeout(handle as ReturnType), ++}; ++ ++export interface PublicationObservation { ++ label: string; ++ flush: number; ++ requestedAt: number; ++ firstPendingAt: number; ++ startedAt: number; ++ finishedAt: number; ++ sourceSample?: number; ++ coalesced: number; ++} ++interface Owner { ++ publish: () => void; ++ label: string; ++ requests: number; ++ coalesced: number; ++ publications: number; ++ cancelled: number; ++ firstPendingAt: number; ++ requestedAt: number; ++ sourceSample?: number; ++ pendingRequests: number; ++} ++ ++/** One application timer; one pending bit per registered presentation owner. ++ * Source data belongs to the owners, never to this queue. A late tick flushes ++ * once, anchored to actual execution time. The drained queue owns no timer. ++ */ ++export class PublicationCadence { ++ private owners = new Map(); ++ private observer: ((event: PublicationObservation) => void) | null = null; ++ private unpaced = false; ++ private pending = new Set(); ++ private timer: unknown = null; ++ private generation = 0; ++ private lastFlush = -Infinity; ++ private disposed = false; ++ private rate: UiUpdateRate; ++ private requests = 0; ++ private coalesced = 0; ++ private flushes = 0; ++ private publications = 0; ++ private cancelled = 0; ++ ++ constructor(rate: unknown = 20, private clock: CadenceClock = realClock) { ++ this.rate = validateUiUpdateRate(rate); ++ } ++ ++ register(publish: () => void, label = "presentation") { ++ if (this.disposed) throw new Error("Publication cadence is disposed"); ++ const key = Symbol(); ++ const owner: Owner = { publish, label, requests: 0, coalesced: 0, publications: 0, ++ cancelled: 0, firstPendingAt: 0, requestedAt: 0, pendingRequests: 0 }; ++ this.owners.set(key, owner); ++ return { ++ request: (sourceSample?: number) => { ++ if (this.disposed || !this.owners.has(key)) return; ++ this.requests++; owner.requests++; ++ if (this.pending.has(key)) { this.coalesced++; owner.coalesced++; } ++ else { owner.firstPendingAt = this.clock.now(); owner.pendingRequests = 0; } ++ owner.requestedAt = this.clock.now(); owner.sourceSample = sourceSample; owner.pendingRequests++; ++ this.pending.add(key); ++ if (this.unpaced) { this.flushes++; this.publish(key); } ++ else this.schedule(); ++ }, ++ cancel: () => { ++ if (this.pending.delete(key)) { this.cancelled++; owner.cancelled++; } ++ if (!this.pending.size) this.cancelTimer(); ++ }, ++ dispose: () => { ++ if (this.pending.delete(key)) { this.cancelled++; owner.cancelled++; } ++ this.owners.delete(key); ++ if (!this.pending.size) this.cancelTimer(); ++ }, ++ }; ++ } ++ ++ setRate(value: unknown): void { ++ const rate = validateUiUpdateRate(value); ++ if (this.disposed || this.rate === rate) return; ++ this.rate = rate; ++ this.cancelTimer(); ++ this.schedule(); ++ } ++ ++ private cancelTimer(): void { ++ ++this.generation; ++ if (this.timer !== null) this.clock.cancel(this.timer); ++ this.timer = null; ++ } ++ ++ private schedule(): void { ++ if (this.disposed || this.timer !== null || !this.pending.size) return; ++ const generation = this.generation; ++ const delay = Math.max(0, this.lastFlush + 1000 / this.rate - this.clock.now()); ++ this.timer = this.clock.schedule(() => { ++ if (this.disposed || generation !== this.generation) return; ++ this.timer = null; ++ // Timers can wake early; retain the lower-bound cadence guarantee. ++ if (this.clock.now() < this.lastFlush + 1000 / this.rate) { ++ this.schedule(); ++ return; ++ } ++ const batch = [...this.pending]; ++ this.lastFlush = this.clock.now(); ++ this.flushes++; ++ for (const key of batch) { ++ this.publish(key); ++ } ++ this.schedule(); ++ }, delay); ++ } ++ ++ private publish(key: symbol): void { ++ const owner = this.owners.get(key); ++ if (!owner || !this.pending.delete(key)) return; ++ this.publications++; owner.publications++; ++ const observer = this.observer; ++ const event = observer ? { label: owner.label, flush: this.flushes, ++ requestedAt: owner.requestedAt, firstPendingAt: owner.firstPendingAt, ++ startedAt: this.clock.now(), finishedAt: 0, sourceSample: owner.sourceSample, ++ coalesced: owner.pendingRequests - 1 } : null; ++ owner.publish(); ++ if (event) { event.finishedAt = this.clock.now(); observer!(event); } ++ } ++ ++ // Diagnostic-only retention-correct control; not a persisted product setting. ++ // Select before App mount so a comparison cannot change policy mid-session. ++ setUnpacedForComparison(enabled: boolean): void { ++ if (this.owners.size || this.disposed) throw new Error("Select comparison policy before mounting telemetry"); ++ this.unpaced = enabled; ++ } ++ observe(observer: ((event: PublicationObservation) => void) | null): void { ++ this.observer = observer; ++ } ++ ++ getDiagnostics() { ++ return { rate: this.rate, unpaced: this.unpaced, observer: this.observer !== null, owners: this.owners.size, pending: this.pending.size, ++ timers: this.timer === null ? 0 : 1, requests: this.requests, coalesced: this.coalesced, ++ flushes: this.flushes, publications: this.publications, cancelled: this.cancelled, ++ disposed: this.disposed, ownerCounts: [...this.owners.values()].map(owner => ({ ++ label: owner.label, requests: owner.requests, coalesced: owner.coalesced, ++ publications: owner.publications, cancelled: owner.cancelled })) }; ++ } ++ ++ dispose(): void { ++ this.disposed = true; ++ this.observer = null; ++ this.cancelled += this.pending.size; ++ this.pending.clear(); ++ this.owners.clear(); ++ this.cancelTimer(); ++ } ++} +diff --git a/src/telemetry/connectionStatus.ts b/src/telemetry/connectionStatus.ts +new file mode 100644 +index 0000000..4b3d782 +--- /dev/null ++++ b/src/telemetry/connectionStatus.ts +@@ -0,0 +1,18 @@ ++import { serialManager } from "../connection"; ++import type { ConnectionStatus } from "../connection"; ++ ++// One connection listener for all presentation owners, removed with the last ++// subscription. Controls still receive the original event synchronously. ++const listeners = new Set<(status: ConnectionStatus) => void>(); ++const dispatch = (status: ConnectionStatus) => { ++ for (const listener of [...listeners]) if (listeners.has(listener)) listener(status); ++}; ++export function onPresentationStatus(listener: (status: ConnectionStatus) => void) { ++ if (!listeners.size) serialManager.on("status", dispatch); ++ listeners.add(listener); ++ return () => { ++ listeners.delete(listener); ++ if (!listeners.size) serialManager.off("status", dispatch); ++ }; ++} ++export const presentationStatusSubscriptions = () => listeners.size; +diff --git a/src/telemetry/presentation.ts b/src/telemetry/presentation.ts +new file mode 100644 +index 0000000..88b4926 +--- /dev/null ++++ b/src/telemetry/presentation.ts +@@ -0,0 +1,12 @@ ++import { getConfig, updateConfig } from "../connection/config"; ++import { PublicationCadence, validateUiUpdateRate } from "./PublicationCadence"; ++ ++// Every presentation subscription shares this queue. Its lifetime contains no ++// timer or listener when the App has released its registrations. ++export const presentation = new PublicationCadence(getConfig().uiUpdateRate); ++ ++export function setUiUpdateRate(value: unknown): void { ++ const rate = validateUiUpdateRate(value); ++ updateConfig({ uiUpdateRate: rate }); ++ presentation.setRate(rate); ++} +diff --git a/tests/config.ts b/tests/config.ts +new file mode 100644 +index 0000000..de51c5d +--- /dev/null ++++ b/tests/config.ts +@@ -0,0 +1,39 @@ ++import assert from "node:assert/strict"; ++import { spawnSync } from "node:child_process"; ++import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { basename, dirname, join, resolve } from "node:path"; ++import { fileURLToPath, pathToFileURL } from "node:url"; ++ ++const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); ++const scratch = mkdtempSync(join(tmpdir(), "ubx-pacing-config-")); ++const configModule = pathToFileURL(resolve(root, "src/connection/config.ts")).href; ++const loader = pathToFileURL(resolve(root, "node_modules/tsx/dist/loader.mjs")).href; ++function run(update = "") { ++ const result = spawnSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", ++ `import { getConfig, updateConfig } from ${JSON.stringify(configModule)}; ${update}; console.log(JSON.stringify(getConfig()));`], ++ { cwd: scratch, encoding: "utf8", windowsHide: true }); ++ assert.equal(result.status, 0, result.stderr); ++ return JSON.parse(result.stdout); ++} ++try { ++ assert.equal(run().uiUpdateRate, 20, "missing configuration defaults to 20"); ++ for (const invalid of [null, [], "invalid", { uiUpdateRate: "60" }, { uiUpdateRate: 0 }, { uiUpdateRate: 30 }]) { ++ writeFileSync(join(scratch, "config.json"), JSON.stringify(invalid)); ++ assert.equal(run().uiUpdateRate, 20); ++ } ++ writeFileSync(join(scratch, "config.json"), JSON.stringify({ portPath: "SYNTHETIC", baudRate: 38400 })); ++ for (const rate of [10, 60, 20]) { ++ assert.equal(run(`updateConfig({ uiUpdateRate: ${rate} })`).uiUpdateRate, rate); ++ assert.equal(JSON.parse(readFileSync(join(scratch, "config.json"), "utf8")).uiUpdateRate, rate); ++ const restarted = run(); ++ assert.equal(restarted.uiUpdateRate, rate, "ordinary exit persists the setting across processes"); ++ assert.equal(restarted.baudRate, 38400); ++ assert.equal(restarted.portPath, "SYNTHETIC"); ++ } ++ console.log("Configuration: invalid/default rates, all choices persisted across exit/restart, receiver settings preserved"); ++} finally { ++ assert.equal(dirname(resolve(scratch)), resolve(tmpdir())); ++ assert.ok(basename(scratch).startsWith("ubx-pacing-config-")); ++ rmSync(scratch, { recursive: true }); ++} +diff --git a/tests/fixtures/ubx-bytes.ts b/tests/fixtures/ubx-bytes.ts +new file mode 100644 +index 0000000..95519a2 +--- /dev/null ++++ b/tests/fixtures/ubx-bytes.ts +@@ -0,0 +1,41 @@ ++/** Deterministic UBX wire packets. Positions are synthetic, never receiver logs. */ ++export function ubxPacket(messageId: number, payload: Buffer, messageClass = 1): Buffer { ++ const packet = Buffer.alloc(payload.length + 8); ++ packet.set([0xb5, 0x62, messageClass, messageId]); ++ packet.writeUInt16LE(payload.length, 4); ++ packet.set(payload, 6); ++ let a = 0, b = 0; ++ for (const byte of packet.subarray(2, -2)) { a = (a + byte) & 255; b = (b + a) & 255; } ++ packet[packet.length - 2] = a; packet[packet.length - 1] = b; ++ return packet; ++} ++export const cnoValues = (sequence: number) => [10 + sequence % 10, 20 + Math.floor(sequence / 10) % 10, ++ 30 + Math.floor(sequence / 100) % 10, 40 + Math.floor(sequence / 1000) % 10]; ++export function navSatPacket(sequence: number) { ++ const payload = Buffer.alloc(8 + 4 * 12); ++ payload.writeUInt32LE(sequence * 50, 0); payload[4] = 1; payload[5] = 4; ++ const cno = cnoValues(sequence); ++ for (let i = 0; i < 4; ++i) { ++ const offset = 8 + i * 12; ++ payload[offset] = [0, 2, 3, 6][i]; payload[offset + 1] = i + 1; ++ payload[offset + 2] = cno[i]; payload[offset + 3] = 30 + i * 10; ++ payload.writeInt16LE(10 + i * 90 + sequence % 15, offset + 4); ++ payload.writeUInt32LE(8, offset + 8); ++ } ++ return ubxPacket(0x35, payload); ++} ++export function navPvtPacket(sequence: number) { ++ const payload = Buffer.alloc(92); ++ payload.writeUInt32LE(sequence * 50, 0); ++ payload.writeUInt16LE(2026, 4); payload[6] = 9; payload[7] = 11; ++ payload[8] = 12; payload[9] = Math.floor(sequence / 1200) % 60; payload[10] = Math.floor(sequence / 20) % 60; ++ payload[11] = 7; payload.writeUInt32LE(100, 12); ++ payload[20] = 3; payload[21] = 1; payload[23] = 4; ++ payload.writeInt32LE(-1200000 + sequence * 10, 24); ++ payload.writeInt32LE(515000000 + (sequence % 100) * 10, 28); ++ payload.writeInt32LE(80000 + sequence, 32); payload.writeInt32LE(50000 + sequence, 36); ++ payload.writeUInt32LE(30000, 40); payload.writeUInt32LE(40000, 44); ++ payload.writeInt32LE(1000 + sequence, 60); payload.writeInt32LE(9000000, 64); ++ payload.writeUInt32LE(100, 68); payload.writeUInt32LE(1000, 72); payload.writeUInt16LE(120, 76); ++ return ubxPacket(7, payload); ++} +diff --git a/tests/pacing.ts b/tests/pacing.ts +new file mode 100644 +index 0000000..f4f9290 +--- /dev/null ++++ b/tests/pacing.ts +@@ -0,0 +1,225 @@ ++import assert from "node:assert/strict"; ++import { PublicationCadence, validateUiUpdateRate, type CadenceClock } from "../src/telemetry/PublicationCadence"; ++import { BoundedHistory } from "../src/telemetry/BoundedHistory"; ++import { PositionHistoryStore } from "../src/telemetry/PositionHistory"; ++import type { NavPvtData } from "../src/hooks/useNavPvt"; ++import { ArrivalWindow } from "../src/telemetry/ArrivalWindow"; ++import { ConsoleHistory } from "../src/telemetry/ConsoleHistory"; ++import type { PublicationObservation } from "../src/telemetry/PublicationCadence"; ++ ++class Clock implements CadenceClock { ++ time = 0; ++ next = 0; ++ jobs = new Map void }>(); ++ now = () => this.time; ++ schedule(callback: () => void, delay: number) { ++ const id = ++this.next; ++ this.jobs.set(id, { at: this.time + delay, callback }); ++ assert.ok(this.jobs.size <= 1, "shared cadence owns at most one timer"); ++ return id; ++ } ++ cancel(id: unknown) { this.jobs.delete(id as number); } ++ advance(to: number) { ++ while (true) { ++ const next = [...this.jobs].sort((a, b) => a[1].at - b[1].at)[0]; ++ if (!next || next[1].at > to) break; ++ this.time = next[1].at; ++ this.jobs.delete(next[0]); ++ next[1].callback(); ++ } ++ this.time = to; ++ } ++} ++ ++for (const value of [undefined, null, "10", 0, -1, 30, NaN, Infinity, {}, []]) ++ assert.equal(validateUiUpdateRate(value), 20); ++ ++for (const rate of [10, 20, 60] as const) { ++ const clock = new Clock(); ++ const cadence = new PublicationCadence(rate, clock); ++ let source = 0; ++ const observed: { time: number; sample: number }[] = []; ++ const owner = cadence.register(() => observed.push({ time: clock.now(), sample: source })); ++ clock.advance(1000); ++ assert.equal(clock.jobs.size, 0, "no idle pacing"); ++ for (let i = 0; i <= 240; i++) { ++ clock.advance(1000 + i * 1000 / 240); ++ source = i; ++ owner.request(); ++ } ++ assert.ok(observed.length <= 1 + rate); ++ assert.ok(observed.length >= rate, "continuous source must actually publish"); ++ clock.advance(2200); ++ assert.equal(observed.at(-1)!.sample, 240, "trailing sample requires no new input"); ++ for (let i = 1; i < observed.length; i++) ++ assert.ok(observed[i].time - observed[i - 1].time >= 1000 / rate - 1e-9); ++ assert.equal(clock.jobs.size, 0); ++ const d = cadence.getDiagnostics(); ++ assert.equal(d.requests, d.coalesced + d.publications); ++ owner.dispose(); ++ assert.equal(cadence.getDiagnostics().owners, 0); ++} ++ ++{ ++ const clock = new Clock(); ++ const cadence = new PublicationCadence(20, clock); ++ const seen: number[] = []; ++ const a = cadence.register(() => seen.push(clock.now())); ++ const b = cadence.register(() => seen.push(-clock.now())); ++ a.request(); b.request(); clock.advance(0); ++ assert.deepEqual(seen, [0, -0], "all owners share a tick"); ++ clock.advance(10); a.request(); b.request(); ++ const late = [...clock.jobs.values()][0].callback; ++ cadence.setRate(10); ++ late(); ++ clock.advance(99); assert.equal(seen.length, 2); ++ clock.advance(100); assert.deepEqual(seen.slice(2), [100, -100]); ++ clock.advance(110); a.request(); cadence.setRate(60); ++ clock.advance(117); assert.equal(seen.at(-1), 100 + 1000 / 60); ++ a.request(); b.request(); ++ // A blocked event loop runs the due timer once, without replaying missed ticks. ++ clock.time = 1000; ++ const delayed = [...clock.jobs.entries()][0]; ++ clock.jobs.delete(delayed[0]); delayed[1].callback(); ++ assert.equal(seen.at(-1), -1000); ++ assert.equal(clock.jobs.size, 0); ++ a.request(); b.request(); ++ const afterDispose = [...clock.jobs.values()][0].callback; ++ const count = seen.length; ++ a.dispose(); b.dispose(); ++ afterDispose(); a.request(); b.request(); ++ assert.equal(seen.length, count); ++ assert.equal(clock.jobs.size, 0); ++ const fresh = cadence.register(() => seen.push(9999)); ++ fresh.request(); fresh.cancel(); clock.advance(2000); ++ assert.equal(seen.length, count, "reset cancels pre-reset work"); ++ fresh.request(); ++ const afterTerminal = [...clock.jobs.values()][0].callback; ++ cadence.dispose(); afterTerminal(); fresh.request(); ++ assert.equal(seen.length, count); ++ assert.equal(cadence.getDiagnostics().owners, 0); ++ assert.equal(clock.jobs.size, 0); ++} ++ ++{ ++ const history = new BoundedHistory(500); ++ for (let i = 1; i <= 7200; i++) history.append(i); ++ assert.deepEqual(history.snapshot(), Array.from({ length: 500 }, (_, i) => 6701 + i)); ++ assert.equal(history.received, 7200); ++ assert.equal(history.evicted, 6700); ++ history.snapshot().push(-1); ++ assert.equal(history.size, 500); ++ history.clear(); history.append(7201); ++ assert.deepEqual(history.snapshot(), [7201]); ++} ++ ++{ ++ const history = new PositionHistoryStore(); ++ const clock = new Clock(); ++ const cadence = new PublicationCadence(10, clock); ++ let snapshot = history.snapshot(); ++ const owner = cadence.register(() => { snapshot = history.snapshot(); }); ++ const packet = (i: number, fixType = 3): NavPvtData => ({ fixType, lat: 0, ++ lon: i / 111320, hMSL: i * 1000, hAcc: i * 1000, gSpeed: i, ++ height: 0, vAcc: 0, numSV: 4, year: 2026, month: 9, day: 11, ++ hour: 0, min: 0, sec: 0, tAcc: 0, heading: 0 }); ++ for (let i = 1; i <= 7200; i++) { ++ history.ingest(packet(i), i); ++ owner.request(); ++ } ++ history.ingest(packet(9999, 1), 7201); ++ assert.equal(snapshot.sampleCount, 0, "ingestion does not publish or derive snapshots"); ++ clock.advance(0); ++ assert.deepEqual(snapshot.samples.map(s => s.sequence), Array.from({ length: 3000 }, (_, i) => 4201 + i)); ++ assert.deepEqual(history.trail.snapshot().map(s => s.sequence), Array.from({ length: 1000 }, (_, i) => 6201 + i)); ++ assert.equal(history.positions.evicted, 4200); ++ assert.equal(history.trail.evicted, 6200); ++ assert.equal(history.received, 7201); ++ assert.equal(history.filtered, 1); ++ assert.equal(snapshot.latestAltitude!.y, 7200); ++ assert.equal(snapshot.latestSpeed!.y, 7200 * 0.0036); ++ assert.equal(snapshot.latestHAcc!.y, 7200); ++ assert.equal(snapshot.cepStats!.meanHAcc, 5700.5); ++ assert.ok(Math.abs(snapshot.cepStats!.cep50 - 750.5) < 1e-8); ++ assert.ok(Math.abs(snapshot.cepStats!.cep95 - 1425.5) < 1e-8); ++ assert.equal(cadence.getDiagnostics().publications, 1); ++ assert.equal(cadence.getDiagnostics().coalesced, 7199); ++ history.ingest(packet(7201), 7300); owner.request(); ++ owner.cancel(); history.resetPosition(); snapshot = history.snapshot(); ++ clock.advance(200); ++ assert.equal(snapshot.sampleCount, 0); ++ assert.equal(history.trail.size, 1000, "Position reset preserves independent map trail"); ++ history.ingest(packet(7202), 8000); owner.request(); clock.advance(201); ++ assert.equal(snapshot.sampleCount, 1); ++ assert.equal(snapshot.elapsedSec, 0); ++ assert.equal(snapshot.latestAltitude!.y, 7202); ++ history.resetConnection(); owner.cancel(); ++ assert.equal(history.positions.size, 0); ++ assert.equal(history.trail.size, 0); ++ owner.dispose(); ++} ++ ++{ ++ const window = new ArrivalWindow(); ++ for (let i = 0; i < 10000; i++) window.add(0, 164); ++ window.add(999, 7); ++ assert.equal(window.total(999), 1640007); ++ assert.equal(window.total(1000), 7); ++ window.add(1001, 11); ++ assert.equal(window.total(1001), 18); ++ assert.equal(window.total(2001), 0); ++ window.add(10000, 9); window.clear(); ++ assert.equal(window.total(10000), 0); ++} ++ ++{ ++ const console = new ConsoleHistory(); ++ const ack = Buffer.from([0xb5, 0x62, 5, 1, 2, 0, 1, 7, 16, 52]); ++ let expected = "", evicted = 0; ++ for (let i = 0; i < 1000; i++) { ++ // Independent literal rendering oracle for this known frame. ++ const line = (i * 10).toString(16).padStart(8, "0") ++ + " b5 62 05 01 02 00 01 07 10 34 |.b.......4 |\n"; ++ expected += "\n──── ACK-ACK [05:01] 2 bytes ────\n" + line; ++ if (expected.length > 65536) { evicted += expected.length - 32768; expected = expected.slice(-32768); } ++ console.ingest(ack.subarray(0, 3)); ++ assert.equal(console.pendingBytes, 3); ++ console.ingest(ack.subarray(3)); ++ assert.equal(console.pendingBytes, 0); ++ } ++ assert.equal(console.text, expected); ++ assert.equal(console.evictedCodeUnits, evicted); ++ assert.equal(console.offset, 10000); ++ assert.equal(console.receivedBytes, 10000); ++ console.ingest(ack.subarray(0, 5)); console.reset(); ++ console.ingest(ack); ++ assert.equal(console.offset, 10); ++ assert.equal(console.pendingBytes, 0); ++ assert.ok(console.text.includes("00000000 b5 62")); ++} ++ ++{ ++ const clock = new Clock(); ++ const cadence = new PublicationCadence(20, clock); ++ const events: PublicationObservation[] = []; ++ cadence.observe(event => events.push(event)); ++ const owner = cadence.register(() => { clock.time += 2; }, "NAV-PVT"); ++ owner.request(50); owner.request(100); clock.advance(0); ++ assert.equal(events.length, 1); ++ assert.equal(events[0].sourceSample, 100); ++ assert.equal(events[0].coalesced, 1); ++ assert.equal(events[0].finishedAt - events[0].startedAt, 2); ++ assert.throws(() => cadence.setUnpacedForComparison(true)); ++ owner.dispose(); cadence.observe(null); ++ cadence.setUnpacedForComparison(true); ++ let publishes = 0; ++ const control = cadence.register(() => publishes++, "control"); ++ for (let i = 0; i < 7200; i++) control.request(i); ++ assert.equal(publishes, 7200); ++ assert.equal(clock.jobs.size, 0); ++ assert.equal(cadence.getDiagnostics().ownerCounts[0].coalesced, 0); ++ control.dispose(); ++ assert.equal(cadence.getDiagnostics().owners, 0); ++} ++ ++console.log("Pacing and retention: all deterministic cadence, lifecycle, exact-tail and derived-statistics checks passed"); +diff --git a/tests/parser-retention.ts b/tests/parser-retention.ts +new file mode 100644 +index 0000000..7b60cb9 +--- /dev/null ++++ b/tests/parser-retention.ts +@@ -0,0 +1,106 @@ ++import assert from "node:assert/strict"; ++import { EventEmitter } from "node:events"; ++import type { NavPvt, NavSat, MonHw } from "ubx-parser"; ++import { SerialManager } from "../src/connection/SerialManager"; ++import { PositionHistoryStore } from "../src/telemetry/PositionHistory"; ++import { ConsoleHistory } from "../src/telemetry/ConsoleHistory"; ++import { HardwareHistory } from "../src/telemetry/HardwareHistory"; ++import { MessageHistory } from "../src/telemetry/MessageHistory"; ++import { PublicationCadence, type CadenceClock } from "../src/telemetry/PublicationCadence"; ++import { navPvtPacket, navSatPacket } from "./fixtures/ubx-bytes"; ++ ++class Port extends EventEmitter { ++ isOpen = true; ++ close() { this.isOpen = false; } ++ write() { return true; } ++} ++let time = 0, callback: (() => void) | null = null; ++const clock: CadenceClock = { now: () => time, schedule: cb => { ++ assert.equal(callback, null); callback = cb; return cb; ++}, cancel: () => { callback = null; } }; ++const ports: Port[] = []; ++const manager = new SerialManager({ list: async () => [], open: (_options, ready) => { ++ const port = new Port(); ports.push(port); queueMicrotask(() => ready(null)); return port; ++} }); ++const position = new PositionHistoryStore(); ++const consoleHistory = new ConsoleHistory(); ++const rows = new MessageHistory(); ++const cadence = new PublicationCadence(20, clock); ++let published = position.snapshot(), latestSat = -1, latestPvt = -1; ++let satCount = 0, pvtCount = 0, messageCount = 0, rawBytes = 0; ++const owner = cadence.register(() => { published = position.snapshot(); }); ++manager.on("NAV-PVT", (msg: NavPvt) => { ++ assert.equal(msg.iTOW / 50, ++pvtCount); ++ latestPvt = msg.iTOW / 50; position.ingest(msg, time); owner.request(); ++}); ++manager.on("NAV-SAT", (msg: NavSat) => { ++ assert.equal(msg.iTOW / 50, ++satCount); latestSat = msg.iTOW / 50; ++}); ++manager.on("message", msg => { rows.ingest(msg, time); messageCount++; }); ++manager.on("rawdata", chunk => { rawBytes += chunk.length; consoleHistory.ingest(chunk); }); ++manager.connect("fixture", 38400); ++await new Promise(done => queueMicrotask(done)); ++for (let sequence = 1; sequence <= 7200; sequence++) { ++ const sat = navSatPacket(sequence), pvt = navPvtPacket(sequence); ++ if (sequence % 2) { ++ ports[0].emit("data", sat.subarray(0, 9)); ++ ports[0].emit("data", Buffer.concat([sat.subarray(9), pvt])); ++ } else ports[0].emit("data", Buffer.concat([sat, pvt])); ++} ++assert.equal(published.sampleCount, 0); ++assert.equal(satCount, 7200); assert.equal(pvtCount, 7200); assert.equal(messageCount, 14400); ++assert.equal(rawBytes, 7200 * 164); ++assert.equal(consoleHistory.receivedBytes, rawBytes); assert.equal(consoleHistory.offset, rawBytes); ++assert.equal(consoleHistory.pendingBytes, 0); ++assert.deepEqual(rows.snapshot().map(row => [row.name, row.sequence]), ++ Array.from({ length: 500 }, (_, i) => [i % 2 ? "NAV-PVT" : "NAV-SAT", 13901 + i])); ++assert.equal(rows.evicted, 13900); ++assert.equal(rows.getDiagnostics().formatted, 500); ++assert.equal(rows.getDiagnostics().evictedBeforeFormatting, 13900); ++const cachedRows = rows.snapshot(); ++assert.strictEqual(rows.snapshot()[0], cachedRows[0]); ++assert.equal(rows.getDiagnostics().formatted, 500); ++time = 100; const trailing = callback!; callback = null; trailing(); ++assert.equal(published.sampleCount, 3000); ++assert.deepEqual(published.samples.map(s => s.hMSL), Array.from({ length: 3000 }, (_, i) => 54201 + i)); ++assert.deepEqual(position.trail.snapshot().map(s => s.hMSL), Array.from({ length: 1000 }, (_, i) => 56201 + i)); ++assert.equal(published.latestAltitude?.y, 57.2); ++assert.equal(latestPvt, 7200); assert.equal(latestSat, 7200); ++assert.equal(cadence.getDiagnostics().publications, 1); ++assert.equal(cadence.getDiagnostics().coalesced, 7199); ++manager.disconnect(); ++position.resetConnection(); consoleHistory.reset(); rows.clear(); owner.cancel(); ++const before = messageCount; ++ports[0].emit("data", navPvtPacket(7201)); ++assert.equal(messageCount, before); ++assert.equal(position.positions.size, 0); ++assert.equal(consoleHistory.receivedBytes, 0); ++assert.equal(ports[0].eventNames().length, 0); ++manager.connect("fixture", 38400); ++await new Promise(done => queueMicrotask(done)); ++ports[1].emit("data", Buffer.concat([navSatPacket(7201), navPvtPacket(7201)])); ++assert.equal(position.positions.size, 1); ++assert.equal(position.snapshot().latestAltitude?.y, 57.201); ++const late = callback!; ++owner.dispose(); cadence.dispose(); late(); ++assert.equal(published.sampleCount, 3000, "disposed publication cannot overwrite the last delivered view"); ++manager.disconnect(); manager.removeAllListeners(); ++assert.equal(callback, null); ++assert.ok(ports.every(port => !port.isOpen && !port.eventNames().length)); ++ ++const hardware = new HardwareHistory(); ++const hw: MonHw = { name: "MON-HW", pinSel: 0, pinBank: 0, pinDir: 0, pinVal: 0, ++ noisePerMS: 10, agcCnt: 20, aStatus: 2, aPower: 1, ++ flags: { rtcCalib: true, safeBoot: false, jammingState: 1, xtalAbsent: false }, ++ reserved1: 0, usedMask: 0, VP: Buffer.alloc(17), jamInd: 0, reserved2: 0, pinIrq: 0, pullH: 0, pullL: 0 }; ++for (let i = 1; i <= 7200; i++) hardware.ingestHw({ ...hw, jamInd: i % 256 }, i * 10); ++assert.equal(hardware.jam.received, 7200); assert.equal(hardware.jam.evicted, 4200); ++assert.deepEqual(hardware.snapshot().jam.map(s => [s.sequence, s.y]), ++ Array.from({ length: 3000 }, (_, i) => [4201 + i, (4201 + i) % 256])); ++assert.equal(hardware.rf?.jamInd, 7200 % 256); ++hardware.reset(); hardware.ingestHw(hw, 100000); ++assert.equal(hardware.snapshot().jam[0].x, 0); ++assert.equal(hardware.jam.size, 1); ++console.log(JSON.stringify({ status: "passed", decodedPerType: 7201, messageCount, rawBytes, ++ retainedMessageRows: 500, retainedPositions: 3000, retainedTrail: 1000, hardwareRetained: 3000, ++ trailingPublicationsBeforeReconnect: 1, lateDeliveryRejected: true })); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-rate-select.ps1 b/packages/dear-imgui/npm/diagnostics/ubx-rate-select.ps1 new file mode 100644 index 0000000..eacde4a --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-rate-select.ps1 @@ -0,0 +1,16 @@ +param( + [Parameter(Mandatory=$true)][int]$ProcessId, + [Parameter(Mandatory=$true)][ValidateSet(10,20,60)][int]$Rate +) +$ErrorActionPreference = 'Stop' +# One compilation of the existing PID-scoped input helper for this specific +# connected-App Combo gesture. The harness verifies the ordinary callback. +. "$PSScriptRoot/native-window.ps1" -ProcessId $ProcessId -Action move -X 220 -Y 200 +Start-Sleep -Milliseconds 200 +[XFramesFixtureWindow]::Run($ProcessId, 'click', 220, 200, '') +Start-Sleep -Milliseconds 250 +$rateIndex = @(10,20,60).IndexOf($Rate) +$optionY = 224 + $rateIndex * 20 +[XFramesFixtureWindow]::Run($ProcessId, 'move', 180, $optionY, '') +Start-Sleep -Milliseconds 200 +[XFramesFixtureWindow]::Run($ProcessId, 'click', 180, $optionY, '') diff --git a/packages/dear-imgui/npm/diagnostics/ubx-source.test.ts b/packages/dear-imgui/npm/diagnostics/ubx-source.test.ts new file mode 100644 index 0000000..d8367ff --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-source.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { deliverScheduledUbx } from "./ubx-source"; + +async function test() { + for (const hz of [20, 120]) { + let now = 0; + let job: { at: number; callback: () => void } | null = null; + const received: number[] = []; + const source = deliverScheduledUbx(hz * 60, hz, 0, sequence => received.push(sequence), { + now: () => now, + schedule: (callback, delay) => { + assert.equal(job, null); + job = { at: now + Math.max(16, delay), callback }; + }, + }); + while (job) { + const current = job as { at: number; callback: () => void }; job = null; + now = current.at; current.callback(); + } + await source.completion; + assert.deepEqual(received, Array.from({ length: hz * 60 }, (_, index) => index + 1)); + assert.ok(now >= 60000 && now <= 60016, "coarse timers must not halve source rate"); + assert.ok(source.diagnostics().maximumBatch <= 32); + assert.ok(source.deadlineLatenessMs.every(value => value >= -1e-9)); + } + let now = 0, job: (() => void) | null = null; + const source = deliverScheduledUbx(120, 120, 0, () => {}, { now: () => now, + schedule: callback => { assert.equal(job, null); job = callback; } }); + now = 1000; + while (job) { const current = job as () => void; job = null; current(); } + await source.completion; + assert.equal(source.diagnostics().maximumBatch, 32); + assert.equal(source.deliveredAt.length, 120); + console.log("UBX source: exact 20/120-Hz sequences with coarse timers, no early packets and bounded delayed-source batches passed"); +} +test().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/packages/dear-imgui/npm/diagnostics/ubx-source.ts b/packages/dear-imgui/npm/diagnostics/ubx-source.ts new file mode 100644 index 0000000..49eabd1 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/ubx-source.ts @@ -0,0 +1,41 @@ +export interface SourceClock { + now(): number; + schedule(callback: () => void, delayMs: number): unknown; +} + +/** Source deadlines do not depend on UI frames. A coarse/delayed host timer may + * deliver several due wire packets, as a serial callback can. Limit each turn + * to 32 pairs so input debt cannot monopolize the JS loop. UI pacing is separate + * and never catches up missed publication ticks. + */ +export function deliverScheduledUbx(count: number, hz: number, start: number, + send: (sequence: number) => void, clock: SourceClock = { + now: () => performance.now(), schedule: (callback, ms) => setTimeout(callback, ms), + }) { + const deliveredAt: number[] = []; + const deadlineLatenessMs: number[] = []; + let timerCallbacks = 0, maximumBatch = 0; + const completion = new Promise((resolve, reject) => { + let next = 1; + const tick = () => { + timerCallbacks++; + try { + let batch = 0; + const due = Math.min(count, Math.floor((clock.now() - start) * hz / 1000 + 1e-9)); + while (next <= due && batch < 32) { + const receivedAt = clock.now(); + send(next); + deliveredAt.push(clock.now() - start); + deadlineLatenessMs.push(receivedAt - (start + next * 1000 / hz)); + next++; batch++; + } + maximumBatch = Math.max(maximumBatch, batch); + if (next > count) resolve(); + else clock.schedule(tick, Math.max(0, start + next * 1000 / hz - clock.now())); + } catch (error) { reject(error); } + }; + clock.schedule(tick, Math.max(0, start + 1000 / hz - clock.now())); + }); + return { completion, deliveredAt, deadlineLatenessMs, + diagnostics: () => ({ timerCallbacks, maximumBatch, maximumAllowedBatch: 32 }) }; +} From 74970ba30832380d2c4f77e8afe318286a7ad418 Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Fri, 11 Sep 2026 22:52:41 +0200 Subject: [PATCH 5/7] docs: reconcile pacing status and reproduction references --- README.md | 1 + ROADMAP.md | 13 +++++----- .../fabric-invalidation-2026-09.md | 8 ++++++ .../ubx-monitor-pacing-cost-2026-09.md | 8 +++++- .../ubx-monitor-streaming-2026-09.md | 18 +++++++++++-- packages/dear-imgui/npm/diagnostics/README.md | 25 +++++++++++++++++-- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7072ad2..373a1eb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ XFrames is actively maintained. The current focus is technical visualization and - [Project roadmap](ROADMAP.md) - [Engineering and strategy documentation](docs/README.md) +- [ubx-monitor application pacing and validation](docs/engineering/ubx-monitor-streaming-2026-09.md) — persisted display rates, source-owned telemetry histories and a bounded Windows streaming-cost comparison - [XFrames and GPUIX assessment](docs/strategy/gpuix-comparison-2026-08.md) - [Fabric-compatible runtime hardening design](docs/architecture/fabric-runtime-hardening.md) diff --git a/ROADMAP.md b/ROADMAP.md index 6c392c0..bdc0e9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,9 +97,9 @@ Once fixed: ### Streaming Architecture -- [ ] Measure and improve the data pipeline: serial port → native parser → JS → XFrames render loop, using the Phase 12 baseline and instrumentation — next bounded application comparison in the [streaming plan](docs/engineering/ubx-monitor-streaming-2026-09.md) -- [ ] Configurable update rates (pace UI publication independently of message rate while preserving bounded histories) — next slice, planned 10/20/60 updates per second with default 20 -- [ ] Validate ubx-monitor against repeatable telemetry input and the Phase 12 benchmark harness; bounded ordinary-App correctness passed, while a matched streaming-cost comparison and broader performance targets remain open +- [ ] Measure and improve the complete data pipeline: serial port → parser → JS → XFrames render loop — the [bounded application comparison](docs/engineering/ubx-monitor-pacing-cost-2026-09.md) is complete; physical serial, uninstrumented costs and broader framework targets remain open +- [x] Configurable update rates: persisted 10/20/60 UI publications per second, default 20, with source-owned bounded histories and native rate-change/lifetime checks — [implementation and evidence](docs/engineering/ubx-monitor-streaming-2026-09.md) +- [ ] Complete broader Phase 12 performance qualification for ubx-monitor — repeatable full-App correctness and three matched quiet-window cost pairs passed; framework latency targets, hardware/backend coverage and equivalent external-app comparisons remain open --- @@ -194,7 +194,7 @@ Establish reproducible current behavior and measurements before changing runtime - [x] Record three-repeat production Windows Node/OpenGL and browser SwiftShader/WebGPU baselines at 20/60/120 Hz, including startup, idle behavior, available memory/CPU, frame counts, and p50/p95/p99/maximum observations - [x] Record build/hardware/adapter/assets/workload metadata, explicit unavailable metrics, and proposed update-rate/latency targets - [ ] Extend benchmark coverage to map pan/zoom/tile completion/overlays and telemetry canvas rendering -- [ ] Add hardware WebGPU and ubx-monitor application baselines; compare equivalent Electron/GPUIX implementations before claiming an end-to-end advantage +- [ ] Extend baselines to hardware WebGPU and equivalent Electron/GPUIX implementations before claiming an end-to-end advantage — the bounded Windows ubx-monitor application and [pacing cost comparison](docs/engineering/ubx-monitor-pacing-cost-2026-09.md) are complete ### Stage 1 — Explicit Cross-Runtime Cleanup @@ -296,8 +296,9 @@ and subsequent hosted CI remain follow-ups. The [streaming record](docs/engineering/ubx-monitor-streaming-2026-09.md) delivers persisted 10/20/60 UI rates (default 20), bounded shared publication cadence and source-sample histories independent of React effects. It continues from XFrames -`54bc382` / application `e7de9e2` in an isolated reproducible patch, preserving the -original checkout. Final 20-Hz and high-input native rate-transition gates pass, +`54bc382` / application `e7de9e2`, with the delivered diagnostics and patch in +XFrames `880506e` and the isolated app in `817f931`. The original checkout is +preserved. Final 20-Hz and high-input native rate-transition gates pass, including Position/Console exact tails, reset, pause/reconnect and disposal. Native rendering, input and resource wakes keep their scheduler contract. diff --git a/docs/engineering/fabric-invalidation-2026-09.md b/docs/engineering/fabric-invalidation-2026-09.md index 786fe31..3fd803b 100644 --- a/docs/engineering/fabric-invalidation-2026-09.md +++ b/docs/engineering/fabric-invalidation-2026-09.md @@ -783,3 +783,11 @@ with synthetic receiver bytes, deterministic local tiles and preserved original application settings. The record retains the reproducible patch/setup and results. Controlled performance, broader hardware evidence and sustained CI history remain separate milestone gaps; the completed CI stabilization is not being reopened. + +The subsequent [UI pacing slice](ubx-monitor-streaming-2026-09.md), committed in +XFrames `880506e` with isolated app `817f931`, adds persisted 10/20/60 display +rates and source-owned histories. Final production rate-change/cleanup checks +and [three quiet-window cost pairs](ubx-monitor-pacing-cost-2026-09.md) passed. +The comparison reports lower snapshot work and measured CPU with higher observed +latency. It changes no shared runtime or native scheduler behavior and does not +close the separate framework latency, hardware or ongoing CI criteria above. diff --git a/docs/engineering/ubx-monitor-pacing-cost-2026-09.md b/docs/engineering/ubx-monitor-pacing-cost-2026-09.md index cd2d81f..64d4a14 100644 --- a/docs/engineering/ubx-monitor-pacing-cost-2026-09.md +++ b/docs/engineering/ubx-monitor-pacing-cost-2026-09.md @@ -42,6 +42,9 @@ eliminated. Every `source-identity.json` is identical across the six runs, inclu application source, package lock, fonts, native/common binaries and diagnostic hashes. The [streaming record](ubx-monitor-streaming-2026-09.md) lists reproduction and package identities. +The measured diagnostics and app changes were subsequently committed in XFrames +`880506e` and isolated ubx-monitor `817f931`. Historical run identities retain +the pre-commit base revisions and diffs; these are the same tested sources. ## Counts and process costs @@ -200,7 +203,10 @@ All run directories are under `packages/dear-imgui/npm/build/diagnostics/ubx-pacing/quiet-pair-{1,2,3}-{unpaced,paced}`. They contain source identities/diffs, independent source delivery, raw receipts and observations, publication stages, source-boundary measurement, exact retained -data/oracle reports, final capture and terminal result. From the npm workspace: +data/oracle reports, final capture and terminal result. These generated directories +are retained locally and ignored by Git. This report and the analysis script are +committed; a fresh clone must obtain the retained dataset or execute fresh runs +before it can recompute a summary. With the dataset present, from the npm workspace: ```powershell node diagnostics/ubx-comparison-report.mjs diff --git a/docs/engineering/ubx-monitor-streaming-2026-09.md b/docs/engineering/ubx-monitor-streaming-2026-09.md index 469e2ca..eb597ab 100644 --- a/docs/engineering/ubx-monitor-streaming-2026-09.md +++ b/docs/engineering/ubx-monitor-streaming-2026-09.md @@ -231,14 +231,17 @@ The reviewable artifact is [`ubx-pacing.patch`](../../packages/dear-imgui/npm/diagnostics/ubx-pacing.patch), SHA-256 `2a2dc7b044427497e4063d2a2339e373450332445d3bf46e901d70018453e8b0`, based directly on app `e7de9e25ff3f608e8bb2613e471d0b97f47d58f2`. -XFrames base is `54bc3827c57c8d8e3a02026ce5998c752e0d1783` plus the reviewable -diagnostic/documentation changes. The working app is on isolated branch +XFrames native/runtime base is `54bc3827c57c8d8e3a02026ce5998c752e0d1783`. +The diagnostic sources, incremental patch and reports were subsequently committed +in XFrames `880506ed5190f5375273d0dd77da903b2edcd8fa`. The working app is on isolated branch `qualification/ui-pacing` under `packages/dear-imgui/npm/build/diagnostics/ubx-pacing/app`. The final fresh reproduction is `build/diagnostics/ubx-pacing-reproduction-final/app`. The app changes were subsequently committed as `817f9316e0c14d9bdf8a17c4d8d962d31de4851f` on that isolated `qualification/ui-pacing` branch. The original application checkout remains unchanged; the incremental patch is the portable reproduction artifact. +Recorded runs predate these commits and identify their original base revisions +plus diffs and source hashes. Committing them did not change the tested runtime. The completion audit verified source equivalence after CRLF normalization: fresh Git patch application changed line endings in 12 source files and package.json, while package lock, fonts, native build and diagnostic source @@ -353,6 +356,17 @@ Final whole-App evidence carries per-run source identities or saved patch/harnes copies. `ubx-pacing-reproduction-final/provenance.json` and `commands.json` identify the final reproduction, preserved originals and headless checks. +The patch, harness, regression sources and Markdown reports are committed. +Raw JSON/log/capture evidence, tarballs, native binaries and isolated app checkouts +are retained locally under ignored `build/diagnostics` directories; cloning +XFrames does not retrieve those artifacts or the isolated app's Git branch. +Use the setup command with `--base=qualified` and the committed incremental patch +to recreate the app. If the recorded tarball cache is unavailable, omit +`--packages` to build/pack the JavaScript packages after preparing the Release +native target. New artifact hashes and fresh run results belong to that new +reproduction, not the historical measurement. See the +[reproduction guide](../../packages/dear-imgui/npm/diagnostics/README.md#application-pacing-and-paired-costs). + The bounded goal is complete. The observed-frame framework p95/p99 targets remain open and are not met by these deliberately batched application observations. Quiet laptop comparisons do not establish uninstrumented performance, GPU diff --git a/packages/dear-imgui/npm/diagnostics/README.md b/packages/dear-imgui/npm/diagnostics/README.md index 75fc162..489f8f9 100644 --- a/packages/dear-imgui/npm/diagnostics/README.md +++ b/packages/dear-imgui/npm/diagnostics/README.md @@ -368,7 +368,7 @@ node --import ./common/node_modules/tsx/dist/loader.mjs diagnostics/ubx-applicat ``` Announce desktop runs: this Windows fixture moves the real mouse and acquires -focus for about two minutes. It waits for hover before clicking, verifies the +focus for about three minutes. It waits for hover before clicking, verifies the selected tab and retries missed targets. It uses the actual `src/index.tsx`, public Node render function, returned disposer, App and callbacks. Only physical serial transport and map resources are substituted. Receiver packets contain @@ -402,10 +402,24 @@ The default `--base=migration` preserves the old reproduction command. Keep historical acceptance artifacts separate from new measurements. Announce desktop runs, retry verified input misses, and arrange quiet measurement periods as needed. +XFrames `880506ed5190f5375273d0dd77da903b2edcd8fa` contains the pacing harness, +incremental patch and reports. The app implementation is also committed as +`817f9316e0c14d9bdf8a17c4d8d962d31de4851f` on the **isolated local** +`qualification/ui-pacing` branch. That app commit is not added to the original +`C:/dev/ubx-monitor` checkout by this workflow. The setup below requires its +qualified base `e7de9e2` to be available in `--source` and reproduces the app from +the committed patch; it does not require access to the isolated branch. + ```powershell node diagnostics/ubx-application-setup.mjs --base=qualified --source=C:/dev/ubx-monitor --output=C:/path/to/fresh/pacing-reproduction --packages=C:/dev/xframes/packages/dear-imgui/npm/build/diagnostics/ubx-application/packages --patch=C:/dev/xframes/packages/dear-imgui/npm/diagnostics/ubx-pacing.patch ``` +`--packages` above points to the retained local qualification cache, which is +ignored by Git. If it is unavailable, omit that option after building the current +Release Node native target; setup will build/pack common and Node itself. Native +binary presence and installed/current-target hash equality are still required. +New builds may have different hashes and must retain their own provenance. + Setup executes `typecheck`, `test:serial` and, when present, `test:pacing` (including real-parser retention and restart/persistence regressions). Final reproduction `build/diagnostics/ubx-pacing-reproduction-final` passed these checks and ordinary @@ -413,6 +427,10 @@ development `npm start`. The incremental patch SHA-256 is `2a2dc7b044427497e4063d2a2339e373450332445d3bf46e901d70018453e8b0`. It applies to `e7de9e2`, not the original main; setup refuses an existing app output and records original-file preservation and package/native identities. +The quoted patch digest describes the retained measurement artifact. Git's +Windows line-ending conversion can change a checked-out patch's byte hash; +setup records the actual file hash. The final audit verified the app's source +equivalence after CRLF normalization, including 12 source files and package.json. From the npm workspace, set the prepared app and choose a **new evidence directory for each run**. Announce every desktop run: full functional scenarios @@ -474,7 +492,10 @@ explicit, and GPU completion/presentation is unavailable. The executed six-run dataset lives at `build/diagnostics/ubx-pacing/quiet-pair-{1,2,3}-{unpaced,paced}`. After all desktop -runs stop, recompute its checked summary with: +runs stop, recompute its checked summary with the commands below. These raw +JSON/log/capture directories are local ignored evidence, not files provided by +a Git clone. The offline script requires all six directories with their recorded +files; use the harness to produce a fresh dataset when the retained one is absent. ```powershell node diagnostics/ubx-comparison-report.mjs From 44560bbf32ffda18be8520b45b1ad683cac1aee8 Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Sun, 27 Sep 2026 20:48:52 +0200 Subject: [PATCH 6/7] feat: implement style patching for StyledWidget and Element with comprehensive tests --- .../cpp/app/src/element/element.cpp | 46 ++-- .../cpp/app/src/widget/styled_widget.cpp | 32 +-- packages/dear-imgui/cpp/tests/CMakeLists.txt | 1 + .../cpp/tests/styled_widget_test.cpp | 198 ++++++++++++++++++ packages/dear-imgui/npm/diagnostics/README.md | 26 +++ packages/dear-imgui/npm/diagnostics/run.mjs | 2 + .../npm/diagnostics/style-patching.tsx | 164 +++++++++++++++ packages/dear-imgui/npm/package.json | 2 + 8 files changed, 427 insertions(+), 44 deletions(-) create mode 100644 packages/dear-imgui/cpp/tests/styled_widget_test.cpp create mode 100644 packages/dear-imgui/npm/diagnostics/style-patching.tsx diff --git a/packages/dear-imgui/cpp/app/src/element/element.cpp b/packages/dear-imgui/cpp/app/src/element/element.cpp index 4f48cf9..8eae8d7 100644 --- a/packages/dear-imgui/cpp/app/src/element/element.cpp +++ b/packages/dear-imgui/cpp/app/src/element/element.cpp @@ -149,33 +149,9 @@ void Element::ApplyStyle() { return; } - switch (state) { - case ElementState_Hover: { - if (m_elementStyle.value().maybeHover.has_value()) { - m_layoutNode->ApplyStyle(m_elementStyle.value().maybeHover.value().styleDef); - } - break; - } - case ElementState_Active: { - if (m_elementStyle.value().maybeActive.has_value()) { - m_layoutNode->ApplyStyle(m_elementStyle.value().maybeActive.value().styleDef); - } - break; - } - case ElementState_Disabled: { - if (m_elementStyle.value().maybeDisabled.has_value()) { - m_layoutNode->ApplyStyle(m_elementStyle.value().maybeDisabled.value().styleDef); - } - break; - } - - default: { - if (m_elementStyle.value().maybeBase.has_value()) { - m_layoutNode->ApplyStyle(m_elementStyle.value().maybeBase.value().styleDef); - } - break; - } - } + const auto& parts = GetElementStyleParts(state); + if (parts) m_layoutNode->ApplyStyle(parts->styleDef); + else m_layoutNode->ResetStyle(); m_lastAppliedState = state; m_styleDirty = false; @@ -523,8 +499,20 @@ void Element::PostRender(XFrames* view) { }; void Element::Patch(const json& elementPatchDef, XFrames* view) { - m_elementStyle = ExtractStyle(elementPatchDef); - m_styleDirty = true; + // Keep layout/effect families in sync with StyledWidget's partial patches. + const auto patchFamily = [&](const char* name, auto member) { + const auto it = elementPatchDef.find(name); + if (it == elementPatchDef.end() || (!it->is_object() && !it->is_null())) return; + if (!m_elementStyle) m_elementStyle.emplace(); + auto& family = m_elementStyle.value().*member; + if (it->is_null()) family.reset(); + else family = extractStyleParts(*it); + m_styleDirty = true; + }; + patchFamily("style", &ElementStyle::maybeBase); + patchFamily("hoverStyle", &ElementStyle::maybeHover); + patchFamily("activeStyle", &ElementStyle::maybeActive); + patchFamily("disabledStyle", &ElementStyle::maybeDisabled); ApplyStyle(); }; diff --git a/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp b/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp index d5de396..b0a8dca 100644 --- a/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp +++ b/packages/dear-imgui/cpp/app/src/widget/styled_widget.cpp @@ -104,8 +104,6 @@ bool WidgetStyle::HasCustomStyleVar(const std::optional widgetStat } StyleColors& WidgetStyle::GetCustomColors(std::optional widgetState) { - auto& baseColors = maybeBase.value().maybeColors.value(); - switch(widgetState.value_or(ElementState_Base)) { case ElementState_Disabled: { if (maybeDisabled.has_value() && maybeDisabled.value().maybeColors.has_value()) { @@ -129,12 +127,10 @@ StyleColors& WidgetStyle::GetCustomColors(std::optional widgetStat break; } - return baseColors; + return maybeBase.value().maybeColors.value(); } StyleVars& WidgetStyle::GetCustomStyleVars(std::optional widgetState) { - auto& baseStyleVars = maybeBase.value().maybeStyleVars.value(); - switch(widgetState.value_or(ElementState_Base)) { case ElementState_Disabled: { if (maybeDisabled.has_value() && maybeDisabled.value().maybeStyleVars.has_value()) { @@ -158,7 +154,7 @@ StyleVars& WidgetStyle::GetCustomStyleVars(std::optional widgetSta break; } - return baseStyleVars; + return maybeBase.value().maybeStyleVars.value(); } StyleColors* WidgetStyle::GetCustomColorsOrNull(std::optional widgetState) { @@ -204,8 +200,6 @@ StyleVars* WidgetStyle::GetCustomStyleVarsOrNull(std::optional wid } int WidgetStyle::GetCustomFontId(std::optional widgetState, XFrames* view) { - auto fontIndex = maybeBase.value().maybeFontIndex.value(); - switch(widgetState.value_or(ElementState_Base)) { case ElementState_Disabled: { if (maybeDisabled.has_value() && maybeDisabled.value().maybeFontIndex.has_value()) { @@ -229,7 +223,7 @@ int WidgetStyle::GetCustomFontId(std::optional widgetState, XFrame break; } - return fontIndex; + return maybeBase.value().maybeFontIndex.value(); } // todo: is it really that difficult to return the variant by reference? @@ -345,12 +339,20 @@ void StyledWidget::ReplaceStyle(WidgetStyle& newStyle) { void StyledWidget::Patch(const json& widgetPatchDef, XFrames* view) { Widget::Patch(widgetPatchDef, view); - // todo: we probably need to test all 4 state objects individually - auto maybeNewStyle = ExtractStyle(widgetPatchDef, view); - - if (maybeNewStyle.has_value()) { - ReplaceStyle(maybeNewStyle.value()); - } + // Creation extracts a complete definition. Patches replace only the named + // families: null removes one, and an object (including {}) replaces one. + const auto patchFamily = [&](const char* name, auto member) { + const auto it = widgetPatchDef.find(name); + if (it == widgetPatchDef.end() || (!it->is_object() && !it->is_null())) return; + if (!m_style) m_style.emplace(std::make_unique()); + auto& family = (*m_style.value()).*member; + if (it->is_null()) family.reset(); + else family = extractStyleParts(*it, view); + }; + patchFamily("style", &WidgetStyle::maybeBase); + patchFamily("hoverStyle", &WidgetStyle::maybeHover); + patchFamily("activeStyle", &WidgetStyle::maybeActive); + patchFamily("disabledStyle", &WidgetStyle::maybeDisabled); if (YGNodeGetParent(m_layoutNode->m_node) && YGNodeHasMeasureFunc(m_layoutNode->m_node)) { YGNodeMarkDirty(m_layoutNode->m_node); diff --git a/packages/dear-imgui/cpp/tests/CMakeLists.txt b/packages/dear-imgui/cpp/tests/CMakeLists.txt index 2bef0ac..3d8e3ab 100644 --- a/packages/dear-imgui/cpp/tests/CMakeLists.txt +++ b/packages/dear-imgui/cpp/tests/CMakeLists.txt @@ -208,6 +208,7 @@ add_executable(Google_Tests_run color_helpers_test.cpp yoga_helpers_test.cpp imgui_helpers_test.cpp + styled_widget_test.cpp element/layout_node_test.cpp element/element_test.cpp xframes_test.cpp diff --git a/packages/dear-imgui/cpp/tests/styled_widget_test.cpp b/packages/dear-imgui/cpp/tests/styled_widget_test.cpp new file mode 100644 index 0000000..15794ec --- /dev/null +++ b/packages/dear-imgui/cpp/tests/styled_widget_test.cpp @@ -0,0 +1,198 @@ +#include +#include + +#include "imgui_renderer.h" +#include "widget/button.h" +#include "widget/text.h" +#include "xframes.h" + +namespace { +constexpr std::array names{"style", "hoverStyle", "activeStyle", "disabledStyle"}; +constexpr std::array widgetMembers{&WidgetStyle::maybeBase, &WidgetStyle::maybeHover, + &WidgetStyle::maybeActive, &WidgetStyle::maybeDisabled}; +constexpr std::array elementMembers{&ElementStyle::maybeBase, &ElementStyle::maybeHover, + &ElementStyle::maybeActive, &ElementStyle::maybeDisabled}; +constexpr std::array states{ElementState_Base, ElementState_Hover, ElementState_Active, ElementState_Disabled}; + +// Real font lookup and ImGui fonts, without a desktop window or graphics driver. +class StyleTestRenderer : public ImGuiRenderer { +public: + explicit StyleTestRenderer(XFrames* view) : ImGuiRenderer(view, "style-test", "style-test", "{}", std::nullopt) { + for (int seed = 0; seed <= 7; ++seed) { + ImFontConfig config; + config.SizePixels = 14 + seed * 2; + m_fontDefMap["test-font"][14 + seed * 2] = seed; + m_loadedFonts.push_back(ImGui::GetIO().Fonts->AddFontDefault(&config)); + } + SetFontDefault(0); + } +}; + +json Family(unsigned seed) { + return {{"font", {{"name", "test-font"}, {"size", 14u + seed * 2u}}}, + {"colors", {{std::to_string(ImGuiCol_Text), fmt::format("#{:02x}8090", seed)}}}, + {"vars", {{std::to_string(ImGuiStyleVar_FrameRounding), seed}, + {std::to_string(ImGuiStyleVar_FramePadding), json::array({seed, seed + 1})}}}, + {"width", 100u + seed}, {"backgroundColor", "#008080"}}; +} + +json Definition() { + json props = {{"id", 1}, {"text", "Encrypt a file"}, {"label", "Encrypt"}}; + for (size_t i = 0; i < names.size(); ++i) props[names[i]] = Family(i + 1); + return props; +} + +void ExpectParts(const std::optional& parts, unsigned seed) { + ASSERT_TRUE(parts); + EXPECT_EQ(parts->maybeFontIndex, seed); + ASSERT_TRUE(parts->maybeColors); + ASSERT_EQ(parts->maybeColors->size(), 1); + const auto color = parts->maybeColors->at(ImGuiCol_Text); + EXPECT_FLOAT_EQ(color.x, seed / 255.0f); + EXPECT_FLOAT_EQ(color.y, 128 / 255.0f); + EXPECT_FLOAT_EQ(color.z, 144 / 255.0f); + EXPECT_FLOAT_EQ(color.w, 1); + ASSERT_TRUE(parts->maybeStyleVars); + ASSERT_EQ(parts->maybeStyleVars->size(), 2); + EXPECT_FLOAT_EQ(std::get(parts->maybeStyleVars->at(ImGuiStyleVar_FrameRounding)), seed); + const auto padding = std::get(parts->maybeStyleVars->at(ImGuiStyleVar_FramePadding)); + EXPECT_FLOAT_EQ(padding.x, seed); + EXPECT_FLOAT_EQ(padding.y, seed + 1); +} +} + +class StyledWidgetPatchTest : public ::testing::Test { +protected: + std::unique_ptr view; + std::unique_ptr renderer; + void SetUp() override { + view = std::make_unique("style-test", std::nullopt); + renderer = std::make_unique(view.get()); + view->m_renderer = renderer.get(); + } + void TearDown() override { + view.reset(); + ImGui::DestroyContext(renderer->m_imGuiCtx); + renderer.reset(); + } + auto MakeText() { + const auto def = Definition(); + auto widget = UnformattedText::makeWidget(def, StyledWidget::ExtractStyle(def, view.get()), view.get()); + widget->Init(def); + return widget; + } + void ExpectFamily(const StyledWidget& widget, size_t family, unsigned seed) { + ASSERT_TRUE(widget.m_style); + ExpectParts((*widget.m_style.value()).*widgetMembers[family], seed); + ASSERT_TRUE(widget.m_elementStyle); + const auto& layout = widget.m_elementStyle.value().*elementMembers[family]; + ASSERT_TRUE(layout); + EXPECT_EQ(layout->styleDef, Family(seed)); + ASSERT_TRUE(layout->backgroundColor); + EXPECT_FLOAT_EQ(layout->backgroundColor->y, 128 / 255.0f); + } + void ExpectOtherFamilies(const StyledWidget& widget, size_t changed) { + for (size_t i = 0; i < names.size(); ++i) if (i != changed) ExpectFamily(widget, i, i + 1); + } +}; + +TEST_F(StyledWidgetPatchTest, TextOnlyPatchPreservesFontsColorsStyleVarsAndLayout) { + auto widget = MakeText(); + auto* originalStyle = widget->m_style.value().get(); + widget->Patch({{"text", "Decrypt a file"}}, view.get()); + EXPECT_EQ(widget->m_text, "Decrypt a file"); + EXPECT_EQ(widget->m_style.value().get(), originalStyle); + for (size_t i = 0; i < names.size(); ++i) ExpectFamily(*widget, i, i + 1); + EXPECT_FLOAT_EQ(view->GetWidgetFontSize(widget.get()), 16); + EXPECT_FLOAT_EQ(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value, 101); +} + +TEST_F(StyledWidgetPatchTest, LabelOnlyPatchPreservesFontsColorsStyleVarsAndLayout) { + const auto def = Definition(); + auto widget = Button::makeWidget(def, StyledWidget::ExtractStyle(def, view.get()), view.get()); + widget->Init(def); + widget->Patch({{"label", "Working..."}}, view.get()); + EXPECT_EQ(widget->m_label, "Working..."); + for (size_t i = 0; i < names.size(); ++i) ExpectFamily(*widget, i, i + 1); + EXPECT_FLOAT_EQ(view->GetFrameHeight(widget.get()), 20); +} + +TEST_F(StyledWidgetPatchTest, NonStylePatchDoesNotCreateStyleOnAnUnstyledWidget) { + StyledWidget widget(view.get(), 1); + widget.Patch({{"label", "Working..."}}, view.get()); + EXPECT_FALSE(widget.m_style); + EXPECT_FALSE(widget.m_elementStyle); +} + +TEST_F(StyledWidgetPatchTest, PatchCanIntroduceStyleOnAnUnstyledWidget) { + StyledWidget widget(view.get(), 1); + widget.Patch({{"hoverStyle", Family(2)}}, view.get()); + ExpectFamily(widget, 1, 2); + EXPECT_FALSE(widget.m_style.value()->maybeBase); + EXPECT_EQ(widget.m_style.value()->GetCustomFontId(ElementState_Hover, view.get()), 2); +} + +class StyledWidgetFamilyPatchTest : public StyledWidgetPatchTest, public ::testing::WithParamInterface {}; + +TEST_P(StyledWidgetFamilyPatchTest, UpdatesOnlyTheExplicitFamily) { + auto widget = MakeText(); + widget->Patch({{names[GetParam()], Family(7)}}, view.get()); + ExpectFamily(*widget, GetParam(), 7); + ExpectOtherFamilies(*widget, GetParam()); +} + +TEST_P(StyledWidgetFamilyPatchTest, ReplacesRatherThanMergesWithinAFamily) { + auto widget = MakeText(); + widget->Patch({{names[GetParam()], {{"colors", {{std::to_string(ImGuiCol_Text), "#ff0000"}}}}}}, view.get()); + const auto& parts = (*widget->m_style.value()).*widgetMembers[GetParam()]; + ASSERT_TRUE(parts); + EXPECT_FALSE(parts->maybeFontIndex); + EXPECT_FALSE(parts->maybeStyleVars); + ASSERT_TRUE(parts->maybeColors); + EXPECT_FLOAT_EQ(parts->maybeColors->at(ImGuiCol_Text).x, 1); + ExpectOtherFamilies(*widget, GetParam()); +} + +TEST_P(StyledWidgetFamilyPatchTest, NullClearsOnlyTheExplicitFamily) { + auto widget = MakeText(); + widget->Patch({{names[GetParam()], nullptr}}, view.get()); + EXPECT_FALSE((*widget->m_style.value()).*widgetMembers[GetParam()]); + EXPECT_FALSE(widget->m_elementStyle.value().*elementMembers[GetParam()]); + ExpectOtherFamilies(*widget, GetParam()); + // In particular, removing base must leave state-only font lookup usable. + for (size_t i = 0; i < names.size(); ++i) { + if (i != GetParam()) { + EXPECT_TRUE(widget->m_style.value()->HasCustomFont(states[i], view.get())); + EXPECT_EQ(widget->m_style.value()->GetCustomFontId(states[i], view.get()), i + 1); + EXPECT_EQ(widget->m_style.value()->GetCustomColors(states[i]).size(), 1); + EXPECT_EQ(widget->m_style.value()->GetCustomStyleVars(states[i]).size(), 2); + } + } + if (GetParam() == 0) EXPECT_TRUE(YGFloatIsUndefined(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value)); +} + +TEST_P(StyledWidgetFamilyPatchTest, EmptyObjectReplacesOnlyTheExplicitFamily) { + auto widget = MakeText(); + widget->Patch({{names[GetParam()], json::object()}}, view.get()); + const auto& parts = (*widget->m_style.value()).*widgetMembers[GetParam()]; + ASSERT_TRUE(parts); + EXPECT_FALSE(parts->maybeFontIndex); + EXPECT_FALSE(parts->maybeColors); + EXPECT_FALSE(parts->maybeStyleVars); + ExpectOtherFamilies(*widget, GetParam()); + if (GetParam() == 0) EXPECT_TRUE(YGFloatIsUndefined(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value)); +} + +TEST_F(StyledWidgetPatchTest, ClearingCurrentHoverLayoutRestoresBaseLayout) { + auto widget = MakeText(); + widget->m_isHovered = true; + widget->ApplyStyle(); + EXPECT_FLOAT_EQ(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value, 102); + widget->Patch({{"hoverStyle", nullptr}}, view.get()); + EXPECT_FLOAT_EQ(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value, 101); + widget->Patch({{"style", nullptr}}, view.get()); + EXPECT_TRUE(YGFloatIsUndefined(YGNodeStyleGetWidth(widget->m_layoutNode->m_node).value)); +} + +INSTANTIATE_TEST_SUITE_P(AllFamilies, StyledWidgetFamilyPatchTest, ::testing::Values(0, 1, 2, 3), + [](const auto& info) { return names[info.param]; }); diff --git a/packages/dear-imgui/npm/diagnostics/README.md b/packages/dear-imgui/npm/diagnostics/README.md index 489f8f9..6bf2120 100644 --- a/packages/dear-imgui/npm/diagnostics/README.md +++ b/packages/dear-imgui/npm/diagnostics/README.md @@ -1,5 +1,31 @@ # Fabric lifecycle and streaming diagnostics +## Native style patch regression + +From `packages/dear-imgui/npm`, rebuild the addon with +`cmake --build node/build --config Release`, then run `npm run test:styles:node`. +The command rebuilds common and uses the real React/Fabric bridge and +`node/build/Release/xframes.node` in both development and production. It opens a +native window, moves the pointer to an unused area once, and closes the window +afterward. Windows uses the existing PID-scoped helper; Linux needs the X11 tools +described below (run through `diagnostics/x11-run.sh`). + +The test asserts that fresh, equivalent React style objects produce text-only +and label-only native patches, then checks submitted heading/button heights and +actual teal button pixels in native screenshots. It also replaces each style +family independently, clears state families, clears/restores base styling, and +replaces base styling with `{}`. JSON evidence includes wire publications, +measured bounds, pixel counts and the loaded addon's SHA-256. Output defaults to +`build/diagnostics/styles-node`; override it with `XFRAMES_DIAGNOSTICS_DIR`. + +Native `StyledWidget*PatchTest` cases in `cpp/tests/styled_widget_test.cpp` check +all four families' fonts, colors, ImGui variables and layout/effect definitions. +Patch semantics are shared by widgets and elements: omitted families are kept, +objects replace the entire addressed family (including `{}`), and `null` removes +that family. Clearing a state family restores base layout when appropriate; +clearing base leaves the other families available. Creation still extracts a +complete initial definition. + The shared React PlotBar/Table fixture runs through Fabric and both real native bindings. Runs require all ten lifetime defect gates, prospective Fabric publication, final-tree wire checks and native visibility tests. See the diff --git a/packages/dear-imgui/npm/diagnostics/run.mjs b/packages/dear-imgui/npm/diagnostics/run.mjs index 8f1e305..c66dee0 100644 --- a/packages/dear-imgui/npm/diagnostics/run.mjs +++ b/packages/dear-imgui/npm/diagnostics/run.mjs @@ -50,6 +50,8 @@ if (resourceServer) options.resourceFixture = { baseUrl: resourceServer.baseUrl, try { if (command === "bridge") { for (const mode of ["development", "production"]) await invoke("diagnostics/bridge-lifecycle.tsx", mode); +} else if (command === "styles-node") { + for (const mode of ["development", "production"]) await invoke("diagnostics/style-patching.tsx", mode); } else if (command === "node") { await invoke("diagnostics/node-runner.ts", extra.includes("--baseline") ? "production" : process.env.NODE_ENV ?? "development"); } else if (command === "wasm") { diff --git a/packages/dear-imgui/npm/diagnostics/style-patching.tsx b/packages/dear-imgui/npm/diagnostics/style-patching.tsx new file mode 100644 index 0000000..fe4e497 --- /dev/null +++ b/packages/dear-imgui/npm/diagnostics/style-patching.tsx @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { inflateSync } from "node:zlib"; +import React from "react"; +import { ImGuiCol, ImGuiStyleVar } from "@xframes/common"; +import { createBridge } from "./bridge"; +import { observeNativeFrame } from "./frames"; +import { nativeInput } from "./node-input"; +import { waitFor } from "./assertions"; + +// Load the actual CMake Release target, never an installed package or a mock. +const addon = resolve("node/build/Release/xframes.node"); +const native = require(addon); +const output = resolve(process.env.XFRAMES_DIAGNOSTICS_DIR ?? "build/diagnostics/styles-node"); +mkdirSync(output, { recursive: true }); +const mode = process.env.NODE_ENV ?? "development"; +const families = ["style", "hoverStyle", "activeStyle", "disabledStyle"] as const; +const teal = "#149c91"; +const publications: any[] = []; +const stages: unknown[] = []; +let expectedClose = false, closed = false; + +// Decode only the 8-bit, non-interlaced RGB/RGBA PNGs produced by the native +// screenshot writer. Pixel assertions must inspect rendered output, not props. +function countTealPixels(path: string) { + const png = readFileSync(path); + assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a"); + assert.equal(png.subarray(12, 16).toString(), "IHDR"); + assert.equal(png[24], 8); + assert.ok(png[25] === 2 || png[25] === 6); + assert.deepEqual([...png.subarray(26, 29)], [0, 0, 0]); + const width = png.readUInt32BE(16), height = png.readUInt32BE(20), channels = png[25] === 6 ? 4 : 3; + const chunks: Buffer[] = []; + for (let offset = 8; offset < png.length;) { + const length = png.readUInt32BE(offset); + if (png.subarray(offset + 4, offset + 8).toString() === "IDAT") chunks.push(png.subarray(offset + 8, offset + 8 + length)); + offset += length + 12; + } + const raw = inflateSync(Buffer.concat(chunks)), stride = width * channels; + assert.equal(raw.length, (stride + 1) * height); + let previous = Buffer.alloc(stride), count = 0; + for (let y = 0; y < height; ++y) { + const row = Buffer.alloc(stride), filter = raw[y * (stride + 1)]; + assert.ok(filter <= 4); + for (let x = 0; x < stride; ++x) { + const left = x < channels ? 0 : row[x - channels], up = previous[x]; + const corner = x < channels ? 0 : previous[x - channels]; + const p = left + up - corner; + const a = Math.abs(p - left), b = Math.abs(p - up), c = Math.abs(p - corner); + const predictor = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up + : filter === 3 ? Math.floor((left + up) / 2) : a <= b && a <= c ? left : b <= c ? up : corner; + row[x] = raw[y * (stride + 1) + 1 + x] + predictor; + } + for (let x = 0; x < stride; x += channels) + if (row[x] === 0x14 && row[x + 1] === 0x9c && row[x + 2] === 0x91) ++count; + previous = row; + } + return count; +} + +function family(fontSize: number) { + return { width: 320, font: { name: "roboto-regular", size: fontSize }, + colors: { [ImGuiCol.Button]: teal, [ImGuiCol.ButtonHovered]: teal, [ImGuiCol.ButtonActive]: teal }, + vars: { [ImGuiStyleVar.FramePadding]: [12, 10], [ImGuiStyleVar.FrameRounding]: 4 } }; +} +const headingStyles: Record = Object.fromEntries(families.map(name => [name, family(28)])); +const buttonStyles: Record = Object.fromEntries(families.map(name => [name, family(18)])); +let text = "Encrypt a file", label = "Encrypt"; +function Fixture() { + // Deliberately fresh objects on every render: Fabric still omits styles + // whose values are unchanged, just as it does in an ordinary application. + return React.createElement("node", { root: true, style: { width: "100%", height: "100%", padding: { all: 28 } } }, + React.createElement("unformatted-text", { id: "heading", text, ...JSON.parse(JSON.stringify(headingStyles)) }), + React.createElement("di-button", { id: "action", label, ...JSON.parse(JSON.stringify(buttonStyles)) })); +} + +async function main() { + const callbacks = ["onTextChange", "onComboChange", "onNumericValueChange", "onBooleanValueChange", "onMultiValueChange", + "onClick", "onTableSort", "onTableFilter", "onTableRowClick", "onTableItemAction", "onPrefetchProgress", "onScriptError"]; + await new Promise(ready => native.init({ + assetsBasePath: resolve("../assets"), theme: "{}", + fontDefs: JSON.stringify({ defs: [14, 18, 28].map(size => ({ name: "roboto-regular", size })) }), + ...Object.fromEntries(callbacks.map(name => [name, () => {}])), onInit: ready, + onBeforeExit: () => { closed = true; if (!expectedClose) process.exit(1); }, + })); + native.setDiagnosticsEnabled(true); + const bridge = createBridge({ ...native, applyCommit(wire: string) { + publications.push(JSON.parse(wire)); + return native.applyCommit(wire); + } }); + try { + await bridge.render(React.createElement(Fixture)); + // Keep interaction state deterministic while checking base styling. + await nativeInput({ action: "move", x: 800, y: 600 }); + const id = (publicId: string) => bridge.registrations.getDiagnostics().mappings.find(item => item.publicId === publicId)!.nativeId; + const headingId = id("heading"), buttonId = id("action"); + const inspect = async (stage: string, styled = true) => { + const frame = await observeNativeFrame(native, () => true, stage); + const heading = frame.elements.find(item => item.id === headingId)!; + const button = frame.elements.find(item => item.id === buttonId)!; + const screenshot = resolve(output, `${mode}-${stage}.png`); + await new Promise((done, reject) => native.captureScreenshot(screenshot, + (error: string | null) => error ? reject(new Error(error)) : done())); + const tealPixels = countTealPixels(screenshot); + stages.push({ stage, headingBounds: heading.bounds, buttonBounds: button.bounds, tealPixels, + nativeRevision: frame.nativeRevision, screenshot }); + assert.equal(heading.bounds[3], styled ? 28 : 14, `${stage}: heading font height`); + assert.equal(button.bounds[3], styled ? 38 : 20, `${stage}: button font and FramePadding`); + assert.ok(styled ? tealPixels > 1000 : tealPixels === 0, `${stage}: rendered button color (${tealPixels} teal pixels)`); + }; + const renderPatch = async (headingKeys: string[], buttonKeys: string[]) => { + const start = publications.length; + await bridge.render(React.createElement(Fixture)); + const patches = publications.slice(start).flatMap(batch => batch.operations).filter(op => op.op === "patch"); + assert.deepEqual(patches.map(op => op.id).sort((a, b) => a - b), [headingId, buttonId].sort((a, b) => a - b)); + assert.deepEqual(Object.keys(patches.find(op => op.id === headingId).props).sort(), [...headingKeys].sort()); + assert.deepEqual(Object.keys(patches.find(op => op.id === buttonId).props).sort(), [...buttonKeys].sort()); + }; + await inspect("initial"); + text = "Decrypt a file"; label = "Working..."; + await renderPatch(["text"], ["label"]); + await inspect("text-and-label-only"); + for (const name of families) { + headingStyles[name] = { ...family(28), vars: { ...family(28).vars, [ImGuiStyleVar.FrameRounding]: 8 } }; + buttonStyles[name] = { ...family(18), vars: { ...family(18).vars, [ImGuiStyleVar.FrameRounding]: 8 } }; + await renderPatch([name], [name]); + await inspect(`replace-${name}`); + } + for (const name of families.slice(1)) { + headingStyles[name] = null; buttonStyles[name] = null; + await renderPatch([name], [name]); + await inspect(`clear-${name}`); + } + headingStyles.style = null; buttonStyles.style = null; + await renderPatch(["style"], ["style"]); + await inspect("clear-base", false); + headingStyles.style = family(28); buttonStyles.style = family(18); + await renderPatch(["style"], ["style"]); + await inspect("restore-base"); + headingStyles.style = {}; buttonStyles.style = {}; + await renderPatch(["style"], ["style"]); + await inspect("empty-base", false); + assert.deepEqual(bridge.rendererErrors, []); + } finally { + await bridge.dispose(); + expectedClose = true; + await nativeInput({ action: "close" }); + await waitFor(() => closed, Boolean, "style test window close"); + } +} + +const watchdog = setTimeout(() => { console.error("Native style test timed out"); process.exit(1); }, 60_000); +main().then(() => finish(), error => finish(error)); +function finish(error?: unknown) { + clearTimeout(watchdog); + writeFileSync(resolve(output, `${mode}-styles.json`), JSON.stringify({ status: error ? "fail" : "pass", mode, addon, + addonSha256: createHash("sha256").update(readFileSync(addon)).digest("hex"), stages, publications, + ...(error ? { error: String(error) } : {}) }, null, 2)); + if (error) console.error(error); + else console.log(`Native Fabric style patching passed (${mode}, ${stages.length} rendered stages)`); + process.exit(error ? 1 : 0); +} diff --git a/packages/dear-imgui/npm/package.json b/packages/dear-imgui/npm/package.json index 8bc1a87..540cd80 100644 --- a/packages/dear-imgui/npm/package.json +++ b/packages/dear-imgui/npm/package.json @@ -8,6 +8,8 @@ ], "scripts": { "test:lifecycle": "node diagnostics/run.mjs bridge", + "pretest:styles:node": "npm run build:common", + "test:styles:node": "node diagnostics/run.mjs styles-node", "test:transactions:parity": "node diagnostics/transaction-parity.mjs", "diagnostics:node": "node diagnostics/run.mjs node", "diagnostics:wasm": "node diagnostics/run.mjs wasm", From 80bed654a776ac5b9c4c7251253782d3f2504024 Mon Sep 17 00:00:00 2001 From: Andrea Mancuso Date: Sun, 27 Sep 2026 21:47:15 +0200 Subject: [PATCH 7/7] feat: add step to checkout ImGui headers for ABI check in CI workflow --- .github/workflows/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/application.yml b/.github/workflows/application.yml index 4261bc9..b710e1a 100644 --- a/.github/workflows/application.yml +++ b/.github/workflows/application.yml @@ -27,6 +27,9 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v4 + - name: Checkout ImGui headers for the ABI check + working-directory: . + run: git submodule update --init --depth 1 -- packages/dear-imgui/cpp/deps/imgui - uses: actions/setup-node@v4 with: node-version: 24.14.0