Repository navigation
fix(nodes): steady link and roster — no false offline or not-responding, one on-the-bus rule, reload keeps state - #152
Merged
Merged
Conversation
…ponding, no orphan bridge (#150) Part of #132 (fix-track chunk 3). Owners: `main/nodes/node-hub.ts`, `main/nodes/node-status-wire.ts` (`NodeRoster`), `main/gateway/gateway-health.ts`, `main/gateway/gateway-connection.ts`, `main/gateway/serial-bridge.ts`. No firmware change. ## Before → after | Item | Before | After | |---|---|---| | **M2** roster requests | Every request (5 s poll, reconnect seed, Refresh) cleared the reply burst, so a request landing mid-reply marked the boards whose rows were already in as offline: false offline/online rows, extra last-seen saves, possible false PDU outages | A request never discards a reply that is arriving. PanelBridge starts each reply with its own node-0 row (never sent as a delta) and ends it with `_NODE_STATUS_END`, in the order asked. From a node-0 row to the next END a reply is arriving, and a request keeps its rows. Only a request with no reply arriving isolates the next burst from earlier deltas, as before | | **G1** not-responding deadline | Poll interval = deadline = 5 s. One lost reply let the 200 ms tick reach the deadline just before the next poll was answered: a "Gateway not responding" flash, then "responding again" | A request unanswered for 1 s is asked again, every second, until answered. The 5 s poll and the 5 s deadline are unchanged. "Not responding" now takes five lost replies in a row | | **M4** stopped serial bridge | After Disconnect or Retry, a released bridge's callbacks still drove the link, and could reopen the port and hold it | `GatewayConnection` ignores every callback from a bridge that isn't current. `SerialBridge` re-checks `stopped` after each await, and releases a port that finished opening after the stop | | **M7** Retry and last seen | error → Retry reset the roster without saving last seen | Retry runs the `closing` riders first, as Disconnect, unplug and quit do (B13) | | **M3** v2 bridge END | The END still completed the roster, so every board read offline while on the bus, and Hide/Forget were allowed | After a v2 row the roster ignores END: boards read *unknown*, and Hide/Forget stay refused. The once-per-link warning (B29) is unchanged; it now reads the roster's v2 flag, so there is one detector | | **Refresh** (user 2026-10-17) | Connection › Connected nodes had a Refresh button | Removed, along with everything only it used: the `nodes:refresh` intent (contract, preload, `IPC_ORDER`, handler, harness, API guard), the store intent `refreshNodes`, and `canRefresh` on `device:status` (`DeviceLinkFacts`, `DEVICE_IDLE`, `linkFacts`, the `GatewayConnectionDeps.canRefresh` hook, `NodeHub.canRefresh()`). The empty-roster hint no longer says "or press Refresh". `canRequestRoster` stays because it is the poll's own guard | ## Poll and deadline design (G1, designed with M2) - **Visible cadence stays 5 s.** The 5 s poll timer is unchanged. - **Re-ask:** `GatewayWatch` owns the rule beside the deadline, so the coupled constants live in one file. `GATEWAY_REASK_MS` = 1000 ms is the roster reply window, because PanelBridge answers in milliseconds. `reaskDue(now)` is true once the oldest request is still unanswered and the latest ask was ≥ 1 s ago. NodeHub checks it on the link's 200 ms tick and asks again. The rule is armed only after the Gateway has answered since the port opened, same as "not responding": a Gateway built without node status keeps only the plain 5 s poll. There is no re-ask during a calibration pause. - **Deadline unchanged:** `GATEWAY_NO_ANSWER_MS` = 5000 ms, counted from the oldest unanswered request. A later ask doesn't restart it, so the alert text "no answer for 5 s" stays literally true. - **Why it can't flash on one loss:** inside the 5 s window there are asks at t = 0, 1, 2, 3 and 4 s. One lost reply is re-asked at about 1.0–1.2 s and answered long before 5 s. Up to four consecutive losses never trip the alert. A unit test pins `floor((5000 − 1) / 1000) ≥ 4`. - **Re-asks can't cut a reply short:** a re-ask goes out only when nothing at all has arrived since the ask. Any line of a reply counts as an answer, so a re-ask never lands inside a reply. M2's rule covers the 5 s poll, which can land inside one. - **Real-outage bound:** "not responding" is raised 5.0–5.2 s after the first unanswered request (deadline + one 200 ms tick). That is at most **10.2 s** after the Gateway goes silent, because the next poll can be up to 5 s away. This is the same bound as before. Recovery is faster: re-asks continue every second, so "responding again" follows within about 1 s of the Gateway's return. - **Cost:** about 10 bytes per second on the serial link, and only while the Gateway is not answering. ## Changed assertions (on purpose) | Test | Change | Why | |---|---|---| | `renderer/test/t5-connection-nodes-card` "Refresh asks main for the roster while the link is up" | Replaced by "has no Refresh button: the roster poll and every reconnect ask for it (#132)" | The button is removed | | `t5-connection-nodes-card` "Refresh is disabled and the rows give way to a hint while the link is down" | Renamed "the rows give way to a hint while the link is down"; the `Refresh … toBeDisabled()` line dropped. Hint and row assertions are unchanged | The button is removed | | `main/app/pushed-rules.seam` "device:status: the link's action, and Refresh only while a roster request can go" | The `canRefresh` fields dropped; asserts that no push carries `canRefresh`. The `action` assertions are unchanged | `canRefresh` is removed | | `main/gateway/link-state` "every push carries it, and Refresh's availability as the node layer hands it in" | Asserts `action` and that `canRefresh` is absent | `linkFacts` lost its `canRefresh` parameter | | `main/nodes/node-status-wire` describe "canRequestRoster (R3.4a: Connection's Refresh)" | Describe name only → "(the poll, its re-ask and the seed on connect)". Assertions unchanged | Wording | Everything else keeps its assertions. That includes the GatewayWatch G1 mechanics test (`gateway-health` "counts from the oldest unanswered request — a later poll does not restart it"). The design keeps those mechanics and adds the re-ask beside them, so the test needed no change. Every existing roster test, including "marks nodes absent from a completed burst offline" (delta isolation), passes unchanged. ## New tests, each shown able to fail To prove each one can fail, I swapped in the base commit's production files and ran the new tests against them: 21 failed. I also broke the fix on purpose, which the table marks *mutated*. | Item | Test | Fails on | |---|---|---| | M2 | `node-status-wire` "a reply arriving across a new request is kept", "replies queued behind each other: a request in the second one keeps it whole" | base; *mutated* (`beginBurst` always clears) | | M2 | `node-status-wire` "a board missing from a whole reply is still offline by it", "with no reply arriving, a request still isolates … earlier deltas" | Guards that pruning still works (pass on both) | | G1 | `gateway-health` "the re-ask" ×4 (timing, room for four re-asks, never unarmed or paused, one lost reply never raises) | base | | G1 | `roster-poll.seam` "the poll's reply is lost: it is asked again within a second, and nothing flashes" | base (it flashes); *mutated* (no re-ask) | | G1 | `roster-poll.seam` "a real outage still raises it: within 5.2 s of the first unanswered request" (and ≤ 10.2 s after silence) | base; *mutated* | | M4 | `app-services` "after Disconnect then Connect / after an error then Retry, the released bridge no longer drives the link" | base (the orphan's late open makes the link `connected`) | | M4 | `serial-bridge` "stopped while looking for the board", "stopped while the port is opening: the open … is released", "… a failed open schedules no retry" | base | | M7 | `roster-poll.seam` "the boards on the bus were last seen at the Retry" | base | | M3 | `node-status-wire` v2 END ×2; `roster-poll.seam` "your boards stay unknown, not offline, so Hide and Forget stay refused" | base | | Refresh | `roster-poll.seam` "no nodes:refresh intent, and the 5 s poll writes the roster request"; T5 "has no Refresh button" | base | | **Soak** | `roster-poll.seam` "30 simulated minutes of lost and late replies": a simulated PanelBridge (4 boards plus its node-0 row). Replies are lost 25% of the time, at most 2 in a row. Delays are 2–50 ms (60%), 0.8–1.6 s (25%) or 2.5–4.5 s (15%), always within 4.5 s of the first unanswered request. Lines trickle 0–300 ms apart, in FIFO order, so requests land inside replies. Asserts: no `nodes:model` push ever shows a board not online; no node offline/online event; no extra last-seen save; no not-responding push or event; and more than 360 asks over the run | base; *mutated* M2 (false offline); *mutated* G1 (flash). Also run with 8 other seeds: all pass | **Loss threshold**, as the soak defines it: below it, the Gateway leaves no request unanswered for 5 s. In practice that is at most 4 lost asks in a row. ## API guard diff `src/shared/app/ipc.compat.test-d.ts`: ```diff @@ -86,6 +86,12 @@ // `CockpitSnapshot.panels` holds only boards in your cockpit, and `BoardPanel.placed` (always true // for those) was removed. // +// Fix track #132 (remove Refresh, user 2026-10-17): Connection › Connected nodes lost its Refresh +// button — the 5 s roster poll, its re-ask and every reconnect already ask for the roster. The +// intent `nodes:refresh` (CTRL.nodesRefresh, `refreshNodes`) was removed from the frozen copy +// below, and `DeviceStatusPush` lost `canRefresh` (its only reader was that button; a type change +// only, no channel renamed). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -227,7 +233,6 @@ interface OldCTRL { readonly captureToggle: 'capture:toggle' readonly replayOpen: 'replay:open' readonly hidAvailability: 'hid:availability' - readonly nodesRefresh: 'nodes:refresh' readonly nodesGet: 'nodes:get' readonly nodeMappingSet: 'node-mapping:set' readonly nodeMappingAdopt: 'node-mapping:adopt' @@ -288,7 +293,6 @@ interface OldSkyhawkApi { toggleCapture(): Promise<OldCaptureState> openReplay(): Promise<ReplayLoad> getHidAvailability(): Promise<HidAvailability> - refreshNodes(): Promise<void> getNodes(): Promise<NodeSnapshot> setNodeMapping(nodeId: number, patch: MappingPatch): Promise<NodeMappingResult> adoptNode(nodeId: number, spec: AdoptSpec): Promise<NodeMappingResult> ``` ## Checks 1360 tests pass (1339 before). `lint` 0/0, `lint:deps` clean, `typecheck` (3 projects) clean. ## Not settled - **A single lost line inside a reply** still gives a one-poll false offline: a row is lost, then the END reconciles. The END carries PanelBridge's row count (it includes node 0), which could guard against this, but that is a new rule beyond these decisions and isn't done here. - **#132's Refresh row** also says "Update the design board's Connection board too". That is the design artifact, not this repo, so it isn't in this PR.
…ad keeps the app's state (#149) Three items from the fix track's chunk 3: N6, X11 and N1. Each one changes behaviour on purpose, as decided on 2026-10-16. One commit per item, plus a small follow-up refactor. N7 (re-reading the HID controls on each connect) was dropped. A mismatched reflash isn't a realistic case, and the HID controls come from `HIDControls.h`, built into the client, so a re-read could never change anything in the app. ## Before → after | Item | Before | After | |---|---|---| | **N6** calibrate badge | The per-axis CAL/RAW badge opened calibration with the link down, while the Calibrate button was greyed out. | One rule in main (`tabState`): calibration needs the serial link. It drives `calibrate.enabled` and the new `tab.badgeOpens`. With the link down a badge still shows its state, greyed out like the button. | | **X11** NACK names | Two functions. `nackRefusalName` named only the 5 reachable reasons. `nackReasonName` named every reason in the table. | One `nackName()` built from `CAL_NACK_REASONS`. Both callers use it: CalLink's refusal message and the failure's `reasonName`. | | **N1** reload | On `relay:status`, main cleared its page-held copies, so a reload lost state main still had. | Main keeps that state. The bundle carries it, and the Cockpit snapshot keeps the session's peak W. | ## NACK name differences The 5 reachable reasons are unchanged: `BAD_INDEX`, `BAD_ORDER`, `NO_SESSION`, `NO_STORAGE`, `BAD_DEADZONE`. | Reason | Refusal message (`device refused (…)`), before → after | `reasonName`, before → after | |---|---|---| | 0x1–0x3 (`BAD_CRC`, `BAD_LENGTH`, `BAD_TYPE`) | `reason 0x1` → `BAD_CRC` (and so on) | unchanged (name) | | unknown byte, e.g. 0x9 | unchanged (`reason 0x9`) | `0x9` → `reason 0x9` | Neither difference is visible in normal use. The framing layer stops 0x1–0x3 before a NACK can be sent. `reasonName` is not displayed anywhere; the dialog shows `message`. ## Rehydrated values (N1) | Value | Where it comes back | |---|---| | Recording on/off (and the last stop's event count) | `relay:status.relayFacts.recording` | | Replay file | `relay:status.relayFacts.replay` | | Serial-monitor flag | `relay:status.device.serialMonitor` | | Aircraft name | `relay:status.aircraft` (new field: the last `aircraft:changed`) | | Telemetry gauges | `relay:status.telemetry` (new field: the last `telemetry:tick`, relaying or not) | | Peak W | the `cockpit:snapshot` pushed on load; `CockpitSnapshotHost.rendererLoaded` keeps the session peak | | Link-down time | `relay:status.device.linkDownAt` | These still start over on a reload: - the open tab and open dialogs (view state); - the calibration view (R3.1, `cal:attach`), so a calibration dialog never resumes. Removed, since nothing calls them now: `DcsRelay.rendererLoaded`, `GatewayConnection.rendererLoaded`, `SerialTrafficMonitor.rendererLoaded` and `LinkDownClock.pageLoaded`. Follow-up refactor: `SerialTrafficMonitor` kept a second copy of its on/off (`shownOn`) only so a reload could clear it. With that reset gone the two never differ, so `device:status` now reads the monitor's own flag. ## API guard (`ipc.compat.test-d.ts`) Updated on purpose, with no channel, intent or method added, removed or renamed: - `RelayStatus` gained `aircraft` and `telemetry`; - `CalibrationTabState` gained `badgeOpens` (type only). The header note sits after the Refresh-removal note from #150. ## Changed assertions | Test | Was | Now | Why | |---|---|---|---| | `main/app/pushed-rules.seam.test.ts` "the serial monitor's flag … a reloaded page **starts over** (N1)" → "… **keeps it** (N1)" | `serialMonitor: false`, `linkDownAt` undefined after `relay:status` | `serialMonitor: true`, `linkDownAt` = the drop time | N1 | | `main/nodes/cockpit-host.test.ts` "a renderer reload **starts the peak over** …" → "… **keeps the session peak** (N1)" | peak 12 W after reload | 24 W | N1 | | `main/gateway/link-state.test.ts` "a reloaded page has none until the link next drops (N1)" | — | **removed** | It tested `LinkDownClock.pageLoaded`, which N1 removes | Test infrastructure changed, with no assertion changes: - **`skyhawk-mock.ts`:** `getStatus` no longer resets the page-held copies and returns `aircraft` and `telemetry`. - **`ipc-adapter.test.ts`:** the fake main's clock starts from the link state main had already pushed (it used to get this from `pageLoaded`). Its bundle has default `aircraft` and `telemetry`. - **`render.ts`:** new `reloadRenderer(ui, page)` helper. It unmounts the page, unsubscribes, resets the stores, then subscribes and pulls again while main keeps running. - **`test-harness/services.ts`:** `capture:toggle` and `replay:open` are now registered through `registerDcsIpc`, over fake dialogs. Every other test keeps its assertions. ## New tests (each run against a reverted fix to check that it fails) - **N6:** - `workflow-host.test.ts` (`tabState`, and the host across a drop). - `t25` "with the link down a badge shows its state but does not open calibration". - Fail when `badgeOpens` follows anything but the link, or when the renderer stops gating the badge. - **X11:** `codec.test.ts` names every `CAL_NACK_REASONS` entry, plus 0x0, 0x9 and 0xff. - **N1:** - `main/app/reload.seam.test.ts` covers all seven values through the services harness. Seven separate one-at-a-time mutations, each putting back one old reset, all fail it. - `t38-reload-rehydrate.test.tsx` covers each value through `reloadRenderer`, plus a check that the tab still resets. Each of the old resets, put back in the mock or the store, fails its case. - `ipc-adapter.test.ts` adds a case using the existing `freshStore` reload pattern. ## Checks - Tests: 1372 pass (1360 on `fix/chunk-3` with #150; this PR adds 13 and removes 1). - Lint: 0 errors, 0 warnings. - `lint:deps`: clean. - Typecheck: clean on all 3 projects. Part of #132
… while unplugged, incomplete replies ignored (#151) The last PR of fix-track chunk 3. Two behaviours change on purpose (decided 2026-10-16); nothing else does. ## 1. X2 / R5 / S10 — one "on the bus" rule | | Before | After | |---|---|---| | **Fresh PDU telemetry** | Made a board visible but not online. A registry PDU seen by its pages alone (no roster row, or a complete roster that left it out) was a "new but offline" board: not counted, not news, never adoptable (S10). | Fresh PDU pages are evidence in the one availability rule (`node-model.ts nodeAvailability` → `{ availability, evidence: 'roster' \| 'telemetry' \| 'none' }`). Order: roster present → online (roster); fresh PDU pages **newer than the roster's last "absent" reading** → online (telemetry); roster absent / complete roster without it → offline (roster); else unknown (none). `availabilityOf` (`node-model.ts`) is the one place that weighs them. | | **Hide / Forget** | `canHide` / `canForget` treated any fresh pages as live, separately from the availability, so the panel could read OFFLINE beside the "Online — can't be removed" footer. | The gate reads only the one availability. NodeHub's `offlineSignals` computes it with `availabilityOf` over the same signals; the footer reads the model's. `OfflineSignals.pduTelemetry` and `offline-actions.ts pduTelemetryLive` are gone. | | **Every "on the bus" surface** | While the Gateway was unplugged, the Cockpit's "New on the bus · N / Adopt…", Settings › About's On the bus column ("yes" / "offline") and Connected nodes all read the stale roster as live. | One `onBusNow(entry, live)` (`node-model.ts`) = link up and online. `isReportableNew(e, live)` is built on it. Every surface reads it: the Cockpit CTA (`newCount`), the strays (`strayNew`), the Overview's new-devices notice (`newDevices`), Connected nodes (`nodesSummary`: `COCKPIT_IDLE.nodesCard` while down) and About's registry rows (`registryOnBus`: `—` while down; a hidden row still reads `hidden`). They all go quiet while unplugged and come back when the link does. | | **Adopt evidence** | An online board with no status row read "not reporting right now". | A PDU online by its pages reads `telemetry: 12 V …` or `telemetry: board status`. | **X2 variants deleted** (the co-located answers from the rewrite): - `cockpit-snapshot/index.ts`: the `live ? strays : []` gate is now `strayNew(..., live)`. The X2 rows are gone from both variant lists in the header. - `attention.ts`: "New on the bus counts whatever the last model said" is gone. `newCount(model, live)` replaces it. - `summaries.ts`: "Connected nodes reads the last model whatever the link" is gone. `nodesSummary(..., live)` replaces it, and `newDevices`' own `if (!live)` is folded into `isReportableNew`. - `registry-rows.ts`: "a CAN row reads the last model whatever the link" is gone. - `node-model.ts`: the two "visible vs online" copies of the telemetry rule are gone. `sending` and `availability` both read `node-telem.ts pduPagesLive`, which `offline-actions.ts pduTelemetryLive` reads too. ### Newest evidence wins (follow-up commit, from review) The first commit let fresh pages win whatever the roster said. A PDU that lost power then read online until its last page went stale (10 s) instead of when the roster's heartbeat timeout reported it absent (3 s), so its outage formed about 7 s late. Now `node-telem.ts pduPagesLive(t, now, absentAt)`, still the one freshness helper, with `newestPduPageAt` next to it, counts pages only while the newest one arrived after the roster's last "absent" reading for that board (`seen.offlineAt`, while its row is absent): - **The roster says absent after the last page:** the PDU is offline at once and folds into its outage on the roster's timing. - **Pages arrive after that "absent":** the PDU is online again. - **No absent reading at all:** pages count. That covers no row, or a complete roster that left it out, which is the S10 case. - **A tie** goes to the roster. `NodeModelInput` gains `now` (when the telemetry snapshot was taken) so page ages can be compared with the roster's times. ## 2. A lost line inside a reply - **END count semantics (confirmed in the firmware).** `PanelBridge.cpp emitAllNodes` at the pinned commit `a78dd90` and on OpenSkyhawk `main`: - it sends `emitSelf()`, then one row per alive node, then `_NODE_STATUS_END <count>`; - `count` is decimal and starts at 1, so it **includes the node-0 row**: `1` means bridge only; - every row in a reply has `present = 1`; - the bridge writes the burst synchronously, so no delta can interleave with it. One caveat: the A-4 worktree named in the brief is on `feat/pdu-telemetry-216`, which still has the pre-#344 v2 version (no node-0 row, count from 0). The client is pinned to the v3 one. - **Before.** One `_NODE_STATUS` line lost inside a reply made that board offline for one poll. That is a false offline row, an extra save and possibly a false PDU outage, all against the #290 "no false offline" gate. - **After.** `NodeRoster` compares the END count with the distinct NODE_IDs the reply brought. - A reply starts at its node-0 row. From there its rows are the reply: not the deltas before it, and not what an earlier reply with a lost END brought. - On a mismatch, absences are not reconciled. The roster does not count as complete (so remembered boards stay `unknown`, not `offline`), and the next whole reply decides. - NodeHub logs one debug line (`roster reply incomplete: 2 of 3 rows; absences not reconciled`), with no event row. - An END with no count completes as before: there is nothing to check it against, and no v3 bridge sends one. - **No firmware change.** ## Changed assertions (each on purpose) | Test | Was | Now | Why | |---|---|---|---| | T9 `stays from the last model while the link is down (R5 today)` → `goes quiet while the link is down, and returns when it is back (R5, #132)` | CTA "New on the bus · 1" and Adopt… enabled with the link down | CTA and Adopt… absent while down, back on reconnect | Contract R5 / F12 (X2 final): `onBusNow(live)` | | T32 `unplugged: … CAN nodes still read "yes" (R5 today)` → `… no CAN row says it is on the bus (R5, #132)` | `#1 … yes` while unplugged | Every CAN row `—` (hidden stays `hidden`), back to `yes` on reconnect | Contract X7 / R5: on the bus means now | | T32 `opened while unplugged: the same` | `#1 … yes` | `#1 … —` | Same | | `registry-rows.test` `lists each registry row … before and after the model` (built with link down) | rows 1/2 `yes` / `offline` | `—` / `—` | The X2 variant's own unit pin; S10 / R5 | | `offline-actions.test` `allowed only offline with no fresh PDU pages` → `allowed only offline` | `gate({ availability: 'offline' \| 'unknown', pduTelemetry: true })` refused | The gate reads only `{ availability }`; that loop is removed | Newest-evidence rule: pages are weighed inside the availability (`availabilityOf`), so a separate "pages win" signal would contradict it. The footer and main agreement is still pinned by `node-hub.test` "one rule for the footer and main", unchanged. | | `offline-actions.test` `PDU pages count only while fresh, and only for that NODE_ID` | `pduTelemetryLive` | Removed with the helper | Replaced by `node-telem.test` "pduPagesLive: the newest evidence wins" | | main `phase0-probes` `M3: a v2 bridge — END completes the roster, added boards read offline, Forget allowed` → `M3 / #132 (fixed): a reply whose rows were dropped completes nothing` | END `2` with its only (malformed, 22-hex) row dropped completed the roster: board `offline`, Forget allowed | Incomplete reply: board `unknown`, Forget refused | The new count check. That row was never a real v2 row (v2 is 26 hex, already handled by M3) | Every other assertion is unchanged. Some call sites changed without changing what they assert: `isReportableNew`, `strayNew`, `newCount` and `nodesSummary` gained the explicit `live` argument (`true` in the old cases), `boardActions` lost its `pduTelemetry` argument, and `buildNodeModel` inputs gained `now`. The soak's seeded PRNG now also draws for line drops, so its random sequence shifted, but its assertions are the same. ## New tests, each shown able to fail Each was run against a temporarily broken source, then restored: | Test | Broken source | Result | |---|---|---| | Telemetry-only PDU counts online: `node-model.test` "the one availability rule" (rule, PDU in your cockpit roster-or-not, stale pages not); `node-hub.test` (online, and Hide / Forget refused) | Drop the telemetry branch | 5 fail | | No unadoptable new-but-offline board: `node-model.test` (a pages-only registry PDU is online, reportable and an Adopt row with `kind: pdu` and telemetry evidence; every new board in the model is online) | Drop the telemetry branch | Covered by the 5 above | | Every on-the-bus surface goes quiet with the link down and returns when it is up: `cockpit-snapshot/on-the-bus.test.ts` (CTA, strays incl. band, Overview notice, Connected nodes, About), through the snapshot builder and through `CockpitSnapshotHost` unplug → replug with no new model; plus T9 / T32 above | Make `onBusNow` ignore `live` and remove the two `!live` guards | 12 fail | | Incomplete reply never marks a board offline; a whole reply still does: `node-status-wire.test` "a reply missing a line reconciles nothing" (lost row, lost node-0 row, lost END, a short first reply leaves the roster incomplete, deltas before node 0 not counted, END without a count); `node-hub.test` (one debug line, no event row, a later whole reply still logs `offline`) | Disable the count check | 5 fail (incl. the soak) | | Same, the node-0 framing | Disable the node-0 reset | "deltas before node 0" fails | | Newest evidence wins: `node-hub.test` "a PDU's pages against the roster". A PDU that loses power is offline at 13.2 s (absent at 13 s, last page at 10 s, still fresh) and folds into its outage (`at: 13000`), shown as one Attention item, with the footer and main both allowing Hide. Pages after the absent bring it back (outage gone; footer, Forget and Hide all refuse). Also `node-model.test` (absent before vs after the newest page) and `node-telem.test` (`pduPagesLive` / `newestPduPageAt`) | Ignore `absentAt` in `pduPagesLive` | 3 fail | | Same: every surface and the gate agree | Make `NodeHub.offlineSignals` drop the pages | 4 fail (footer-vs-main matrix, M1/C3 probe, both new hub cases) | | #150's 30-minute soak: one in five replies now loses a single random line (a board row, node 0 or END). It asserts no false offline, no extra saves, no node event rows and no "not responding" flash, and that over 50 lines were dropped | Disable the count check | Fails: a false offline | ## Checks - `npm test`: 1394 passed (baseline 1372, +22) - `npm run lint`: 0 / 0 - `npm run lint:deps`: clean - `npm run typecheck` (3 projects): clean The renderer only displays: no renderer source changed. Part of #132
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix-track chunk 3 (#132): link and roster lifecycle, HID, reload. Behaviour changes on purpose; final answers decided 2026-10-16 (see #135), plus two items added by the user on 2026-10-17.
N7 (HID controls follow a reflash) dropped: the hat/button list is the client's synced copy, and a mismatched reflash isn't a realistic case.
Tests: 1339 → 1394.
Rig test 2026-10-17 (Gateway + one panel group, debug log on):
_NODE_STATUS_END 2), no interval over 6 s, Center_Armament present in all 305 rows, TEC/REC 0 and no flags throughout, no re-asks, no incomplete replies, no offline or not-responding rowsBEGIN_COMMIT_OVERRIDE
fix(nodes): steady roster polling — no lost replies, no false not-responding, no orphan bridge (#150)
fix(hid): calibrate badge follows the link, one NACK name table, reload keeps the app's state (#149)
fix(nodes): one "on the bus" rule — telemetry counts as online, quiet while unplugged, incomplete replies ignored (#151)
END_COMMIT_OVERRIDE
Closes #132