Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions src/core/backends/hl2/Hl2Backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include "core/backends/hl2/Hl2RxDsp.h"
#include "core/backends/hl2/Hl2TxDsp.h"
#include "core/backends/hl2/Hl2DspSetupPolicy.h"
#include "core/backends/hl2/Hl2TxLevelPolicy.h"
#include "core/backends/hl2/MetisClient.h"
#include "core/backends/hl2/MetisProtocol.h"
Expand Down Expand Up @@ -52,6 +53,14 @@ struct Hl2Backend::PendingConnect {
// m_connectGeneration; a build whose generation is stale on completion
// tears itself down instead of starting a wire nobody asked for.
quint64 generation = 0;
// How long this phase has been running. Belongs to the connect rather than
// to the backend so a superseded build cannot report the new one's elapsed.
QElapsedTimer clock;
// Set when the phase watchdog has already released the caller with a
// dspSetupFinished(). The build is still running and finishDspSetup() will
// reach its stale branch later; this stops that branch emitting a second
// end-of-phase edge for one connect.
bool finishSignalled = false;
};

// What the I/O thread carries back. Parallel arrays indexed by DDC rather than
Expand Down Expand Up @@ -1990,6 +1999,13 @@ void Hl2Backend::beginDspSetup()
}

emit dspSetupProgress(tr("Preparing the receive chain…"), 0, total);
// MIRRORED TO THE LOG, because dspSetupProgress has exactly one consumer in
// the tree and it is MainWindow — so the phase is visible to an operator
// watching a dialog and invisible to everyone else, which is why a headless
// run showed nothing between here and the wire. (#5413.)
qCInfo(lcHl2) << "HL2 DSP setup: opening" << total << "receive chain(s)";
m_pendingConnect->clock.start();
armDspSetupWatchdog();

const Hl2TxDsp::Config tc = m_pendingConnect->tc;
Hl2TxDsp* txDsp = m_txDsp;
Expand Down Expand Up @@ -2054,22 +2070,109 @@ void Hl2Backend::beginDspSetup()
}, Qt::QueuedConnection);
}

void Hl2Backend::armDspSetupWatchdog()
{
if (!m_dspSetupWatchdog) {
m_dspSetupWatchdog = new QTimer(this);
m_dspSetupWatchdog->setSingleShot(true);
connect(m_dspSetupWatchdog, &QTimer::timeout, this,
&Hl2Backend::onDspSetupWatchdog);
}
m_dspSetupWatchdog->start(
static_cast<int>(AetherSDR::hl2::dspSetupNextCheckMs(0)));
}

void Hl2Backend::onDspSetupWatchdog()
{
if (!m_pendingConnect) {
return; // finished between the fire and this slot
}
const qint64 elapsed = m_pendingConnect->clock.elapsed();
switch (AetherSDR::hl2::dspSetupAction(elapsed)) {
case AetherSDR::hl2::DspSetupAction::None:
// Fired early (a timer can). Look again at the point that matters.
m_dspSetupWatchdog->start(
static_cast<int>(AetherSDR::hl2::dspSetupNextCheckMs(elapsed)));
return;
case AetherSDR::hl2::DspSetupAction::Warn:
// NOT a failure. A machine's first WDSP open measures its FFT plans
// rather than loading them and is legitimately slow (#5052) — 98 s on a
// quiet machine, 188 s under load — so this says so and keeps waiting;
// the alternative is failing a connect that is working. It repeats,
// because the wait it is reporting can be minutes long.
qCWarning(lcHl2) << "HL2 DSP setup: still opening after" << elapsed
<< "ms — a first open on this machine can be slow;"
<< "will fail at"
<< AetherSDR::hl2::kDspSetupFailMs / 1000 << "s";
m_dspSetupWatchdog->start(
static_cast<int>(AetherSDR::hl2::dspSetupNextCheckMs(elapsed)));
return;
case AetherSDR::hl2::DspSetupAction::Fail:
break;
}

qCWarning(lcHl2) << "HL2 DSP setup: gave up after" << elapsed << "ms";

// INVALIDATE, DO NOT TEAR DOWN. The I/O thread may be inside configure() on
// these very chains — WDSP's OpenChannel does not return early, so the build
// cannot be cancelled — and freeing them from this thread would be a
// use-after-free. So do exactly what disconnectRadio() does: bump the
// generation and LEAVE m_pendingConnect SET. The build runs to completion,
// finishDspSetup() takes its stale branch, and that branch is what releases
// the chains and re-drives anything queued behind them.
//
// Resetting pending here instead would disable the very mechanism this
// comment relies on: finishDspSetup() would return at its
// `if (!m_pendingConnect)` guard, tearDownReceivers() would never run, and
// every timed-out connect would leak its WDSP channels out of the 32-slot
// pool. Worse, connectRadio() queues behind an in-flight build only while
// m_pendingConnect is set, so a retry after this error would call
// buildReceivers() on the chains the I/O thread is still opening — a
// use-after-free reached by the ordinary "connect failed, try again"
// gesture. (#5413 triage; #5415 review.)
++m_connectGeneration;
// Before the emits, not after: a connectionError handler can re-enter this
// object, and the stale branch must see this flag whatever it does.
m_pendingConnect->finishSignalled = true;
Comment thread
Ozy311 marked this conversation as resolved.
emit connectionError(
tr("Hermes-Lite 2: the DSP setup did not finish within %1 seconds")
.arg(AetherSDR::hl2::kDspSetupFailMs / 1000));
// So a caller waiting on the phase is released rather than left hanging on
// a signal that now never comes. The build is still running; the flag above
// keeps the stale branch from emitting this a second time.
emit dspSetupFinished();
Comment thread
Ozy311 marked this conversation as resolved.
}

void Hl2Backend::finishDspSetup(const DspSetupResult& result)
{
if (!m_pendingConnect)
// Stopped on EVERY exit below, including the two early returns — a timer
// left running past the phase it measures would fail a connect that had
// already succeeded.
if (m_dspSetupWatchdog) {
m_dspSetupWatchdog->stop();
}
if (!m_pendingConnect) {
return;
}
qCInfo(lcHl2) << "HL2 DSP setup: chains open after"
<< m_pendingConnect->clock.elapsed() << "ms";
// A disconnect, or a second connect, arrived while the chains were opening.
// The wire was never started, so there is nothing to stop — but the chains
// ARE open, and leaving them open would leak WDSP channels out of the
// 32-slot pool on every abandoned connect.
if (m_pendingConnect->generation != m_connectGeneration) {
qCInfo(lcHl2) << "HL2: connect superseded while the DSP was opening —"
<< "releasing the chains it built";
// Read before the reset: the phase watchdog may already have ended the
// phase for a caller that could not wait for this moment.
const bool alreadyFinished = m_pendingConnect->finishSignalled;
m_pendingConnect.reset();
// Safe to block inside here: the build has finished, so the I/O thread
// is back at its event loop and publishIoDsps() returns promptly.
tearDownReceivers();
emit dspSetupFinished();
if (!alreadyFinished) {
emit dspSetupFinished();
}
// A connect that arrived mid-build has been waiting for exactly this.
if (m_queuedConnect) {
const RadioConnectRequest queued = *m_queuedConnect;
Expand Down Expand Up @@ -2196,6 +2299,11 @@ void Hl2Backend::disconnectRadio()
// cancelled — WDSP's OpenChannel does not return early — so it runs to
// completion on the I/O thread and finishDspSetup() releases what it built.
++m_connectGeneration;
// The phase watchdog goes with it: the operator has left, so a later fire
// would report a failure for a connect nobody is waiting on.
if (m_dspSetupWatchdog) {
m_dspSetupWatchdog->stop();
}
// And a connect PARKED BEHIND that build is stale for the same reason: the
// operator has since asked to be disconnected. Leaving it here made
// finishDspSetup()'s supersede branch re-drive it — a second full build
Expand Down
6 changes: 6 additions & 0 deletions src/core/backends/hl2/Hl2Backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ class Hl2Backend : public IRadioBackend {
// the note above buildReceivers() and docs/HERMES.md §20.8). The sequence stays
// serial on the I/O thread; only the GUI thread stopped waiting for it.
void beginDspSetup();
void armDspSetupWatchdog();
void onDspSetupWatchdog();

// Everything the async build needs to carry across event-loop turns (the
// wire params, the RX and TX configs, and which connect it belongs to). A
Expand All @@ -193,6 +195,10 @@ class Hl2Backend : public IRadioBackend {
void finishDspSetup(const DspSetupResult& result);

std::unique_ptr<PendingConnect> m_pendingConnect;
// Watches the window between beginDspSetup() returning and finishDspSetup()
// being posted back — the one stretch of the connect that no other timer
// covers, because MetisClient's watchdog is armed after it (#5413).
QTimer* m_dspSetupWatchdog = nullptr;
// A connect that arrived while m_pendingConnect was still building. Held
// rather than served inline — see the guard at the top of connectRadio().
std::unique_ptr<RadioConnectRequest> m_queuedConnect;
Expand Down
158 changes: 158 additions & 0 deletions src/core/backends/hl2/Hl2DspSetupPolicy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#pragma once

// How long the DSP-setup phase may run before it is worth saying something, and
// before it is worth giving up — as a pure decision.
//
// THE PHASE HAS NO TIMEOUT AT ALL TODAY, and that is the defect (#5413).
// beginDspSetup() hands the WDSP opens to the I/O thread and returns to the
// event loop; finishDspSetup() is posted back when they finish. Between those
// two points nothing in Hl2Backend is watching. The only connect watchdog lives
// in MetisClient::start(), which is reached AFTER this phase, so a stall here is
// outside every guard in the path — the caller sees the same reply a successful
// connect gives and then silence.
//
// TWO STAGES, NOT ONE NUMBER, and the reason is that a slow first open is
// legitimate rather than broken. A machine's first WDSP/FFTW open measures its
// plans instead of loading them, which is genuinely expensive (#5052;
// MetisClient.cpp cites ~19 s), and a single tight timeout would turn a working
// first launch into a failed connect. So: warn early and keep going, fail only
// far out.
//
// WHERE "FAR OUT" IS, MEASURED. The first bound here was 90 s, chosen against a
// single observation that an uncached open was "still running at 150 s". It was
// wrong, and wrong in the direction that fails working connects. On an IDLE
// machine — 21 load samples between 3.3 and 4.1 — a cold first open measured
// 98269 ms, against HERMES §22.3's documented 18865 ms
// (streams/hl2-telemetry/runs/d57_bench_quiet_result.txt). So 90 s failed a
// connect that was working, on a quiet machine, every time.
//
// The same series measured that load roughly doubles it: 188128 ms for the open
// at one-minute load 38-40, and four cold CONNECTS at load 31-44 came in at
// 193 / 195 / 214 / 219 s (streams/hl2-telemetry/runs/d57_quiet_connect.py).
// 98.3 s is therefore a FLOOR from one sample on one machine, not a typical
// case — and the cost being measured is FFTW timing candidate plans, which
// varies several-fold across hardware. A laptop or a CI runner will be slower.
//
// AND THE LARGEST DOCUMENTED FIGURE IS NOT OURS. #4877 -- closed, titled "every
// run re-measures 190 s of PATIENT plans" -- reports 178.7 s on an i9-13980HX
// and 188-190 s across four consecutive CI runs. So ~190 s is an EXPECTED cold
// cost in at least one shipped configuration, independently of this bench, and
// the 188.1 s above reproduces it rather than discovering it. Read the range as
// 19 s to 190 s across binaries and platforms before a slower CPU is counted —
// with the low end as the outlier rather than an equal member: three
// independent cold measurements sit between 98 and 190 s (this bench's 98.3 s,
// wdsp_channel_test's 165.1 s, #4877's 178.7 s and its 188-190 s CI runs),
// while HERMES §22.3's 18.9 s and its 22.4 s companion stand alone.
//
// One caveat on treating those as one number: HERMES.md notes that the app's
// plan set and the tests' plan set are different FFTW problems, so #4877's
// figure and a connect are not strictly the same measurement. That cuts toward
// a wider bound, not a narrower one.
//
// WHAT NONE OF THOSE FIGURES COVER: MORE THAN ONE RECEIVER — and the exposure
// is much narrower than "the board reports four". beginDspSetup() opens one
// chain per receiver, `for (int i = 0; i < actualNumRx; ++i)`, and the transmit
// path opens none, so the cost does scale with actualNumRx. But three things
// bound how it can exceed 1 inside this window:
//
// * connectRadio() sets `m_requestedNumRx = 1` unconditionally and raises it
// only for an explicit `numRx` CONNECT PARAM. A saved count is deliberately
// not re-imposed (see the comment there). Nothing in RadioModel passes the
// param, so an ordinary app connect opens exactly one chain.
// * A receiver added later is outside this timer entirely: createPanadapter()
// refuses before m_connected, so its chain opens after the phase this
// watchdog measures.
// * receiverCeiling() is min(board, maxReceiversAtRate(rate, board)), so at
// 384 kHz — the rate every measurement above used — a 4-receiver board is
// honestly 3. A four-receiver run would have to change the rate, and would
// then not be comparable to the figures above at all.
//
// So the reachable case is an automation or embedder caller that passes numRx>1
// at connect: the same class of entry point as #5402's lnaGainDb. Plan sets
// overlap heavily (this bench's second, third and fourth cold opens, at other
// rates, cost 1862 / 1223 / 739 ms after that first 98 s), so chains 2..N at
// one rate are probably nearly free — an expectation, not a measurement. If
// such a connect ever fails this bound, that is the number to go and measure.
//
// Hence 600 s: an order of magnitude over the quiet floor, ~3x over the largest
// documented cold cost, ~2.7x over the worst measured working connect, and
// still finite. The asymmetry justifies the
// generosity — failing a connect that would have succeeded loses the session,
// while a late error on a true hang only delays a message the operator can
// already see coming from the warn line.
//
// WHICH IS WHY THE WARN REPEATS. A single line at 10 s followed by ten minutes
// of silence is barely better than the unbounded phase this replaces, so after
// the first warning the watchdog re-warns on a fixed cadence until it either
// finishes or fails. The schedule below is what produces that.
//
// A pure function so the timing behaviour is testable without a radio, a socket
// or a running event loop — the layer #5358 asks for.

#include <cstdint>

namespace AetherSDR::hl2 {

enum class DspSetupAction {
None, // still inside the expected window; say nothing
Warn, // slow enough to be worth a log line, NOT a failure
Fail, // long enough that the caller deserves an error instead of silence
};

// Default stages. Deliberately far apart: the gap between them is where a
// legitimately slow first open lives — measured at 98.3 s quiet and 188 s under
// load, so the gap is the working case, not the pathological one.
inline constexpr std::int64_t kDspSetupWarnMs = 10'000;
inline constexpr std::int64_t kDspSetupFailMs = 600'000;

// How often to repeat the warning once the phase is past the warn point. Not a
// stage: it changes nothing about what dspSetupAction() decides, only how often
// the caller wakes up to hear the same Warn again.
inline constexpr std::int64_t kDspSetupWarnRepeatMs = 30'000;

inline DspSetupAction dspSetupAction(std::int64_t elapsedMs,
std::int64_t warnMs = kDspSetupWarnMs,
std::int64_t failMs = kDspSetupFailMs)
{
// Fail is checked FIRST so a misconfigured pair (fail <= warn) still fails
// rather than warning forever. The ordering is the guard, not an assert:
// this runs on a timer in a connect path and must not abort a session.
if (elapsedMs >= failMs) {
return DspSetupAction::Fail;
}
if (elapsedMs >= warnMs) {
return DspSetupAction::Warn;
}
return DspSetupAction::None;
}

// How long to wait before looking again, given that `elapsedMs` has just been
// judged.
//
// Before the warn point this is the exact REMAINDER, not a poll: a connect that
// is going to finish in four seconds costs zero wake-ups. After it, the cadence
// is what keeps the ten-minute window from being silent — but the last wait is
// clamped to the remainder so the fail point is hit exactly rather than
// overshot by up to a repeat interval.
inline std::int64_t dspSetupNextCheckMs(std::int64_t elapsedMs,
std::int64_t warnMs = kDspSetupWarnMs,
std::int64_t failMs = kDspSetupFailMs,
std::int64_t repeatMs = kDspSetupWarnRepeatMs)
{
if (elapsedMs < warnMs) {
return warnMs - elapsedMs;
}
if (elapsedMs >= failMs) {
return 0; // nothing further to wait for
}
const std::int64_t remaining = failMs - elapsedMs;
// A non-positive cadence would arm a zero-delay timer and spin the event
// loop for the rest of the phase. Fall back to the old single-shot
// behaviour rather than doing that.
if (repeatMs <= 0) {
return remaining;
}
return remaining < repeatMs ? remaining : repeatMs;
}

} // namespace AetherSDR::hl2
Loading
Loading