Skip to content

fix(nodes): steady link and roster — no false offline or not-responding, one on-the-bus rule, reload keeps state - #152

Merged
mottihoresh merged 3 commits into
mainfrom
fix/chunk-3
Oct 17, 2026
Merged

mottihoresh merged 3 commits into
mainfrom
fix/chunk-3

Conversation

@mottihoresh

@mottihoresh mottihoresh commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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.

PR Change
#150 Roster polling: a new request never discards a reply in flight (no false offline); re-ask every 1 s inside the 5 s deadline, so "Gateway not responding" needs five lost replies in a row (a real outage still alerts in the same time); callbacks from a stopped serial bridge ignored (no orphan holding the port); Retry saves last seen; a v2 bridge's END no longer marks everything offline; the Connected-nodes Refresh button removed (user 2026-10-17)
#149 Calibrate badge follows the link rule; one NACK name table from the firmware sync; a window reload keeps recording, replay, serial monitor, aircraft, gauges, peak W and link-down time
#151 One "on the bus" rule: fresh PDU telemetry counts as online (newest evidence wins against the roster), every on-the-bus surface goes quiet while the Gateway is unplugged; a reply whose END count doesn't match its rows never marks a board offline

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

  • Soak, 25 min: 303 roster replies, every one complete (_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 rows
  • Unplug: Gateway "no device", every CAN board "—" on the bus; replug: back within seconds, last seen saved
  • Disconnect/Connect ×5: five clean reopens, no "port busy"
  • Reload while recording: still recording
  • Calibrate badge with the link down; no Refresh button

BEGIN_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

…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
@mottihoresh
mottihoresh marked this pull request as ready for review October 17, 2026 17:44
@mottihoresh
mottihoresh merged commit 894e179 into main Oct 17, 2026
4 checks passed
@mottihoresh
mottihoresh deleted the fix/chunk-3 branch October 17, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: chunk 3 — link and roster lifecycle, HID

1 participant