Skip to content

[codex] Fix panadapter restore across reconnects#3429

Merged
jensenpat merged 3 commits into
aethersdr:mainfrom
rfoust:codex/reconnect-panadapter-session-reuse
Jun 10, 2026
Merged

[codex] Fix panadapter restore across reconnects#3429
jensenpat merged 3 commits into
aethersdr:mainfrom
rfoust:codex/reconnect-panadapter-session-reuse

Conversation

@rfoust

@rfoust rfoust commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

This fixes the dead/stale panadapter behavior seen after reconnect by reconciling previous-session UI models with the radio-restored GUI client state at connect time.

When a connection comes back, AetherSDR now stages the previous slice and panadapter models, reclaims the ones whose IDs reappear in radio status, and prunes only models that do not return. This preserves existing UI wiring for restored panadapters while still removing genuinely stale session data.

Context

The original symptom was an extra non-functional panadapter/waterfall after reconnect. An earlier local cleanup attempt moved too much cleanup to disconnect time, which caused existing panadapters to disappear during radio reboot while the reconnect dialog was active. This PR replaces that approach with connect-time reconciliation.

Details

  • Preserve current slice and panadapter models through disconnect.
  • Stage prior session models at connect time, then reclaim models whose IDs return in slice/pan status.
  • Remove only staged models that do not reappear after the reconnect restore window.
  • Treat staged panadapters as known for ownership classification while still removing explicitly foreign panadapters when a different client_handle arrives.
  • Keep the panadapter connecting overlay visible during disconnect so stale queued frame callbacks do not clear it prematurely.
  • Re-push dimensions and dBm setup for the specific reclaimed panadapter, not just the active panadapter, so multi-panadapter restores do not leave non-active panes at radio defaults.

Validation

  • git diff --check
  • cmake --build build --parallel

The local build completed and linked AetherSDR.app/Contents/MacOS/AetherSDR. The build still reports unrelated existing warnings in other files.

@rfoust rfoust self-assigned this Jun 6, 2026
@rfoust rfoust marked this pull request as ready for review June 6, 2026 12:16
@rfoust rfoust requested a review from a team as a code owner June 6, 2026 12:16
Copilot AI review requested due to automatic review settings June 6, 2026 12:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes stale/dead panadapter behavior after reconnect by reconciling prior-session UI models with the radio-restored GUI client state at connect time, so restored panadapters/slices reuse existing UI wiring while truly missing ones are pruned.

Changes:

  • Stage existing slice/panadapter models at connect time, reclaiming them when matching IDs reappear in status, and pruning only those that never return.
  • Adjust ownership handling so staged panadapters are treated as “known” while still removing explicitly foreign panadapters when a different client_handle appears.
  • Prevent the panadapter “connecting” overlay from being cleared by queued frame callbacks while disconnected, and configure reclaimed pans by ID (not just the active pan).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/models/RadioModel.h Adds stale-session model storage and generation tracking, and updates configurePan to target a specific pan ID.
src/models/RadioModel.cpp Implements connect-time staging/reclaim/prune for slices and panadapters; updates status/ownership handling and per-pan configuration.
src/gui/MainWindow.cpp Keeps the panadapter connection animation from being cleared while disconnected.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rfoust — the stage-on-connect / reclaim-by-ID / prune-what-didn't-return shape is a clean way to fix this, and the surgical scope (only MainWindow.cpp + RadioModel.{h,cpp}) looks appropriate.

A few things worth a closer look before merge:

1. Hard-coded 2 s prune window in registerAsGuiClient (RadioModel.cpp:2291)

sendCmd("slice list",
    [this](int code3, const QString& body) {
        const quint64 restoreGeneration = m_sessionModelGeneration;
        QTimer::singleShot(2000, this, [this, restoreGeneration]() {
            pruneStaleSessionModels(restoreGeneration);
        });

The timer is armed as soon as the slice list callback fires — i.e. when the slice list response comes back, not when all the per-slice/per-pan status messages have actually been replayed. On a healthy LAN that's plenty; on a routed/SmartLink reconnect with several slices + pans (status burst can stretch out past a second), or if the radio is rebooting and a second reconnect race-stacks, there's a real risk of pruning a panadapter that would have been reclaimed by a status message arriving at ~2.1 s. The symptom would be the same dead pan this PR is fixing, just rarer.

Two options that seem better than the fixed window:

  • Trigger the prune when some explicit completion signal arrives (e.g. an empty-pending-deferred-status sweep, or the activity meter going idle for N ms), rather than a fixed delay from the slice list reply.
  • At minimum, bump it to something safer (~5 s) and add a debug log when prune actually deletes something so this is observable in field logs.

The generation counter correctly guards the disconnect-during-window race — good — but it doesn't help with this "slow status replay" case since the generation hasn't moved.

2. Stream IDs across a radio reboot when reclaiming PanadapterModel

ensureOwnedPanadapter reclaims by panId, preserving the PanadapterModel instance and its existing signal/slot connections. After a radio reboot (vs. a TCP drop), the underlying pan_stream_id / wf_stream_id will be re-issued by the radio, and the old IDs are now stale w.r.t. m_panStream. I think this works out (incoming status will call applyStatus and re-emit waterfallIdChanged, the existing updateStreamFilters connection still fires, etc.), but it's worth double-checking that:

  • m_panStream doesn't end up with a registration for the old stream ID lingering from the prior session, and
  • setWaterfallConfigured(false) (which you do in stageSessionModelsForReconnect) is enough to force re-configureWaterfall() for the new IDs.

A git diff --check + clean build is fine but won't catch this — would be good to verify on real hardware with a reboot, not just a disconnect/reconnect.

3. Stale-pan cleanup in onStatusReceived Ignore branch (RadioModel.cpp:~4113)

if (auto it = m_stalePanadapters.find(panId); ...) {
    rejectedPan = it.value();
    m_stalePanadapters.erase(it);
} else if (kvs.contains(QStringLiteral("client_handle"))) {
    rejectedPan = m_panadapters.take(panId);
}

This now removes an owned (non-stale) pan whenever an Ignore decision is reached with an explicit client_handle kv — that's new behavior, not just a stale-map cleanup. If classifyOwnedStatus ever returns Ignore for an already-claimed pan because a transient/partial status kv arrived (e.g. a status fragment without our handle), we'd silently rip down the user's panadapter. Worth a sanity check that Ignore + kvs.contains("client_handle") is only reached for genuinely-foreign claims.

Smaller notes

  • configurePan signature change to take panId and the pan->panId() plumbing in handlePanadapterStatus is the right fix for the multi-pan case — nice catch.
  • MainWindow.cpp finishPanadapterConnectionAnimation early-return when disconnected is the right shape; the overlay stays put for reconnect.
  • Memory ownership looks correct — the stage maps hold the same this-parented pointers, and deleteLater() on prune/foreign-takeover is the Qt-idiomatic path.

Otherwise looks good. Mostly the prune-window timing is what I'd want to harden before this lands.


🤖 aethersdr-agent · cost: $12.7698 · model: claude-opus-4-7

@rfoust

rfoust commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a follow-up commit addressing the review feedback: 658c46d1 fix(radio): harden reconnect panadapter restore Principle XI.

Changes made:

  • Increased the reconnect stale-session prune grace window from 2s to 5s with a named kSessionRestorePruneDelayMs constant.
  • Tightened the foreign-owner Ignore path so it only removes staged stale panadapters. Already-claimed current panadapters are no longer torn down on a foreign client_handle status; that case is ignored with a debug log.
  • Made waterfall configuration pan-specific, mirroring the earlier pan-specific configurePan fix. Reclaimed non-active waterfalls now get configured by their owning pan’s waterfall ID instead of relying on activeWfId().

I also rechecked the stream-ID concern: disconnect still calls m_panStream->clearRegisteredStreams(), and restored pan/waterfall status re-registers the current stream IDs. The added per-pan waterfall configuration covers the remaining active-only gap.

Validation:

  • git diff --check
  • cmake --build build --parallel

The local build completed and linked AetherSDR.app/Contents/MacOS/AetherSDR. The build still reports unrelated existing warnings in other files.

I still recommend real-radio reboot testing before merge because the status replay timing is hardware/runtime behavior rather than something the build can prove.

@rfoust rfoust marked this pull request as draft June 6, 2026 14:11
@NF0T NF0T assigned NF0T and unassigned NF0T Jun 6, 2026
@M7HNF-Ian

M7HNF-Ian commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Ran a real-radio reboot test of this branch — the validation noted as the outstanding item. Happy to report it passes.

Build: codex/reconnect-panadapter-session-reuse @ 658c46d1, clean build on macOS (Apple Silicon).
Hardware: FLEX-6600, firmware 4.2.18.41174.

Method: Connected with 1 panadapter + 2 slices (A 21.074 DIGU, B 21.172 DIGU), spectrum and waterfall both live. Triggered a genuine radio reboot via Radio Setup → Reboot Radio (so the radio re-issues fresh pan_stream_id/wf_stream_id — the exact case in concern #2 above), then let AetherSDR auto-reconnect.

Result:

  • During reboot: "connection lost, reconnecting automatically", PAN/WF at 0.0 FPS as expected.
  • After ~40s the client auto-reconnected on its own.
  • ✅ Both slices restored at their correct frequencies.
  • ✅ Panadapter spectrum live again (24.5 FPS).
  • Waterfall live and scrolling again (24.5 FPS) — i.e. the wf_stream_id re-registered correctly against the radio's freshly-issued stream IDs.
  • ✅ FT8 decodes flowing on the pan post-reconnect (confirming live IQ, not a static redraw).
  • ✅ No dead/frozen panadapter.

Caveat: this was a single-panadapter configuration (two slices on one pan), so it exercises the reclaim-by-ID + stream re-registration path but not the per-pan-vs-active-pan waterfall fix (configureWaterfall(waterfallId)), which needs 2+ pans.

Screenshots captured if useful. Nice work @rfoust — the reboot path behaves exactly as intended. 73

@M7HNF-Ian

Copy link
Copy Markdown
Contributor

Follow-up: re-ran with two panadapters to close the caveat from my previous comment, and it passes cleanly.

Setup: Top pan with Slices A (21.074 DIGU) + B (21.172 DIGU); a second pan with Slice C (21.200 USB). Both spectrum + waterfall live before the reboot.

Reboot method this time: physical power-button reboot on the FLEX-6600 (not the in-app button — see note below), with AetherSDR left running so the in-memory session staging/reclaim path is exercised.

Result after auto-reconnect (~40s):

  • ✅ Both pans restored; both slices + Slice C at correct freqs.
  • Active pan (A+B): PAN 24.5 / WF 24.5 FPS.
  • Non-active pan (C): PAN 24.1 / WF 24.1 FPS — i.e. the per-pan configureWaterfall(waterfallId) change correctly re-registered the non-active pan's waterfall against the radio's freshly-issued stream IDs, not just the active pan's. This is the path that wasn't covered in the single-pan run.
  • ✅ During reconnect, both pans kept their "Reconnecting… / Restoring your session" overlay (the MainWindow.cpp animation-persistence change).
  • ✅ FT8 decodes flowing on both pans post-reconnect → live IQ, not a static redraw.

So the multi-pan reboot path looks solid on real hardware. 👍


Unrelated observation while testing (flagging, not blocking): the in-app Reboot Radio button in Radio Setup was present on first launch, but after one reboot/reconnect cycle it was gone from the dialog (Radio Information section) and didn't return on close+reopen. Had to use the physical button for the second test. Since the remote-reboot button (#3334) and the reconnect/session work both live on this branch, it might be a side effect of the session-restore changes — but it could equally be a pre-existing persisted-dialog quirk; I haven't confirmed root cause. Worth a quick look. 73

@M7HNF-Ian

Copy link
Copy Markdown
Contributor

Re the reboot-button observation in my earlier comment — I dug into it, and good news: it's not a side effect of this PR's session-restore work.

Root cause: the "Reboot Radio" button (#3334) is correctly added on every open, but setEnabled(isConnected()) disables it when Radio Setup is opened during the reconnect window, and its disabled stylesheet uses dim blue-grey tokens that blend into the dialog background — so a disabled button reads as absent rather than greyed-out. It reappears on reconnect via the existing connectionStateChanged hook. Confirmed with instrumented logging (button added with enabled=false when built mid-reconnect).

Fixed separately as a styling tweak in #3441 — nothing for this PR to address. Apologies for the earlier red herring!

@jensenpat jensenpat marked this pull request as ready for review June 9, 2026 23:15
rfoust and others added 3 commits June 9, 2026 16:50
Move reconnect cleanup to the post-connect restore path so transient disconnects keep existing panadapter UI alive while the radio reports the new session state.

Reclaim matching slice and panadapter models, prune only models that are not restored, and keep the reconnect overlay visible until a live frame arrives after reconnection.

Co-authored-by: Codex <noreply@openai.com>
Address PR review feedback by widening the reconnect restore prune grace window, limiting foreign-owner cleanup to staged stale panadapters, and configuring restored waterfalls by their owning panadapter rather than the active pan only.

Co-authored-by: Codex <noreply@openai.com>
…ross-radio

Review follow-ups on the reconnect staging/reclaim/prune design:

1. Reclaim staged pans only on a confirmed client_handle match. Staged
   pans are no longer treated as "known" by classifyOwnedStatus, so a
   handle-less status for a stale ID defers into m_pendingPanStatuses
   (replayed on claim) instead of Apply-reclaiming an ID that may have
   been reassigned to another client (SmartSDR) after a radio reboot.
   The foreign-owner-on-claimed-pan log is now a qCWarning.

2. Re-wire per-pan FPS / waterfall-line-duration reconcilers for
   reclaimed pans. Disconnect tears these connections down and only
   wirePanadapter() (via panadapterAdded) re-created them; reclaimed
   pans never re-emit panadapterAdded. New panadapterReclaimed signal +
   MainWindow::wirePanReconcilers() extracted from wirePanadapter().

3. Re-assert TX on reclaimed TX slices (aethersdr#145 semantics): after a reboot
   the radio recreates the slice with tx=1 but a dead tx_client_handle;
   fresh slices get "slice set N tx=1" from onSliceAdded, reclaimed
   slices now send it from the reclaim path.

4. Cross-radio guard: record chassis_serial at disconnect and drop (not
   stage) previous-session models when connecting to a different radio
   — slice indexes and stream IDs collide across radios, and a
   reclaimed SliceModel would drain queued commands at the wrong radio.
   LAN checks the discovery serial at stage time; WAN re-checks when
   the "info" reply delivers chassis_serial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Co-authored-by: Codex <noreply@openai.com>
@jensenpat jensenpat force-pushed the codex/reconnect-panadapter-session-reuse branch from 658c46d to 824762a Compare June 9, 2026 23:58
@jensenpat

Copy link
Copy Markdown
Collaborator

Did a deep follow-up review of this PR focused on three questions: do we reclaim the right pan/slices on reconnect, can we ever capture IDs that belong to SmartSDR (or another client), and do orphaned slices get cleaned up. The stage-at-connect / reclaim-by-ID / prune-what-didn't-return architecture is right, and it correctly formalizes what main was doing implicitly (slices were never cleaned across reconnects; pans were qDeleteAll'd at disconnect without ever emitting panadapterRemoved — that asymmetry was the original dead-pan bug). Four findings, all addressed in follow-up commits pushed to this branch (along with a rebase onto current main, which was clean):

1. Stale pans could be reclaimed without ownership confirmation (the SmartSDR-capture risk)
Widening knownPan to include staged pans made classifyOwnedStatus return Apply for handle-less statuses, and the Apply && stale branch then reclaimed on an unconfirmed ID match. After a reboot, Flex stream IDs are reassigned in creation order, so a staged 0x40000000 colliding with a SmartSDR-owned pan is the expected case — an incremental status without client_handle arriving before the ownership-bearing dump would have captured it, stamped our handle on it, and pushed display pan set commands at another client's pan.
Fix: knownPan is back to m_panadapters only and the Apply && stale branch is gone. Handle-less statuses for staged IDs now take the existing Defer path (m_pendingPanStatuses, #2222/#2228 machinery); reclaim happens exclusively on a confirmed Claim, with the deferred kvs replayed on claim as before. The foreign-owner-on-claimed-pan log was promoted to qCWarning since it firing means a misclaim happened.

2. Reclaimed pans lost their FPS / waterfall-line-duration reconcilers
The disconnect path explicitly disconnects and clears m_panFpsReconcileConnections / m_wfLineDurationReconcileConnections, and the only place they were re-created was wirePanadapter() — reached only via panadapterAdded, which reclaimed pans (correctly) never re-emit. After any reconnect, fpsReported / waterfallLineDurationReported reconciling was silently dead for the rest of the session.
Fix: new RadioModel::panadapterReclaimed(PanadapterModel*) signal, emitted from ensureOwnedPanadapter() on the reclaim path; the reconciler block was extracted from wirePanadapter() into MainWindow::wirePanReconcilers() and is re-run from a panadapterReclaimed handler. The full add path still doesn't re-run (no duplicate model→widget connections).

3. Reclaimed TX slices skipped the #145 TX re-claim
After a radio reboot the slice comes back with tx=1 but tx_client_handle pointing at our dead pre-reboot handle (or 0). Fresh slices re-assert slice set <id> tx=1 from MainWindow::onSliceAdded; reclaimed slices skip sliceAdded, so TX ownership could be left unclaimed after a reboot (the hardware tests above validated RX, not TX).
Fix: the reclaim path in handleSliceStatus re-sends slice set <id> tx=1 when the reclaimed slice is the TX slice.

4. Staging was keyed only by ID — switching radios would reclaim across radios
Slice indexes (0..n) and stream IDs (0x40000000…) collide near-certainly across different radios, so disconnect-from-A / connect-to-B would reclaim radio A's models against radio B — including draining a reclaimed SliceModel's queued commands at the wrong radio.
Fix: onDisconnected now records the session's chassis_serial; stageSessionModelsForReconnect() drops (rather than stages) the previous-session models when the LAN discovery serial differs, and registerAsGuiClient()'s info reply repeats the check for SmartLink/WAN connects where the serial isn't known at stage time.

Also verified during review: no textual conflicts with main; the #3391 handshake pipelining interacts benignly with the 5 s prune window (status replay now starts earlier relative to the timer); slice tab buttons survive reclaim via the infoChanged rebuild; pruned slices route through sliceRemoved so active-slice fallback works; and the relocated m_foreignSliceOwners clear preserves #2606 Multi-Flex semantics.

Remaining known limitation (unchanged from the earlier review round): the prune window is still a fixed 5 s armed from the slice list reply rather than an explicit restore-complete signal. On a very slow SmartLink restore a pan could still be pruned and then re-added fresh — old behavior, cosmetic, acceptable.

Thanks @rfoust for the clean architecture here, and @M7HNF-Ian for the hardware validation runs — a TX-after-reboot check on real hardware would be a welcome confirmation of fix 3.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after extensive review (see detailed findings comment above). Rebased onto main and hardened in 824762a: handle-confirmed pan reclaim (no SmartSDR ID capture), reclaimed-pan reconciler re-wiring, TX re-claim on reclaimed slices, and a cross-radio chassis_serial guard. Build verified locally; hardware reboot tests by @M7HNF-Ian.

@jensenpat

Copy link
Copy Markdown
Collaborator

Additional hardware validation on the rebased + hardened head (824762a): ~40 forced reconnect collisions with 1 and 2 panadapters open — 0 empty/dead panadapters after reconnect. Also confirmed a second, non-patched client connected to the same radio no longer ends up with orphaned pans, since the patched client no longer creates duplicate panafalls during its reconnect window. Merging once CodeQL completes.

@jensenpat jensenpat merged commit 7331bb2 into aethersdr:main Jun 10, 2026
6 checks passed
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ts (aethersdr#3429)

## Summary

This fixes the dead/stale panadapter behavior seen after reconnect by reconciling previous-session UI models with the radio-restored GUI client state at connect time.

When a connection comes back, AetherSDR now stages the previous slice and panadapter models, reclaims the ones whose IDs reappear in radio status, and prunes only models that do not return. This preserves existing UI wiring for restored panadapters while still removing genuinely stale session data.

## Context

The original symptom was an extra non-functional panadapter/waterfall after reconnect (aethersdr#3212 family). An earlier local cleanup attempt moved too much cleanup to disconnect time, which caused existing panadapters to disappear during radio reboot while the reconnect dialog was active. This PR replaces that approach with connect-time reconciliation.

## Details

- Preserve current slice and panadapter models through disconnect.
- Stage prior session models at connect time, then reclaim models whose IDs return in slice/pan status.
- Remove only staged models that do not reappear after the reconnect restore window (5 s, generation-guarded).
- Reclaim staged panadapters only on a confirmed client_handle match: handle-less statuses for staged IDs defer into m_pendingPanStatuses (replayed on claim) so an ID reassigned to another client (SmartSDR) after a reboot can never be captured; explicitly foreign statuses remove the stale local model.
- Re-wire per-pan FPS / waterfall line-duration reconcilers for reclaimed pans via a new panadapterReclaimed signal (disconnect tears them down; reclaimed pans never re-emit panadapterAdded).
- Re-assert TX (slice set N tx=1, aethersdr#145 semantics) on reclaimed TX slices, since they skip onSliceAdded.
- Cross-radio guard: record chassis_serial at disconnect and drop, rather than stage, previous-session models when connecting to a different radio (LAN checks discovery serial at stage time; WAN re-checks on the info reply).
- Keep the panadapter connecting overlay visible during disconnect so stale queued frame callbacks do not clear it prematurely.
- Re-push dimensions and dBm setup for the specific reclaimed panadapter, not just the active panadapter, so multi-panadapter restores do not leave non-active panes at radio defaults.

## Validation

- Real-hardware reboot tests by @M7HNF-Ian (FLEX-6600, single- and two-pan, in-app reboot and physical power-button): pans/slices restored, waterfalls live, overlay behavior correct.
- Rebased onto main (clean); local RelWithDebInfo build links with only pre-existing warnings.

Squashed-from: aethersdr#3429

Co-authored-by: Robbie Foust <2991296+rfoust@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pat Jensen <patjensen@gmail.com>
ten9876 added a commit that referenced this pull request Jun 27, 2026
…n (streams verb) (#3856) (#3857)

## What

Implements the radio-side display-stream **inventory + leak detection**
feature requested in #3856. It makes leaked / duplicate / lingering
panadapter & waterfall streams *on the radio* directly observable — a
class of bug `get pans` can never surface, because the client always
cleans up its own view on the radio's `removed` echo.

Two independent radio-authoritative layers behind one `streams` bridge
verb:

### Layer A — `streams` (VITA-49 UDP truth)
`PanadapterStream` records any FFT/waterfall packet for a stream id that
was **ever registered this session but is no longer registered** — a
stream we once owned and let go of that the radio keeps transmitting.
Keyed off *ever-registered ∧ not-now-registered* (not "live set
non-empty"), so it stays detectable after `pan close all` and never
mis-flags a freshly-created stream's registration-lag window. Catches
**continued-stream** leaks (the #268 class). `streams reset`
re-baselines.

### Layer B — `streams radio` (status-bookkeeping truth)
`RadioModel` now accumulates the radio-authoritative display-object set
(`m_radioDisplayPans` / `m_radioDisplayWaterfalls`) from **all**
`display pan` / `display waterfall` status — ours, foreign, and orphan —
pruned on the matching `removed`, independent of `m_panadapters` (which
only holds objects *we* own). The pure `DisplayInventory::classify()`
labels each object `ours` / `foreign` / `orphan` and flags **leaked
waterfalls** (parent panadapter gone) — the #3843 fingerprint. This
catches **resource-level lingering that emits no UDP**, which Layer A
cannot see.

`DisplayInventoryPolicy` is a pure, header-only function (mirroring
`RadioStatusOwnership` / `SliceRecreatePolicy`), with a unit test
(`display_inventory_policy_test`, 12 assertions) covering clean,
leaked-parent-gone, foreign-vs-orphan, no-parent-reported, and
unknown-own-handle cases.

Closes #3856.

## Bridge surface

| action | layer | effect |
|---|---|---|
| `streams` | A | `registeredPanStreams`, `registeredWfStreams`,
`orphanStreams` (`streamId`, `kind`, `packets`, `age_ms`) |
| `streams radio` (alias `inventory`) | B | radio display-object set
classified ours/foreign/orphan + `leakedWaterfalls` |
| `streams reset` | A | clear the orphan tally |

All `streams` actions are RX/observe only — none sends a radio command
or keys TX. Docs in `docs/automation-bridge.md`.

## Proven on a live FLEX-8400M (firmware 4.2.18)

`streams radio` tracked the inventory **exactly** across an add/close
cycle, every object `ownership: ours`, no false-positive leaks:

```
baseline:   radioPanCount=1 radioWaterfallCount=1   leakCount=0
after add:  radioPanCount=2 radioWaterfallCount=2   leakCount=0   (both ours, parentMissing=false)
after close:radioPanCount=1 radioWaterfallCount=1   leakCount=0
```

**Key firmware finding (answers the open #3843 question):** on this
radio, `display pan remove` **alone** makes the radio *also* remove the
waterfall — it echoes `display waterfall <id> removed`. A leak-repro
build that deliberately omitted `display panafall remove` produced the
**same clean result** (`leakedWaterfalls=[]`, waterfall count 2→1,
removal echo observed). So **neither #3843 symptom — continued UDP nor
lingering resource — reproduces on 8000-series / 4.2.18**: the radio
auto-cascades. The #3855 close-path fix remains correct per FlexLib and
matters for firmware that does **not** cascade (the #268 class).

Because this firmware never leaves an orphan, **Layer B's positive leak
detection is covered by the unit test** (synthetic parent-gone waterfall
→ flagged); live, it correctly reads clean with no false positives.
Layer A likewise reads `orphanCount:0` here (this firmware stops the
UDP), and is the guard for firmware that keeps streaming.

## Test plan

- [x] `display_inventory_policy_test` — 12/12 pass (pure classification
logic, incl. the leaked-waterfall case).
- [x] Full app builds clean (Ninja, RelWithDebInfo, arm64).
- [x] Live FLEX-8400M: `streams` + `streams radio` inventory accurate
across add/close; no false-positive leaks; firmware cascade behavior
characterized.
- [ ] Maintainer review.

## Notes / follow-ups
- The empirical cascade finding means Layer B's live positive-catch can
only be demonstrated on #268-class firmware (which leaves the orphan).
The detection logic is unit-tested.
- Layer B is positioned to plug into reconnect/duplicate-pan
reconciliation (#3429) — surfacing a radio-side pan the client doesn't
own — as a future step.

## Related
- #3856 — this feature request.
- #3855 / #3843 — the panafall close-path fix this observability
complements (the *send* side; this is the *observe* side).
- #268 — continued-UDP waterfall leak on older firmware (the case Layer
A guards).
- #3429 — reconnect reconciliation (future Layer B integration point).

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
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.

5 participants