Skip to content

fix(model): ownership-gated auto-floor, stale-session detection + eviction, get clients verb (#3977, #3951)#3981

Merged
ten9876 merged 5 commits into
aethersdr:mainfrom
jensenpat:fix/3977-stale-session-floor
Jul 4, 2026
Merged

fix(model): ownership-gated auto-floor, stale-session detection + eviction, get clients verb (#3977, #3951)#3981
ten9876 merged 5 commits into
aethersdr:mainfrom
jensenpat:fix/3977-stale-session-floor

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Summary

Fixes #3977 (the root cause of #3951). Stacked on #3975 (first commit here is that PR's fix; review the second commit).

A still-alive earlier AetherSDR session keeps running its auto-floor tracker against the pan a newer session has reclaimed, ratcheting min_dbm/max_dbm in 0.1 dB steps and visibly bouncing the new session's trace every ~1–1.5 s (evidence: #3951's support bundle — ~250 foreign-handle status echoes, zero outbound dBm commands from the victim).

The three layers

  1. Auto-floor discipline (radio-authoritative ownership). PanadapterModel now parses client_handle from pan status — previously unparsed; ownership was assumed at creation. The dBm-range sender drops-and-warns when the radio-reported owner isn't us, so a superseded session goes quiet the moment the radio broadcasts the new owner. No timers, no new state machine.
  2. Reclaim eviction. When reconnect-reclaim adopts a staged pan, the predecessor handle recorded on it gets a client disconnect 0x<handle> (SmartSDR's takeover behavior, reusing the existing disconnectClientHandlesThen). Disconnecting a dead handle is a harmless error reply.
  3. Evidence-based zombie eviction. The S<handle>| status source — parsed by CommandParser but discarded at the statusReceived signal boundary — is now plumbed through (RadioConnection/WanConnection signature; RadioModel's public relay unchanged). Foreign writes to our pans' dBm range are tallied per handle and warned (first + every 25th). Three strikes and a radio-roster identity match (program AetherSDR + our own radio-reported station — not local settings, which can differ) → evict. Anything else — SmartSDR, a different station, an unidentifiable non-GUI writer — is logged forensics only, never touched.

Per review discussion: no display-side smoothing of foreign echoes — a foreign write to our pan is an anomaly to eliminate, not render gracefully, and smoothing would have masked #3951 instead of surfacing it.

get clients bridge verb (+ pan snapshot fields)

get pans could only show the symptom (minDbm drifting between polls). The new verb shows the culprit: radio client roster (handle/station/program/isUs), per-handle foreignPanWrites counters with last pan + timestamp, and evictedHandles. get pan/pans snapshots now carry clientHandle + ownedByUs. Documented in docs/automation-bridge.md, with tools/zombie_session_sim.py (raw-TCP zombie: registers as AetherSDR with the victim's station, replays display pan set … min_dbm=…) as the repro/regression harness.

Live proofs (FLEX-8400M, fw 4.2.18)

  • Detection: every simulated zombie was counted, attributed, and warned in the log; counters visible via get clients.
  • Reclaim eviction: a radio-initiated bounce mid-test (see below) forced a real reconnect → pan reclaim → the prior handle was evicted (evictedHandles populated). The victim recovered fully — new handle, pan reclaimed, clean roster.
  • Firmware finding worth recording: registering a second connection with the victim's own client gui UUID made 4.2.18 fire its own duplicate_client_id disconnects — modern firmware self-heals the exact weird behaviour in the spectrum of v2665 #3951 topology. The client-side eviction is the backstop for pre-4.2 firmware (the reporter's 8600 runs 4.1.3, where the zombie demonstrably survived).
  • Honest limitation: the full 3-strike eviction path (roster-identified GUI zombie surviving long enough) couldn't be provoked on this firmware — the radio kills duplicates first, and the radio's GUI slots were occupied so a distinct-UUID GUI zombie couldn't join. That path shares its identity check and disconnect helper with the live-proven reclaim eviction.

TX safety

No TX-path changes; the simulator and all tests are RX/display-only (display pan set min_dbm exclusively).

💻 Generated with Claude Code (claude-fable-5 7/2/26) with architecture by @jensenpat

🤖 Generated with Claude Code

jensenpat and others added 2 commits July 2, 2026 18:08
… falloff, plateau-gated smoothing

Fixes the two-release trace-quality regression chain behind aethersdr#3967 (and
aethersdr#3932), root-caused with per-commit builds and live same-band A/B grabs:

1. aethersdr#3836 (shipped 26.6.5) applied buildFftDisplayTrace's 5-tap spatial
   smooth at an unconditional 0.75 blend. It exists to melt the radio's RBW
   stair-steps when zoomed in, but at wide spans it low-passes everything:
   narrow carriers lose height and the noise floor defocuses ("out of
   focus", aethersdr#3932). The blend is now gated on the measured plateau fraction
   (adjacent equal-pixel bins): zoomed-in plateau runs still get the full
   blend, busy wide spans get none, with a ramp between so zooming never
   visibly mode-flips.

2. aethersdr#3958 (shipped 26.7.1) drew the per-pixel stroke from the distance to
   the segment's INFINITE line (dFdx-derived slope). On steep segments that
   line passes near the whole pixel column, so the stroke shot full-height
   spikes above and below the trace, and the fixed ±0.75 px smoothstep AA
   band read as a soft "blurred" stroke at 1x DPI. The stroke now uses the
   true distance to the three adjacent polyline segments (endpoint-clamped,
   matching the old geometry quads' coverage) with the old falloff: solid
   to halfWidth−1, 1 px fade, 1 px feather annulus at low alpha.

Also folds in PR aethersdr#3968's R16F-unconditional column format (float32 linear
filtering is optional on GLES and undetectable via
isTextureFormatSupported; half precision is ample for normalized 0..1
amplitudes) — though note that could not have caused aethersdr#3967: the reporter's
renderer string shows D3D11 on Intel HD 520 (FL 12.1), where R32F
filtering is mandatory and hardware-accelerated.

Live-proven on FLEX-8400M against the AM broadcast band: 4-way same-band
comparison (pre-aethersdr#3836 / post-aethersdr#3836 / 26.7.1 / this fix) shows the fix
restores the pre-aethersdr#3836 look; panstats confirms frame-prep cost unchanged
(~120 µs/frame at 58 presents/s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wnership gate, zombie detection + eviction, get clients verb

Fixes aethersdr#3977 (root cause of aethersdr#3951: a still-alive earlier AetherSDR session
keeps auto-floor-adjusting the pan a newer session reclaimed, bouncing the
new session's trace 0.1 dB at a time).

Three layers plus test tooling:

- Auto-floor discipline (radio-authoritative): PanadapterModel now parses
  client_handle from pan status, and the dBm-range sender drops-and-warns
  when the pan's radio-reported owner is not us — a superseded session goes
  quiet the moment the radio broadcasts the new owner.
- Reclaim eviction: when reconnect-reclaim adopts a staged pan, the
  predecessor handle recorded on it gets a `client disconnect` (SmartSDR's
  takeover behavior); disconnecting an already-dead handle is a harmless
  error reply. Live-proven: a radio-initiated bounce mid-test produced a
  reconnect+reclaim that evicted the prior handle.
- Evidence-based zombie eviction: the S<handle>| status source (parsed but
  previously discarded at the connection signal boundary) is now plumbed
  through; foreign writes to OUR pans' dBm range are counted per handle and
  warned (1st + every 25th). Three strikes AND radio-roster identity match
  (program AetherSDR + our own radio-reported station) → evict. Anything
  else — SmartSDR, other stations, unidentifiable non-GUI writers — is
  logged, never touched.
- `get clients` bridge verb (documented): radio client roster, per-pan
  clientHandle/ownedByUs (also added to get pan/pans), foreign-write
  counters, evicted handles. `get pans` could only show minDbm drifting;
  this shows the culprit.
- tools/zombie_session_sim.py: raw-TCP zombie simulator (registers as
  AetherSDR with the victim's station, replays display pan set min_dbm
  writes) used for the live proofs.

Live findings on FLEX-8400M / 4.2.18: detection attributed and counted
every simulated zombie; radio firmware self-heals the same-client_id
topology via duplicate_client_id disconnects (both sides bounced — the
client-side eviction is the backstop for pre-4.2 firmware like aethersdr#3951's
4.1.3); victim survived the hostile bounce with a clean reconnect+reclaim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jensenpat jensenpat marked this pull request as ready for review July 3, 2026 02:56
@jensenpat jensenpat requested review from a team as code owners July 3, 2026 02:56
…andle

Found during the aethersdr#3984 defensive review: pan status parsing (client_handle,
added in this PR) can legitimately reassign a staged pan's owner to another
live client before we disconnect (ownership transfer between two sessions
sharing a client_id). Reclaim eviction then read that handle as "our
predecessor" and force-disconnected a healthy session — observed live as a
forced=1 bounce loop between this Mac and the test Mac's instance. Stash our
own handle at stage time and evict only when the staged pan still records
exactly that handle.

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

@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.

Reviewed the two PR-specific commits (597ff84c, b4ce5766); the third commit 1c40d965 and its files (panscope.frag, SpectrumWidget.cpp) are the stacked #3975 and out of scope here. CI is green across build/macOS/Windows/CodeQL.

Overall

This is a well-architected fix. The layering is the right call, and the load-bearing layer is the cheapest one: because the radio broadcasts the reassigned client_handle to every connected client, the superseded session's own PanadapterModel::applyPanStatus flips ownedByClient() false and the ownership gate in wirePanadapter silences it — no timers, no eviction round-trip needed. Eviction is correctly positioned as the pre-4.2-firmware backstop. Conventions are respected (AppSettings via ourStationName(), RAII, QStringView/std::clamp), and the fail-open bias in ownedByClient (unknown/unparsable/0 → treated as ours) is the right default — a parse glitch degrades to "send anyway," never to "silently muted forever."

Signal-signature propagation checks out: both RadioConnection/WanConnection emit sites pass msg.handle (populated for S<handle>| status at CommandParser.cpp:77), both connect() calls use member-pointer syntax to the new 3-arg slot, and the public RadioModel::statusReceived relay stays 2-arg so TciServer/MemoryDialog are untouched.

Minor (non-blocking)

  • Duplicated hex-handle parsing. The 0x-strip + toUInt(&ok, 16) dance is now open-coded in PanadapterModel::ownedByClient and applyPanStatus. RadioModel already has a parseClientHandle() free helper doing exactly this (RadioModel.cpp:130) — worth a small static equivalent on PanadapterModel so the two copies can't drift (e.g. one treating 0 as "ours" and the other not).

  • ForeignPanWrite pan-id parse. noteForeignPanWriteIfAny hand-parses the id with object.mid(12).section(' ', 0, 0) while the main status dispatcher uses the panRe regex. It happens to be correct (and "display panafall " is excluded by the trailing space), but keying off the same regex/normalizePanadapterId path everything else uses would be more robust to future object-format tweaks.

One thing to confirm

The 3-strike foreign-write path force-disconnects another client — outward-facing and hard to reverse. The gate (≥3 writes to a pan we still own and radio-reported program==AetherSDR and matching radio-reported station) is tight, and you've documented that this exact path couldn't be provoked on 4.2.18. The residual risk is two legitimate same-station AetherSDR sessions, but since a pan carries a single client_handle, a foreign write to a pan the radio still says we own is genuinely anomalous — so the classification looks sound. Flagging only because it's the one branch that acts on another session and wasn't hardware-exercised end-to-end.

Nice work on the get clients verb + zombie_session_sim.py harness — turning "minDbm drifts between polls" into an attributable culprit roster is a real diagnostic upgrade. Thanks @jensenpat.


🤖 aethersdr-agent · cost: $3.5363 · model: claude-opus-4-8

@jensenpat jensenpat added the priority: medium Medium priority label Jul 4, 2026
@ten9876 ten9876 self-assigned this Jul 4, 2026
ten9876
ten9876 previously requested changes Jul 4, 2026

@ten9876 ten9876 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.

Review: request changes

Deep pass over the eviction/ownership work (multi-agent finders + adversarial verify; the SpectrumWidget/panscope hunks are identical to #3975 and were reviewed there, not here). The get clients verb and the foreign-write detection/forensics are genuinely useful and I'd like to see them land. But two of the three defense layers are currently dead code, and the one live layer can force-disconnect a healthy client — so I'm requesting changes on the mechanism, not just lines. Each inline comment carries a fix (apply-able suggestion where clean, a code sketch where the fix is structural).

Dead / non-functional:

  1. Reclaim eviction never fires — the guard added in b4ce5766 reads m_staleSessionOwnHandle, which is captured after the new handle lands, making it always-false (RadioModel.cpp:2106 / :3946). This is why the "reclaim eviction: live-proven" note no longer holds.
  2. Ownership-transfer tracking never runsclassifyOwnedStatus drops a reclaimed pan's foreign-owner status before applyPanStatus sees it, so ownedByClient(ours) stays true and the Layer-1 gate is a no-op (PanadapterModel.cpp:71).

Unsafe (the one live layer):
3. A healthy second operator can be force-disconnected — the identity gate is station-name-only with no liveness check; two same-hostname operators collide (RadioModel.cpp:4082). Recommend gating the disconnect behind an opt-in (default off) per AGENTS.md, plus a recency window.
4. Fail-open ownership as eviction evidence — writes to a not-yet-attributed pan are tallied as writes to ours (RadioModel.cpp:4050). The eviction tally must fail closed.
5. Eviction state is never reset — recycled FLEX handles then suppress legitimate evictions and mislabel get clients (folded into comment 1).

Minor: get pan emits "0x" for an unset handle; braces-on-control-flow; and three hand-rolled copies of a handle parser that gates a disconnect — reuse the existing helpers.

Threading is clean — mutator (onStatusReceived) and reader (doGet) both run on the main event loop, no race.

Suggested path: land the detection + get clients half; hold the automatic eviction until findings 1-4 are addressed and you've decided (finding 3) whether client eviction should ship at all or stay opt-in. Independent of the rest of the queue, so nothing else is blocked.

Comment thread src/models/RadioModel.cpp Outdated
Comment thread src/models/PanadapterModel.cpp
Comment thread src/models/RadioModel.cpp Outdated
Comment thread src/models/RadioModel.cpp Outdated
Comment thread src/core/AutomationServer.cpp Outdated
Comment thread src/models/PanadapterModel.cpp Outdated
Comment thread src/core/AutomationServer.cpp Outdated
Comment thread src/models/PanadapterModel.cpp Outdated
ten9876 and others added 2 commits July 4, 2026 08:50
Resolves the two textual conflicts against main's panstats/wavestats
additions by union: the get-verb hint string lists both clients and
wavestats, and the docs verb table keeps both rows. Merge (not rebase)
per CONTRIBUTING.md stale-base policy.
…ciple II.

Addresses both review passes on aethersdr#3981 (8 inline threads + 10 findings):

Ownership (radio-authoritative, Principle II):
- Stamp the radio-reported owner onto a held pan in the foreign-owner
  Ignore branch instead of only warning — Layer 1 now actually flips
  ownedByClient() for superseded sessions, and the rightful new owner's
  own echoes stop counting as zombie evidence (mutual-eviction loop).
- Central pan-set gate in RadioModel::sendCommand covers every writer
  (band restore, center/bandwidth/zoom/fps), not just the dBm lambda.
- dBm handlers refuse the whole operation before committing the local
  stream decoder, so a superseded session's display can't diverge.

Eviction (opt-in, confirmed, evidence fail-closed):
- 3-strike eviction now requires AppSettings["StaleSessionDefense"]
  {"EvictionEnabled": true} (default OFF — detection/forensics always
  run). fw 4.2+ self-heals duplicate client_ids; S<handle> originator
  semantics are unverified on the 4.1.x firmware this backstops.
- evictStaleSession(): handles enter evictedPredecessorHandles only on
  a radio-acknowledged disconnect; refused disconnects stay retryable.
- Evidence gate uses the new fail-closed PanadapterModel::ownerHandle()
  (unattributed pans can no longer frame a legitimate peer).
- Reclaim-eviction guard fixed: our own handle is captured at
  registration and consumed at stage time (was captured post-reconnect,
  making the guard dead code).
- Strike tallies + eviction marks reset on disconnect (handles are
  radio-boot-scoped and recycled).

Plumbing and cleanups:
- Drop the statusReceived signature widening in RadioConnection and
  WanConnection: messageReceived(ParsedMessage) already carries the
  S<handle> source; the tally hooks into onMessageReceived instead.
- Fix dangling QStringView over the by-value temporary from
  const QMap::operator[] in applyPanStatus (UB); parse the owner once
  into a cached quint32.
- panSnapshot(): empty clientHandle serializes as "" (not "0x");
  ownedByUs folded into the snapshot; braces per style guide.
- get clients: report evictionEnabled; document confirmed-eviction and
  per-connection-reset semantics, and the non-GUI-zombie roster limit.
- zombie_session_sim.py: treat ConnectionResetError/BrokenPipeError as
  the eviction succeeding (was an unhandled traceback exiting 1 on the
  success path).
- Rebased onto main; get-clients verb merged with wavestats additions.

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

ten9876 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed maintainer fixes on top of your branch (with maintainerCanModify): a merge of current main (per the CONTRIBUTING.md stale-base policy — resolves the get clients/wavestats verb-list and docs-table conflicts by union) plus one fix commit, 679d4282, addressing all open review threads and the second-pass findings. Build green locally, 56/56 tests pass, bridge smoke-tested (get clients / get pans).

How the fixes map to the review threads:

  1. Layer-1 gate unreachable / mutual-eviction risk → the foreign-owner Ignore branch now stamps the radio-reported owner onto a held pan (Principle II) instead of only warning. That flips ownedByClient() for the superseded session and — because the strike tally now fail-closes on ownerHandle() — the rightful new owner's own echoes can no longer be counted as zombie evidence against it.
  2. Reclaim-eviction dead guard → our own handle is captured at registration (m_ownSessionHandle, reply-time so it's guaranteed assigned) and consumed at stage time; the guard now compares against the previous session's handle as intended.
  3. Fail-open evidence → new PanadapterModel::ownerHandle() (0 = unknown) gates evidence fail-closed; ownedByClient() stays fail-open but is documented as safe only for gating our own outbound commands.
  4. Eviction opt-in / weak identity gate → the 3-strike eviction now requires AppSettings["StaleSessionDefense"] = {"EvictionEnabled": true} (default off; detection + forensics always run). The pan-reclaim eviction stays active since it's scoped to our own pre-reconnect handle. Rationale in the code: fw 4.2+ self-heals duplicate client_ids, and the S<handle> originator semantics are unverified on 4.1.x (FlexLib never parses that token) — pinned in comments per AGENTS.md.
  5. One-shot eviction / lying forensicsevictStaleSession() marks a handle evicted only when the radio acknowledges the client disconnect; refused disconnects log and stay retryable. Also deduplicates the two copy-pasted eviction tails.
  6. Bookkeeping never reset → strike tallies, eviction marks, and in-flight guards all clear on disconnect (handles are radio-boot-scoped and recycled).
  7. Signal-widening unnecessary → reverted the statusReceived signature change in RadioConnection/WanConnection entirely; the tally hooks into onMessageReceived, which already receives ParsedMessage.handle. Shrinks the PR by four files.
  8. Dangling QStringView (UB)applyPanStatus no longer constructs a view over the by-value temporary from const QMap::operator[]; the owner is parsed once into a cached quint32.
  9. Ungated pan writers (band restore, center/zoom/fps) → central ownership gate in RadioModel::sendCommand covers every display pan set path; the dBm handlers additionally refuse before committing the local stream decoder, so a superseded session's display can't diverge from the radio.
  10. "0x" serialization, braces, ownedByUs duplication → fixed; panSnapshot now takes the handle and emits both ownership fields itself.
  11. Sim toolConnectionResetError/BrokenPipeError (the usual RST shape of a forced disconnect) now hits the EVICTED success path instead of a traceback + exit 1. Docs updated: confirmed-eviction semantics, per-connection reset, evictionEnabled field, and the non-GUI-zombie roster limitation (--bind-client-id writers are tallied and logged but never evicted — they never appear in the GUI roster the identity gate reads).

Not addressed here (follow-up material, not blockers): unifying the pan-owner record with the #3856 RadioDisplayPan inventory, and the roster-JSON shape duplication across get clients / troubleshootingSnapshot.

The detection layer + get clients forensics are the load-bearing deliverables now; eviction is a documented opt-in. @jensenpat please re-verify the live proofs on your 8400M when you get a chance — especially that the ownership stamp flips on takeover and your auto-floor goes quiet (the get pans ownedByUs field shows it directly).

🤖 Pushed with Claude Code on behalf of KK7GWY

@ten9876 ten9876 dismissed their stale review July 4, 2026 16:28

changes made

@ten9876 ten9876 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.

Approving — the review-fixes commit (679d4282) addresses all 10 findings from the two passes. Spot-verified the load-bearing ones in the code (not an exhaustive re-review of the +425-line delta): reclaim-eviction guard now captures the pre-reconnect handle at registration (no longer dead code); the foreign-owner Ignore branch stamps the new owner so Layer-1 ownedByClient() actually flips; the 3-strike eviction is gated behind StaleSessionDefense.EvictionEnabled (default OFF — detection/forensics still run); evidence uses the fail-closed ownerHandle(); per-disconnect state reset; and the applyPanStatus dangling-QStringView UB is fixed. The opt-in default-off eviction is the right call given the force-disconnect risk. Nice thorough remediation.

@ten9876 ten9876 merged commit 88ed2d8 into aethersdr:main Jul 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Medium priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale MultiFlex session keeps auto-floor-adjusting a reclaimed pan; foreign dBm-range echoes bypass smoothing and bounce the trace (#3951 root cause)

2 participants