diff --git a/.claude/skills/release-prep/SKILL.md b/.claude/skills/release-prep/SKILL.md index 54cfaf42bf..f2d95a9688 100644 --- a/.claude/skills/release-prep/SKILL.md +++ b/.claude/skills/release-prep/SKILL.md @@ -347,7 +347,10 @@ time: ``` It replaces the snapshot, its sidebar and the version label - (`docs/user/stable-version.json`); the build is its check. It is its own + (`docs/user/stable-version.json`), and rewrites the Log Analyzer's `/next/` + rule links in `docs/user/src/components/LogAnalyzer/rules.json` to Stable + for every page the snapshot now has; the build and + `node tools/docs/test_log_rules.mjs` are its checks. It is its own commit (`docs(user): Stable docs snapshot for vX.Y.Z. Principle VIII.`), and its paths show up as `WARN` extras in step 5: list them in the scope table as "docs snapshot". Re-run it when you fold in a PR that changed diff --git a/CMakeLists.txt b/CMakeLists.txt index e7a6bc49aa..f5c5733904 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1688,6 +1688,7 @@ target_link_libraries(aetherdesktop_support PUBLIC add_executable(AetherSDR src/main.cpp + src/QtPlatformChoice.cpp # start-up log lines: the Qt platform plugin and any fallback ${GUI_SOURCES} ${RESOURCES} ${DFNR_RESOURCES} diff --git a/docs/user/README.md b/docs/user/README.md index 5fe61d65df..1062d5cce7 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -58,7 +58,8 @@ needs an entry in `sidebars.js`. There is one snapshot. Edit `docs/` only: the snapshot is replaced, never edited, by `tools/docs/snapshot_stable.py`, which `/release-prep` runs for every release. It copies `docs/` and the evaluated `sidebars.js` into the -snapshot and sets the label: +snapshot, sets the label, and points the Log Analyzer's `/next/` rule links +at Stable for every page the snapshot now has: ```sh python3 tools/docs/snapshot_stable.py 26.10.2 @@ -139,8 +140,10 @@ every release with a detached signature made with the release key. checks a log or support bundle in the browser against `src/components/LogAnalyzer/rules.json`. Each rule cites the source line that writes the message it matches and links the fix: `/#` on -Stable, or `/next/#` for a page only Next has. Test the rules -with: +Stable, or `/next/#` for a page only Next has. +`snapshot_stable.py` turns each `/next/` link whose page is in the new Stable +snapshot into a Stable link, so users of the release land on its docs. Test the +rules with: ```sh node tools/docs/test_log_rules.mjs diff --git a/docs/user/docs/bnr-gpu-noise-removal.md b/docs/user/docs/bnr-gpu-noise-removal.md index 01e9b3e2ae..def1ed40aa 100644 --- a/docs/user/docs/bnr-gpu-noise-removal.md +++ b/docs/user/docs/bnr-gpu-noise-removal.md @@ -95,10 +95,25 @@ You are on macOS, or using a build compiled without BNR (`-DENABLE_NVIDIA_AFX=OF ### The download fails -The connection dropped, or there isn't enough free disk space for the pack. +The connection dropped or stalled for 30 seconds, or there isn't enough free disk space for the pack. The log shows `NvidiaAfxPack: install failed:` with the reason. 1. Check your internet connection. 2. Make sure about 1 GB of disk space is free. +3. Click the **Download** button again. Components that finished downloading are kept, so the download resumes. + +### The download fails with "checksum mismatch" + +A downloaded file is not the one this version of AetherSDR expects: it was altered on the way, or the server holds a different file. Downloading it again fetches the same file. + +1. Try again from a different network; a proxy or security software can alter downloads. +2. If it still fails, file a bug report with your log. + +### The download finishes but BNR is not installed + +AetherSDR could not unpack the files, or the unpacked pack is incomplete. The log shows `NvidiaAfxPack: install failed:` followed by `extract failed`, `assembled pack missing`, `could not install into` or `install verification failed`. + +1. On Linux, install `unzip` and `zstd`: AetherSDR unpacks the pack with `unzip` and `tar --zstd`. +2. Make sure about 1 GB of disk space is free. 3. Click the **Download** button again. ### BNR turns off when you change mode diff --git a/docs/user/docs/generated/log-categories.md b/docs/user/docs/generated/log-categories.md index 0230c8905d..a6f615f54d 100644 --- a/docs/user/docs/generated/log-categories.md +++ b/docs/user/docs/generated/log-categories.md @@ -61,4 +61,5 @@ These are the logging categories listed in **Help → Support & Diagnostics...** | Icom Scope | `aether.icom.pan` | Icom spectrum scope: sweep frames, division reassembly, bounds | | Icom Link | `aether.icom.link` | Icom backend link state: connect/disconnect, model resolution, capability publication | | Icom Credentials | `aether.icom.cred` | Icom credential storage and retrieval (no secret values are logged) | +| BNR (NVIDIA AFX) | `aether.nvafx` | BNR GPU noise removal: runtime pack download and install failures, network transfer errors, filter load errors | | System Info | `aether.sysinfo` | Startup hardware/capability inventory: OS, CPU model + SIMD features, RAM, and the speech-engine ISA baseline check (#4986). A few lines once per launch | diff --git a/docs/user/docs/gpu-rendering.md b/docs/user/docs/gpu-rendering.md index 3aa31d3bb4..ed00b82e1a 100644 --- a/docs/user/docs/gpu-rendering.md +++ b/docs/user/docs/gpu-rendering.md @@ -117,6 +117,13 @@ The graphics adapter AetherSDR found is a software rasteriser (llvmpipe, WARP, S 1. If your computer has more than one adapter, open **Display** and pick the real GPU under **SYSTEM → GPU:**. 2. Restart AetherSDR; the choice takes effect on the next launch. +### AetherSDR runs on XWayland in a Wayland session + +Qt could not load its Wayland platform plugin, so it fell back to the next plugin in `QT_QPA_PLATFORM`, usually `xcb` (XWayland). The log shows `Platform: Qt fell back from "wayland" to "xcb"`; the line before it names the plugin in use. + +1. If you built AetherSDR yourself, install your distribution's Qt 6 Wayland package. The AppImage includes the plugin. +2. Start AetherSDR from a terminal: Qt prints why the plugin did not load there, not in AetherSDR's log. + ## See also - [Panadapter Controls](./panadapter-controls.md) diff --git a/docs/user/docs/low-bandwidth-connections.md b/docs/user/docs/low-bandwidth-connections.md index 31ecf4b2fd..fe7680fc32 100644 --- a/docs/user/docs/low-bandwidth-connections.md +++ b/docs/user/docs/low-bandwidth-connections.md @@ -165,6 +165,15 @@ Network jitter or lost packets are reaching the audio buffer. 2. Leave **Smooth packet loss** on. 3. Use Opus compression and low bandwidth mode. +### Audio drops out or the waterfall has gaps + +Packets from the radio are lost, or arrive out of order, on the way to the computer. Wi-Fi, a busy switch or a VPN are the usual causes. The log then shows `PanadapterStream: VITA-49 sequence errors on stream`, at most once every 10 seconds for each stream. + +1. Open **Tools → Network Diagnostics...** to see which streams lose packets. +2. Connect the computer to the network by cable instead of Wi-Fi. +3. If the VITA-49 RX buffer says it was capped, raise `net.core.rmem_max` (see [The VITA-49 RX buffer says it was capped](#the-vita-49-rx-buffer-says-it-was-capped)). +4. On a remote link, use Opus compression and low bandwidth mode. + ### Low bandwidth mode has no effect It takes effect only when you connect, and never on a FlexRadio found **On This Network**. diff --git a/docs/user/log-samples/clean.log b/docs/user/log-samples/clean.log index c2f1d130ab..81896b4f4b 100644 --- a/docs/user/log-samples/clean.log +++ b/docs/user/log-samples/clean.log @@ -1,5 +1,6 @@ [09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) [09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.101] INF default: Platform: Qt platform plugin "xcb" (QT_QPA_PLATFORM unset, Qt default) [09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 [09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 [09:14:02.140] INF aether.sysinfo: RAM: 32012 MB diff --git a/docs/user/log-samples/rules/bnr-checksum-mismatch.log b/docs/user/log-samples/rules/bnr-checksum-mismatch.log new file mode 100644 index 0000000000..70b48ed74b --- /dev/null +++ b/docs/user/log-samples/rules/bnr-checksum-mismatch.log @@ -0,0 +1,8 @@ +[09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) +[09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 +[09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 +[09:14:02.140] INF aether.sysinfo: RAM: 32012 MB +[09:14:02.141] INF aether.sysinfo: Speech engine CPU baseline: x86-64-v3 +[09:14:02.152] DBG default: Starting AetherSDR 26.10.1 +[10:02:11.418] WRN aether.nvafx: NvidiaAfxPack: install failed: checksum mismatch for AFX runtime (corrupt or stale mirror) diff --git a/docs/user/log-samples/rules/bnr-download-failed.log b/docs/user/log-samples/rules/bnr-download-failed.log new file mode 100644 index 0000000000..1f1ec513ef --- /dev/null +++ b/docs/user/log-samples/rules/bnr-download-failed.log @@ -0,0 +1,9 @@ +[09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) +[09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 +[09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 +[09:14:02.140] INF aether.sysinfo: RAM: 32012 MB +[09:14:02.141] INF aether.sysinfo: Speech engine CPU baseline: x86-64-v3 +[09:14:02.152] DBG default: Starting AetherSDR 26.10.1 +[09:15:40.212] WRN aether.nvafx: NvidiaAfxPack: transfer failed: AFX runtime: Network unreachable +[09:15:40.212] WRN aether.nvafx: NvidiaAfxPack: install failed: download failed (AFX runtime): Network unreachable diff --git a/docs/user/log-samples/rules/bnr-install-failed.log b/docs/user/log-samples/rules/bnr-install-failed.log new file mode 100644 index 0000000000..3a5a4320f3 --- /dev/null +++ b/docs/user/log-samples/rules/bnr-install-failed.log @@ -0,0 +1,8 @@ +[09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) +[09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 +[09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 +[09:14:02.140] INF aether.sysinfo: RAM: 32012 MB +[09:14:02.141] INF aether.sysinfo: Speech engine CPU baseline: x86-64-v3 +[09:14:02.152] DBG default: Starting AetherSDR 26.10.1 +[10:05:37.920] WRN aether.nvafx: NvidiaAfxPack: install failed: extract failed: tar: unrecognized option '--zstd' diff --git a/docs/user/log-samples/rules/qt-platform-fallback.log b/docs/user/log-samples/rules/qt-platform-fallback.log new file mode 100644 index 0000000000..e053bbd4e7 --- /dev/null +++ b/docs/user/log-samples/rules/qt-platform-fallback.log @@ -0,0 +1,10 @@ +[09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) +[09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.101] INF default: Platform: Wayland session, display presence connected -> QT_QPA_PLATFORM wayland;xcb +[09:14:02.101] INF default: Platform: Qt platform plugin "xcb" (QT_QPA_PLATFORM=wayland;xcb, set by AetherSDR) +[09:14:02.101] WRN default: Platform: Qt fell back from "wayland" to "xcb"; the first plugin in QT_QPA_PLATFORM did not load +[09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 +[09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 +[09:14:02.140] INF aether.sysinfo: RAM: 32012 MB +[09:14:02.141] INF aether.sysinfo: Speech engine CPU baseline: x86-64-v3 +[09:14:02.152] DBG default: Starting AetherSDR 26.10.1 diff --git a/docs/user/log-samples/rules/vita-sequence-errors.log b/docs/user/log-samples/rules/vita-sequence-errors.log new file mode 100644 index 0000000000..2a9747e772 --- /dev/null +++ b/docs/user/log-samples/rules/vita-sequence-errors.log @@ -0,0 +1,11 @@ +[09:14:02.101] INF default: GpuSelector: render GPU -> Auto (system default — no override) +[09:14:02.101] INF default: QtAudioBackendGuard: PipeWire context OK; Qt's default backend left in place +[09:14:02.140] INF aether.sysinfo: OS: Ubuntu 24.04.3 LTS kernel 6.8.0-85-generic arch x86_64 +[09:14:02.140] INF aether.sysinfo: CPU: AMD Ryzen 7 5800X 8-Core Processor features: SSE42 AVX AVX2 FMA F16C BMI2 +[09:14:02.140] INF aether.sysinfo: RAM: 32012 MB +[09:14:02.141] INF aether.sysinfo: Speech engine CPU baseline: x86-64-v3 +[09:14:02.152] DBG default: Starting AetherSDR 26.10.1 +[09:14:05.130] INF aether.vita49: PanadapterStream: VITA UDP receive buffer requested=8388608 granted=8388608 +[09:20:41.502] WRN aether.vita49: PanadapterStream: VITA-49 sequence errors on FFT stream 0x40000000: 1 in the last 396 s, 1 since the stream started (9402 packets received) +[09:20:51.733] WRN aether.vita49: PanadapterStream: VITA-49 sequence errors on FFT stream 0x40000000: 12 in the last 10 s, 13 since the stream started (9658 packets received) +[09:20:52.004] WRN aether.vita49: PanadapterStream: VITA-49 sequence errors on audio stream 0x04000008: 7 in the last 407 s, 7 since the stream started (17890 packets received) diff --git a/docs/user/src/components/LogAnalyzer/index.js b/docs/user/src/components/LogAnalyzer/index.js index 3cb80e8bb8..1e21441217 100644 --- a/docs/user/src/components/LogAnalyzer/index.js +++ b/docs/user/src/components/LogAnalyzer/index.js @@ -25,6 +25,7 @@ const SUMMARY_FIELDS = [ ['cpu', 'CPU'], ['ram', 'Memory'], ['gpu', 'Render GPU'], + ['qtPlatform', 'Qt platform plugin'], ['radioModel', 'Radio model'], ['firmware', 'Radio firmware'], ]; @@ -131,7 +132,12 @@ export default function LogAnalyzer() {
Your log stays on your computer. The analysis runs entirely in your browser. The file is never uploaded, and this page sends nothing anywhere. - AetherSDR already redacts addresses, serial numbers and passwords from its logs. + AetherSDR already masks personal details in its logs: IPv4 and MAC addresses (all but + the last part), IPv6 addresses, radio serial numbers (all but the last group), + passwords, tokens and keys (tokens and keys keep their first four characters), your home directory + and email addresses; and host names, names, locations and grid squares where a log + line labels them as such. Your callsign is kept on purpose, so a report can be + identified.

Find your log

diff --git a/docs/user/src/components/LogAnalyzer/rules.json b/docs/user/src/components/LogAnalyzer/rules.json index ca2b0634b9..f8672e47a1 100644 --- a/docs/user/src/components/LogAnalyzer/rules.json +++ b/docs/user/src/components/LogAnalyzer/rules.json @@ -25,6 +25,23 @@ "text": "else if (key == \"software_ver\")" } ] + }, + { + "key": "qtPlatform", + "regex": "Platform: Qt platform plugin \"([^\"]+)\"", + "last": false, + "source": [ + { + "file": "src/QtPlatformChoice.cpp", + "line": 74, + "text": "Platform: Qt platform plugin \\\"%1\\\" (%2)" + }, + { + "file": "src/QtPlatformChoice.cpp", + "line": 57, + "text": "Platform: Qt platform plugin \\\"%1\\\" (-platform" + } + ] } ], "rules": [ @@ -215,7 +232,7 @@ }, { "file": "src/core/backends/flex/PanadapterStream.cpp", - "line": 221, + "line": 234, "text": "PanadapterStream: failed to bind UDP socket:" }, { @@ -237,11 +254,28 @@ "source": [ { "file": "src/core/backends/flex/PanadapterStream.cpp", - "line": 175, + "line": 188, "text": "capped by net.core.rmem_max" } ] }, + { + "id": "vita-sequence-errors", + "severity": "warning", + "title": "Packets from the radio were lost or arrived out of order", + "explanation": "AetherSDR counted gaps in the VITA-49 packet sequence of a stream from the radio (audio, FFT, waterfall, meter or DAX). Lost or reordered packets cause audio dropouts and gaps in the waterfall. Wi-Fi, a busy switch or a VPN between the radio and the computer are the usual causes.", + "match": { + "substring": "PanadapterStream: VITA-49 sequence errors on" + }, + "docs": "/next/low-bandwidth-connections#audio-drops-out-or-the-waterfall-has-gaps", + "source": [ + { + "file": "src/core/backends/flex/VitaSequenceLossReport.h", + "line": 125, + "text": "PanadapterStream: VITA-49 sequence errors on %1 stream 0x%2: " + } + ] + }, { "id": "smartlink-login-failed", "severity": "problem", @@ -423,7 +457,7 @@ }, { "file": "src/core/backends/flex/PanadapterStream.cpp", - "line": 1686, + "line": 1766, "text": "stream create failed/dropped" } ] @@ -440,7 +474,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 12653, + "line": 12671, "text": "RadioModel: DAX TX create failed" } ] @@ -545,6 +579,23 @@ } ] }, + { + "id": "qt-platform-fallback", + "severity": "warning", + "title": "Qt fell back to another platform plugin", + "explanation": "Qt could not load the first platform plugin requested (in QT_QPA_PLATFORM, or in the -platform argument when AetherSDR is started with one) and started with the next one. In a Wayland session that usually means the Qt Wayland plugin is missing, so AetherSDR runs on XWayland. Qt's reason is printed in the terminal, not in AetherSDR's log.", + "match": { + "substring": "Platform: Qt fell back from" + }, + "docs": "/next/gpu-rendering#aethersdr-runs-on-xwayland-in-a-wayland-session", + "source": [ + { + "file": "src/QtPlatformChoice.cpp", + "line": 77, + "text": "Platform: Qt fell back from" + } + ] + }, { "id": "x11-error", "severity": "warning", @@ -557,7 +608,7 @@ "source": [ { "file": "src/main.cpp", - "line": 80, + "line": 84, "text": "Non-fatal X11 error suppressed (error_code=" } ] @@ -706,6 +757,127 @@ } ] }, + { + "id": "bnr-download-failed", + "severity": "warning", + "title": "BNR: the runtime pack download failed", + "explanation": "Downloading the BNR runtime pack failed: the connection dropped, a server or package index could not be reached, the transfer stalled for 30 seconds, or the pack could not be written to disk. Components that finished are kept, so clicking Download again resumes.", + "match": { + "regex": "NvidiaAfxPack: (transfer failed: |transfer timed out \\(stall limit |install failed: (download failed \\(|PyPI lookup failed for |simple-index lookup failed for |write failed for |cannot write cache))" + }, + "docs": "/bnr-gpu-noise-removal#the-download-fails", + "source": [ + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 81, + "text": "NvidiaAfxPack: transfer failed: %1: %2" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 76, + "text": "NvidiaAfxPack: transfer timed out (stall limit %1 s): %2" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 388, + "text": "NvidiaAfxPack: install failed:" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 716, + "text": "download failed (%1): %2" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 637, + "text": "PyPI lookup failed for %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 589, + "text": "simple-index lookup failed for %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 713, + "text": "write failed for %1 (disk full?)" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 668, + "text": "cannot write cache" + } + ] + }, + { + "id": "bnr-checksum-mismatch", + "severity": "problem", + "title": "BNR: a downloaded file failed its checksum", + "explanation": "A file of the BNR runtime pack did not match the SHA-256 this version of AetherSDR pins, so it was rejected. Downloading again fetches the same file; a proxy or security software altering downloads, or a changed file on the server, is the cause.", + "match": { + "regex": "NvidiaAfxPack: install failed: checksum mismatch for " + }, + "docs": "/next/bnr-gpu-noise-removal#the-download-fails-with-checksum-mismatch", + "source": [ + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 388, + "text": "NvidiaAfxPack: install failed:" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 723, + "text": "checksum mismatch for %1 (corrupt or stale mirror)" + } + ] + }, + { + "id": "bnr-install-failed", + "severity": "problem", + "title": "BNR: the runtime pack downloaded but did not install", + "explanation": "AetherSDR could not unpack the BNR runtime pack, or the unpacked pack was incomplete, so BNR stays unavailable. On Linux the pack is unpacked with unzip and tar --zstd, which must be installed.", + "match": { + "regex": "NvidiaAfxPack: install failed: (wheel extract failed: |extract failed: |wheel \\S+ contained no DLLs|assembled pack missing |could not install into |install verification failed)" + }, + "docs": "/next/bnr-gpu-noise-removal#the-download-finishes-but-bnr-is-not-installed", + "source": [ + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 388, + "text": "NvidiaAfxPack: install failed:" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 775, + "text": "wheel extract failed: %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 814, + "text": "extract failed: %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 790, + "text": "wheel %1 contained no DLLs" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 825, + "text": "assembled pack missing %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 835, + "text": "could not install into %1" + }, + { + "file": "src/core/NvidiaAfxPack.cpp", + "line": 856, + "text": "install verification failed" + } + ] + }, { "id": "hl2-socket-failed", "severity": "problem", diff --git a/src/QtPlatformChoice.cpp b/src/QtPlatformChoice.cpp new file mode 100644 index 0000000000..851306ae0b --- /dev/null +++ b/src/QtPlatformChoice.cpp @@ -0,0 +1,87 @@ +#include "QtPlatformChoice.h" + +#include + +#include + +namespace AetherSDR { + +QString QtPlatformChoice::firstRequested(const QString& requested) +{ + const QString first = requested.section(QLatin1Char(';'), 0, 0).trimmed(); + return first.section(QLatin1Char(':'), 0, 0).trimmed(); +} + +// "wayland" loads as "wayland" or "wayland-egl", and "wayland-egl" reports +// itself as "wayland", so a prefix match either way is a hit. The cost: a list +// such as "minimalegl;minimal" landing on "minimal" is not reported. +bool QtPlatformChoice::fellBack(const QString& requested, const QString& platformName) +{ + const QString first = firstRequested(requested); + if (first.isEmpty() || platformName.isEmpty()) { + return false; + } + return !platformName.startsWith(first, Qt::CaseInsensitive) + && !first.startsWith(platformName, Qt::CaseInsensitive); +} + +// Mirrors QGuiApplicationPrivate's argument loop: one leading '-' of "--" is +// dropped, and "-platform" takes the next argument when there is one. +std::optional QtPlatformChoice::platformArgument(int argc, const char* const* argv) +{ + std::optional value; + for (int i = 1; i < argc; ++i) { + const char* arg = argv[i]; + if (!arg || arg[0] != '-') { + continue; + } + if (arg[1] == '-') { + ++arg; + } + if (std::strcmp(arg, "-platform") == 0 && i + 1 < argc && argv[i + 1]) { + value = QString::fromLocal8Bit(argv[++i]); + } + } + return value; +} + +QStringList QtPlatformChoice::logLines(const QString& rawRequested, QpaRequestSource source, + const QString& platformName) +{ + // The request comes from argv or the environment: a CR or LF in it would + // write a second, forged line into the log the Log Analyzer reads. + static const QRegularExpression newlines(QStringLiteral("[\\r\\n]+")); + const QString requested = QString(rawRequested).replace(newlines, QStringLiteral(" ")); + QStringList lines; + if (source == QpaRequestSource::CommandLine) { + lines << QStringLiteral("Platform: Qt platform plugin \"%1\" (-platform %2, on the command line)") + .arg(platformName, requested); + } else { + QString request; + switch (source) { + case QpaRequestSource::Unset: + request = QStringLiteral("QT_QPA_PLATFORM unset, Qt default"); + break; + case QpaRequestSource::User: + request = QStringLiteral("QT_QPA_PLATFORM=%1, set by the user").arg(requested); + break; + case QpaRequestSource::AetherSDR: + request = QStringLiteral("QT_QPA_PLATFORM=%1, set by AetherSDR").arg(requested); + break; + case QpaRequestSource::CommandLine: + break; + } + lines << QStringLiteral("Platform: Qt platform plugin \"%1\" (%2)").arg(platformName, request); + } + if (source != QpaRequestSource::Unset && fellBack(requested, platformName)) { + lines << QStringLiteral("Platform: Qt fell back from \"%1\" to \"%2\"; " + "the first plugin in %3 did not load") + .arg(firstRequested(requested), platformName, + source == QpaRequestSource::CommandLine + ? QStringLiteral("-platform") + : QStringLiteral("QT_QPA_PLATFORM")); + } + return lines; +} + +} // namespace AetherSDR diff --git a/src/QtPlatformChoice.h b/src/QtPlatformChoice.h new file mode 100644 index 0000000000..e25eacbca3 --- /dev/null +++ b/src/QtPlatformChoice.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include + +namespace AetherSDR { + +// Who made the platform request Qt started with: the -platform argument, which +// Qt gives precedence over the environment, or QT_QPA_PLATFORM and who put it +// in the environment. +enum class QpaRequestSource { Unset, User, AetherSDR, CommandLine }; + +// The start-up log lines that record which Qt platform plugin is running and +// whether Qt fell back from the first one requested. Qt's own messages about a +// plugin that fails to load come before AetherSDR's log handler exists, so +// these lines are what a log can show. +struct QtPlatformChoice { + // `requested` is the effective request: the -platform value when source is + // CommandLine, otherwise QT_QPA_PLATFORM's. + static QStringList logLines(const QString& requested, QpaRequestSource source, + const QString& platformName); + // The first plugin in a request ("wayland;xcb" -> "wayland", + // "xcb:arg" -> "xcb"); empty when nothing was requested. + static QString firstRequested(const QString& requested); + static bool fellBack(const QString& requested, const QString& platformName); + // The value of the -platform (or --platform) argument as Qt reads it: the + // next argument, the last occurrence winning. Read before QApplication, + // which removes the arguments it consumes. + static std::optional platformArgument(int argc, const char* const* argv); +}; + +} // namespace AetherSDR diff --git a/src/core/GpuSelector.cpp b/src/core/GpuSelector.cpp index 97f75d1fed..14b2e1b23b 100644 --- a/src/core/GpuSelector.cpp +++ b/src/core/GpuSelector.cpp @@ -127,26 +127,22 @@ QVector enumeratePlatform() { return {}; } QString s_appliedSummary = QStringLiteral("not run"); #if defined(Q_OS_LINUX) -// Will the app run on a Wayland (EGL) platform rather than X11 (GLX)? main.cpp -// sets QT_QPA_PLATFORM (including the headless->xcb case) before applyAtStartup() -// runs, so we read that decision directly, falling back to the session type only -// when it is unset. GLX vendor selection only applies under X11 — under Wayland +// Will the app run on a Wayland (EGL) platform rather than X11 (GLX)? Qt prefers +// a -platform argument to QT_QPA_PLATFORM, so main.cpp passes it in; otherwise +// main.cpp sets QT_QPA_PLATFORM (including the headless->xcb case) before +// applyAtStartup() runs, so we read that decision directly, falling back to the +// session type only when it is unset. GLX vendor selection only applies under X11 — under Wayland // it is useless and __GLX_VENDOR_LIBRARY_NAME=nvidia can even raise a GLX // BadValue. -bool willUseWayland() +bool willUseWayland(const QString& platformArgument) { - const QByteArray plat = qgetenv("QT_QPA_PLATFORM"); - if (!plat.isEmpty()) { - // QT_QPA_PLATFORM is an ordered list ("wayland;xcb" / "xcb;wayland"); Qt - // loads the first entry it can, so only the first decides. Match it, not - // a substring of the whole value — "xcb;wayland" contains "wayland" but - // runs on xcb. - const QByteArray first = plat.split(';').constFirst().trimmed(); - if (first.contains("wayland")) return true; - if (first.contains("xcb")) return false; - } - return qEnvironmentVariableIsSet("WAYLAND_DISPLAY") - || qgetenv("XDG_SESSION_TYPE") == QByteArrayLiteral("wayland"); + const QByteArray request = platformArgument.isEmpty() + ? qgetenv("QT_QPA_PLATFORM") + : platformArgument.toLocal8Bit(); + return GpuSelector::requestPicksWayland( + request, + qEnvironmentVariableIsSet("WAYLAND_DISPLAY") + || qgetenv("XDG_SESSION_TYPE") == QByteArrayLiteral("wayland")); } #endif @@ -207,8 +203,11 @@ void GpuSelector::saveChoiceId(const QString& id) s.save(); } -void GpuSelector::applyAtStartup() +void GpuSelector::applyAtStartup(const QString& platformArgument) { +#if !defined(Q_OS_LINUX) + Q_UNUSED(platformArgument); // only the Linux GLX/EGL choice depends on it +#endif // Runs in main() BEFORE QApplication. Read the persisted choice straight // from the settings file — constructing AppSettings::instance() here would // run its path migration before setApplicationName() and break it for @@ -242,7 +241,7 @@ void GpuSelector::applyAtStartup() // applies under X11/XWayland (GLX). Under Wayland the app uses EGL, where it // is useless and =nvidia can raise GLX BadValue, so set only the // windowing-agnostic offload hints. - const bool wayland = willUseWayland(); + const bool wayland = willUseWayland(platformArgument); // Honour an explicit GPU-selection env override, but only one that applies to // the windowing system in use. __GLX_VENDOR_LIBRARY_NAME is widely exported and @@ -254,8 +253,8 @@ void GpuSelector::applyAtStartup() } else if (qEnvironmentVariableIsSet("DRI_PRIME")) { vetoedBy = "DRI_PRIME"; } else if (!wayland && qEnvironmentVariableIsSet("__GLX_VENDOR_LIBRARY_NAME")) { - // `wayland` is predicted from the environment before QApplication parses argv; - // `-platform xcb` or a failed Wayland plugin can still land on GLX. Being wrong + // `wayland` is predicted before QApplication starts (from -platform, else + // QT_QPA_PLATFORM); a failed Wayland plugin can still land on GLX. Being wrong // only mislabels the summary line: the Wayland branch never sets // __GLX_VENDOR_LIBRARY_NAME, so it can't re-arm the GLX BadValue. vetoedBy = "__GLX_VENDOR_LIBRARY_NAME"; diff --git a/src/core/GpuSelector.h b/src/core/GpuSelector.h index a003138cf6..f51e7a912c 100644 --- a/src/core/GpuSelector.h +++ b/src/core/GpuSelector.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -48,8 +50,30 @@ class GpuSelector { // Called from main() BEFORE QApplication. Reads the saved choice and sets // the adapter-selection env for the active backend. No-op for Auto, when // the saved GPU is no longer present, or when the user already set the - // relevant environment variable explicitly. - static void applyAtStartup(); + // relevant environment variable explicitly. `platformArgument` is the + // -platform argument, which Qt prefers to QT_QPA_PLATFORM; empty when there + // is none. + static void applyAtStartup(const QString& platformArgument = QString()); + + // Whether the platform request Qt will use picks Wayland (EGL) rather than + // X11 (GLX). `request` is the -platform argument when there is one, else + // QT_QPA_PLATFORM. It is an ordered list ("wayland;xcb" / "xcb;wayland") + // and Qt loads the first entry it can, so only the first decides; match + // it, not a substring of the whole value ("xcb;wayland" runs on xcb). When + // it names neither, the session decides. + static bool requestPicksWayland(const QByteArray& request, bool waylandSession) + { + if (!request.isEmpty()) { + const QByteArray first = request.split(';').constFirst().trimmed(); + if (first.contains("wayland")) { + return true; + } + if (first.contains("xcb")) { + return false; + } + } + return waylandSession; + } // One-line description of what applyAtStartup() did, for logging once the // log handler is up (applyAtStartup itself runs before logging exists). diff --git a/src/core/LogManager.cpp b/src/core/LogManager.cpp index 2a65d65c26..ab069bc437 100644 --- a/src/core/LogManager.cpp +++ b/src/core/LogManager.cpp @@ -135,6 +135,7 @@ LogManager::LogManager() {"aether.icom.pan", "Icom Scope", "Icom spectrum scope: sweep frames, division reassembly, bounds"}, {"aether.icom.link", "Icom Link", "Icom backend link state: connect/disconnect, model resolution, capability publication"}, {"aether.icom.cred", "Icom Credentials", "Icom credential storage and retrieval (no secret values are logged)"}, + {"aether.nvafx", "BNR (NVIDIA AFX)", "BNR GPU noise removal: runtime pack download and install failures, network transfer errors, filter load errors"}, {"aether.sysinfo", "System Info", "Startup hardware/capability inventory: OS, CPU model + SIMD features, RAM, and the speech-engine ISA baseline check (#4986). A few lines once per launch"}, }; diff --git a/src/core/NvidiaAfxFilter.h b/src/core/NvidiaAfxFilter.h index 9a27283c98..c797672648 100644 --- a/src/core/NvidiaAfxFilter.h +++ b/src/core/NvidiaAfxFilter.h @@ -3,12 +3,15 @@ #ifdef HAVE_NVIDIA_AFX #include +#include #include #include #include #include #include +Q_DECLARE_LOGGING_CATEGORY(lcNvAfx) + namespace AetherSDR { class Resampler; diff --git a/src/core/NvidiaAfxPack.cpp b/src/core/NvidiaAfxPack.cpp index 588832f43b..53c4f61f1c 100644 --- a/src/core/NvidiaAfxPack.cpp +++ b/src/core/NvidiaAfxPack.cpp @@ -1,6 +1,7 @@ #ifdef HAVE_NVIDIA_AFX #include "NvidiaAfxPack.h" +#include "NvidiaAfxFilter.h" #include #include @@ -63,6 +64,24 @@ QString humanRate(double bps) return QStringLiteral("%1 %2").arg(v, 0, 'f', v < 10.0 ? 1 : 0).arg(QLatin1String(unit)); } +// Network failures get their own line: the user-facing message omits the +// cause for lookups. Qt 6.12 reports the transfer timeout as TimeoutError. +// OperationCanceledError counts as one too: cancel() disconnects before it +// aborts, and the write-failure abort is handled before this is called. +void logTransferFailure(const QString& label, QNetworkReply::NetworkError err, + const QString& errorString) +{ + if (err == QNetworkReply::TimeoutError || err == QNetworkReply::OperationCanceledError) { + qCWarning(lcNvAfx).noquote() + << QStringLiteral("NvidiaAfxPack: transfer timed out (stall limit %1 s): %2") + .arg(kTransferTimeoutMs / 1000).arg(label); + return; + } + qCWarning(lcNvAfx).noquote() + << QStringLiteral("NvidiaAfxPack: transfer failed: %1: %2") + .arg(label, NvidiaAfxPack::withoutUrlQueries(errorString)); +} + // "8s" / "1m 05s" / "1h 02m" from a seconds count. QString humanEta(qint64 secs) { @@ -310,6 +329,17 @@ QList NvidiaAfxPack::manifest(const QString& arch) con #endif } +QString NvidiaAfxPack::withoutUrlQueries(const QString& text) +{ + // The query runs to the next whitespace, less any closing punctuation that + // ends it: "(https://h/x?sig=1)." keeps its ")." and loses "?sig=1". + static const QRegularExpression re(QStringLiteral( + "(\\bhttps?://[^\\s?#]+)[?#]\\S*?([)\\]}>\"',.;:]*)(?=\\s|$)"), + QRegularExpression::CaseInsensitiveOption); + QString out = text; + return out.replace(re, QStringLiteral("\\1\\2")); +} + // ─── Lifecycle ─────────────────────────────────────────────────────────────── NvidiaAfxPack::NvidiaAfxPack(QObject* parent) : QObject(parent), m_nam(new QNetworkAccessManager(this)) @@ -349,7 +379,15 @@ void NvidiaAfxPack::fail(const QString& msg) if (m_dlFile) { m_dlFile->close(); delete m_dlFile; m_dlFile = nullptr; } if (!m_tmpFile.isEmpty()) { QFile::remove(m_tmpFile); m_tmpFile.clear(); } m_busy = false; - emit finished(false, msg); + // Stripped for the panel's "Failed:" text as well as for the log: a message + // can carry a URL (a redirected download, an index URL) with a signed query. + const QString shown = withoutUrlQueries(msg); + if (shown == QLatin1String("cancelled")) { + qCInfo(lcNvAfx) << "NvidiaAfxPack: install cancelled"; + } else { + qCWarning(lcNvAfx).noquote() << "NvidiaAfxPack: install failed:" << shown; + } + emit finished(false, shown); } QList NvidiaAfxPack::plannedComponents() const @@ -439,7 +477,7 @@ void NvidiaAfxPack::install() { if (m_busy) { return; } m_arch = detectArch(); - if (m_arch.isEmpty()) { emit finished(false, QStringLiteral("no supported NVIDIA GPU found")); return; } + if (m_arch.isEmpty()) { fail(QStringLiteral("no supported NVIDIA GPU found")); return; } m_busy = true; m_cancelled = false; QDir().mkpath(cacheRoot()); m_staging = stagingPath(); @@ -544,8 +582,13 @@ void NvidiaAfxPack::resolveWheelUrl(const Component& c) if (!m_reply) { return; } const QByteArray body = m_reply->readAll(); const auto err = m_reply->error(); + const QString es = m_reply->errorString(); m_reply->deleteLater(); m_reply = nullptr; - if (err != QNetworkReply::NoError) { fail(QStringLiteral("simple-index lookup failed for %1").arg(name)); return; } + if (err != QNetworkReply::NoError) { + logTransferFailure(name, err, es); + fail(QStringLiteral("simple-index lookup failed for %1").arg(name)); + return; + } // Match anchors: href="#sha256=" // We pin a specific version, so embed it in the regex to skip others fast. // The version appears between the package name and the next dash, e.g. @@ -587,8 +630,13 @@ void NvidiaAfxPack::resolveWheelUrl(const Component& c) if (!m_reply) { return; } const QByteArray body = m_reply->readAll(); const auto err = m_reply->error(); + const QString es = m_reply->errorString(); m_reply->deleteLater(); m_reply = nullptr; - if (err != QNetworkReply::NoError) { fail(QStringLiteral("PyPI lookup failed for %1").arg(name)); return; } + if (err != QNetworkReply::NoError) { + logTransferFailure(name, err, es); + fail(QStringLiteral("PyPI lookup failed for %1").arg(name)); + return; + } const QJsonObject root = QJsonDocument::fromJson(body).object(); for (const QJsonValue v : root.value(QStringLiteral("urls")).toArray()) { const QJsonObject u = v.toObject(); @@ -663,7 +711,11 @@ void NvidiaAfxPack::downloadTo(const QUrl& url, const QString& sha256, // Checked before the network-error branch: abort() above also sets an // error, but the disk-full cause is the one worth reporting. if (m_dlWriteFailed) { fail(QStringLiteral("write failed for %1 (disk full?)").arg(label)); return; } - if (err != QNetworkReply::NoError) { fail(QStringLiteral("download failed (%1): %2").arg(label, es)); return; } + if (err != QNetworkReply::NoError) { + logTransferFailure(label, err, es); + fail(QStringLiteral("download failed (%1): %2").arg(label, es)); + return; + } if (!sha256.isEmpty() && m_dlHash.result().toHex() != sha256.toLatin1()) { // A mismatch is not transient (the bytes are pinned) — say so rather @@ -801,7 +853,7 @@ void NvidiaAfxPack::assembleAndCommit() writeReceipt(current); m_busy = false; - if (installedPackDir().isEmpty()) { emit finished(false, QStringLiteral("install verification failed")); return; } + if (installedPackDir().isEmpty()) { fail(QStringLiteral("install verification failed")); return; } emit finished(true, statusText()); } @@ -825,7 +877,7 @@ QList NvidiaAfxPack::installedComponents() void NvidiaAfxPack::installFromFile(const QString& archivePath) { if (m_busy) { return; } - if (!QFile::exists(archivePath)) { emit finished(false, QStringLiteral("archive not found")); return; } + if (!QFile::exists(archivePath)) { fail(QStringLiteral("archive not found")); return; } m_busy = true; m_cancelled = false; m_idx = 0; m_done.clear(); // offline import is always a fresh, single-archive install diff --git a/src/core/NvidiaAfxPack.h b/src/core/NvidiaAfxPack.h index 92a5dc0db2..887c5574c1 100644 --- a/src/core/NvidiaAfxPack.h +++ b/src/core/NvidiaAfxPack.h @@ -77,6 +77,10 @@ class NvidiaAfxPack : public QObject { QString statusText() const; bool busy() const { return m_busy; } + // `text` with every URL's query and fragment removed, for the log: a + // redirected download URL can carry a signed, short-lived query string. + static QString withoutUrlQueries(const QString& text); + void install(); // v2 multi-source fetch + assemble void installFromFile(const QString& archivePath); // offline single-tarball void cancel(); diff --git a/src/core/backends/flex/PanadapterStream.cpp b/src/core/backends/flex/PanadapterStream.cpp index b8f4ca9b00..744e5f9f52 100644 --- a/src/core/backends/flex/PanadapterStream.cpp +++ b/src/core/backends/flex/PanadapterStream.cpp @@ -26,6 +26,19 @@ constexpr float kMinSpectrumDbm = -180.0f; constexpr int kAudioSampleRate = AudioEngine::DEFAULT_SAMPLE_RATE; constexpr int kOpusFramesPerPacket = 240; +QString streamCategoryName(PanadapterStream::StreamCategory cat) +{ + switch (cat) { + case PanadapterStream::CatAudio: return QStringLiteral("audio"); + case PanadapterStream::CatFFT: return QStringLiteral("FFT"); + case PanadapterStream::CatWaterfall: return QStringLiteral("waterfall"); + case PanadapterStream::CatMeter: return QStringLiteral("meter"); + case PanadapterStream::CatDAX: return QStringLiteral("DAX"); + case PanadapterStream::CatCount: break; + } + return QStringLiteral("other"); +} + QHostAddress chooseLanBindAddress(RadioConnection* conn, QString* chosenReason, QHostAddress* chosenAddress, @@ -435,18 +448,43 @@ void PanadapterStream::registerWfStream(quint32 streamId) void PanadapterStream::unregisterPanStream(quint32 streamId) { - QMutexLocker lock(&m_streamMutex); - m_knownPanStreams.remove(streamId); - m_frames.remove(streamId); - m_dbmRanges.remove(streamId); - m_pendingDbmRanges.remove(streamId); + { + QMutexLocker lock(&m_streamMutex); + m_knownPanStreams.remove(streamId); + m_frames.remove(streamId); + m_dbmRanges.remove(streamId); + m_pendingDbmRanges.remove(streamId); + } + restartStreamSequence(streamId); } void PanadapterStream::unregisterWfStream(quint32 streamId) { - QMutexLocker lock(&m_streamMutex); - m_knownWfStreams.remove(streamId); - m_wfFrames.remove(streamId); + { + QMutexLocker lock(&m_streamMutex); + m_knownWfStreams.remove(streamId); + m_wfFrames.remove(streamId); + } + restartStreamSequence(streamId); +} + +void PanadapterStream::restartStreamSequence(quint32 streamId) +{ + QMutexLocker statsLock(&m_statsMutex); + const auto it = m_streamStats.find(streamId); + if (it != m_streamStats.end()) { + it->lastSeq = -1; + it->started = false; + } +} + +void PanadapterStream::restartAllStreamSequences() +{ + QMutexLocker statsLock(&m_statsMutex); + for (auto it = m_streamStats.begin(); it != m_streamStats.end(); ++it) { + it->lastSeq = -1; + it->started = false; + } } void PanadapterStream::clearRegisteredStreams() @@ -471,6 +509,10 @@ void PanadapterStream::clearRegisteredStreams() m_everRegisteredPanStreams.clear(); // new session — re-arm from scratch (#3856) m_everRegisteredWfStreams.clear(); resetAudioStreamStats(); + // Sequence tracking is keyed by stream id too, and a reconnect can reuse + // an id: without this, its first packet is measured against the previous + // session's last count and logged as a sequence error. + restartAllStreamSequences(); qCDebug(lcVita49) << "PanadapterStream: cleared all registered streams"; } @@ -730,12 +772,28 @@ void PanadapterStream::processDatagram(const QByteArray& data) // Only track owned/routed streams — skip uncategorized packets. (#455) bool sequenceError = false; int audioMissedThisPacket = 0; + // Filled under the stats lock; formatted and logged after it is released. + bool reportSequenceLoss = false; + int lossHeldBackErrors = 0; + int lossErrors = 0; + qint64 lossWindowMs = 0; + int lossStreamErrors = 0; + int lossStreamPackets = 0; if (cat != CatCount) { QMutexLocker statsLock(&m_statsMutex); m_catStats[cat].bytes += data.size(); m_catStats[cat].packets++; auto& stats = m_streamStats[streamId]; stats.totalCount++; + if (!stats.started) { + stats.started = true; + stats.startErrorCount = stats.errorCount; + stats.startTotalCount = stats.totalCount - 1; + if (!m_seqLossClock.isValid()) { + m_seqLossClock.start(); + } + stats.lossLog.start(m_seqLossClock.elapsed()); + } if (stats.lastSeq >= 0) { const int expected = (stats.lastSeq + 1) & 0x0F; if (vitaSeq != expected) { @@ -747,6 +805,19 @@ void PanadapterStream::processDatagram(const QByteArray& data) audioMissedThisPacket = ((vitaSeq - stats.lastSeq - 1) & 0x0F); } + // The shared budget is asked only when this stream's line is + // due; when it says no, the error stays held for the next line. + const qint64 nowMs = m_seqLossClock.elapsed(); + const bool mayReport = + !stats.lossLog.due(nowMs) || m_seqLossBudget.allow(nowMs); + if (stats.lossLog.recordError(nowMs, mayReport)) { + reportSequenceLoss = true; + lossHeldBackErrors = m_seqLossBudget.takeSuppressed(); + lossErrors = stats.lossLog.reportErrors(); + lossWindowMs = stats.lossLog.reportWindowMs(); + lossStreamErrors = stats.errorCount - stats.startErrorCount; + lossStreamPackets = stats.totalCount - stats.startTotalCount; + } } } stats.lastSeq = vitaSeq; @@ -772,6 +843,14 @@ void PanadapterStream::processDatagram(const QByteArray& data) } } } + if (reportSequenceLoss) { + if (lossHeldBackErrors > 0) { + qCWarning(lcVita49).noquote() << formatVitaSequenceLossSuppressed(lossHeldBackErrors); + } + qCWarning(lcVita49).noquote() + << formatVitaSequenceLoss(streamCategoryName(cat), streamId, lossErrors, + lossWindowMs, lossStreamErrors, lossStreamPackets); + } if ((cat == CatFFT || cat == CatWaterfall) && PerfTelemetry::instance().enabled()) { PerfTelemetry::instance().recordStreamPacket( cat == CatWaterfall ? PerfTelemetry::FrameKind::Waterfall @@ -1556,6 +1635,7 @@ void PanadapterStream::unregisterDaxStream(quint32 streamId) } qCDebug(lcVita49) << "PanadapterStream: unregistered DAX stream" << Qt::hex << streamId; } + restartStreamSequence(streamId); if (channel) emit daxStreamUnregistered(channel, streamId); } @@ -1807,10 +1887,13 @@ void PanadapterStream::registerIqStream(quint32 streamId, int channel) void PanadapterStream::unregisterIqStream(quint32 streamId) { - QMutexLocker lock(&m_streamMutex); - m_iqStreamIds.remove(streamId); - m_loggedIqPacketStreams.remove(streamId); - qCDebug(lcVita49) << "PanadapterStream: unregistered IQ stream" << Qt::hex << streamId; + { + QMutexLocker lock(&m_streamMutex); + m_iqStreamIds.remove(streamId); + m_loggedIqPacketStreams.remove(streamId); + qCDebug(lcVita49) << "PanadapterStream: unregistered IQ stream" << Qt::hex << streamId; + } + restartStreamSequence(streamId); } void PanadapterStream::sendToRadio(const QByteArray& packet) diff --git a/src/core/backends/flex/PanadapterStream.h b/src/core/backends/flex/PanadapterStream.h index 3257f3939b..0e523fd9dc 100644 --- a/src/core/backends/flex/PanadapterStream.h +++ b/src/core/backends/flex/PanadapterStream.h @@ -5,6 +5,7 @@ #include "core/PacketLossConcealment.h" #include "core/VitaBinCoverage.h" +#include "core/backends/flex/VitaSequenceLossReport.h" #include #include @@ -86,6 +87,13 @@ class PanadapterStream : public QObject { void unregisterPanStream(quint32 streamId); void unregisterWfStream(quint32 streamId); void clearRegisteredStreams(); + // The stream's next packet starts a new instance: no sequence error + // against the previous instance's last count, and the log line's counts + // and window start over. The unregister*() calls and clearRegisteredStreams() + // do this; RadioModel calls it for every stream the radio reports removed, + // which covers streams with no registration here (remote_audio_rx). Takes + // m_statsMutex. + void restartStreamSequence(quint32 streamId); // Layer A radio-side orphan detector (#3856): processDatagram() records any // FFT/waterfall packet whose stream id was registered earlier this session but @@ -216,6 +224,7 @@ private slots: private: friend class PcmCompatibilityTestAccess; + friend class VitaSequenceLossLogTestAccess; PcmProducer m_pcmProducer; std::map> m_daxPcm; void publishLegacyDaxAudio(quint32 streamId, int channel, const QByteArray& pcm); @@ -300,10 +309,19 @@ private slots: QMap m_wfFrames; // per-stream waterfall frame assembly // Per-stream packet sequence tracking (4-bit count in VITA-49 word0 bits 19:16) + // errorCount/totalCount are cumulative for the life of the process (the + // status bar and Network Diagnostics totals). The rest describes the + // current instance of the stream and is restarted when the stream is torn + // down, because the radio can reuse the id: see restartStreamSequence(). + void restartAllStreamSequences(); struct StreamStats { int lastSeq{-1}; int errorCount{0}; int totalCount{0}; + bool started{false}; // this instance has seen its first packet + int startErrorCount{0}; // errorCount when this instance started + int startTotalCount{0}; // totalCount before its first packet + VitaSequenceLossLimiter lossLog; }; public: @@ -364,6 +382,8 @@ private slots: QMap m_frames; // per-stream FFT frame assembly QMap m_streamStats; // keyed by stream ID mutable QMutex m_statsMutex; + QElapsedTimer m_seqLossClock; // time base for StreamStats::lossLog; read only on new streams and errors + VitaSequenceLossBudget m_seqLossBudget; // all streams' lines together; under m_statsMutex CategoryStats m_catStats[CatCount]{}; struct AudioStreamTracker { diff --git a/src/core/backends/flex/VitaSequenceLossReport.h b/src/core/backends/flex/VitaSequenceLossReport.h new file mode 100644 index 0000000000..a7eca692b4 --- /dev/null +++ b/src/core/backends/flex/VitaSequenceLossReport.h @@ -0,0 +1,135 @@ +#pragma once + +#include +#include + +namespace AetherSDR { + +// Rate limit for PanadapterStream's VITA-49 sequence-error log line: at most one +// line per stream per kIntervalMs. The first error reports at once; later ones +// are held and reported with the first error after the interval, so only the +// error path touches this. Errors held when a stream goes clean are not logged. +class VitaSequenceLossLimiter { +public: + static constexpr qint64 kIntervalMs = 10000; + + // Call on the first packet of each instance of the stream: the next + // report's window starts here, and errors held from a previous instance + // are dropped. The interval since the last report still applies, so a + // stream torn down and re-created over and over logs no more often. + void start(qint64 nowMs) { m_windowStartMs = nowMs; m_pending = 0; } + + // Whether the next error would report (the interval since the last report + // has passed). + bool due(qint64 nowMs) const + { + return m_lastReportMs < 0 || nowMs - m_lastReportMs >= kIntervalMs; + } + + // Count one sequence error. Returns true when a line is due and + // `mayReport` allows it; the line's values are then reportErrors() and + // reportWindowMs(). When `mayReport` is false the error is held like any + // other, so it reaches the next line that is logged. + bool recordError(qint64 nowMs, bool mayReport = true) + { + if (m_windowStartMs < 0) { + m_windowStartMs = nowMs; + } + ++m_pending; + if (!due(nowMs) || !mayReport) { + return false; + } + m_reportErrors = m_pending; + m_reportWindowMs = nowMs - m_windowStartMs; + m_pending = 0; + m_lastReportMs = nowMs; + m_windowStartMs = nowMs; + return true; + } + + int reportErrors() const { return m_reportErrors; } + qint64 reportWindowMs() const { return m_reportWindowMs; } + +private: + qint64 m_windowStartMs{-1}; + qint64 m_lastReportMs{-1}; + int m_pending{0}; + int m_reportErrors{0}; + qint64 m_reportWindowMs{0}; +}; + +// A bound on the sequence-error lines of all streams together: at most +// kLinesPerInterval per kIntervalMs. The per-stream limiter lets the first error +// of every stream id log at once, and the VITA socket does not filter by sender, +// so without this a burst of new stream ids (many streams on a lossy link, or +// stray datagrams) could log one line per id and push the cause out of a +// support log's tail. Errors whose line the budget refuses are counted (a +// refused stream asks again on each error), and the next line logged says how +// many were held back. +class VitaSequenceLossBudget { +public: + static constexpr int kLinesPerInterval = 8; + static constexpr qint64 kIntervalMs = 10000; + + // True when a line may be logged now; otherwise the error asking is + // counted as held back. + bool allow(qint64 nowMs) + { + if (m_windowStartMs < 0 || nowMs - m_windowStartMs >= kIntervalMs) { + m_windowStartMs = nowMs; + m_used = 0; + } + if (m_used >= kLinesPerInterval) { + ++m_suppressed; + return false; + } + ++m_used; + return true; + } + + // Errors held back since the last call; resets the count. + int takeSuppressed() + { + const int n = m_suppressed; + m_suppressed = 0; + return n; + } + +private: + qint64 m_windowStartMs{-1}; + int m_used{0}; + int m_suppressed{0}; +}; + +// Logged before the next line that gets through the budget, when some were +// held back. Not matched by the analyzer rule: the lines around it are. +inline QString formatVitaSequenceLossSuppressed(int errors) +{ + return QStringLiteral("PanadapterStream: %1 VITA-49 sequence error(s) held back: " + "more than %2 line(s) in %3 s across all streams") + .arg(errors) + .arg(VitaSequenceLossBudget::kLinesPerInterval) + .arg(VitaSequenceLossBudget::kIntervalMs / 1000); +} + +// The log line the docs Log Analyzer matches on its fixed prefix. A sequence +// error is a gap in the 4-bit VITA-49 packet count, so it counts packets that +// were lost and packets that arrived out of order alike, and one gap is one +// error however many packets it skipped: the counts are errors, and the packet +// count is the packets received. +inline QString formatVitaSequenceLoss(const QString& category, quint32 streamId, + int errors, qint64 windowMs, + int totalErrors, int totalPackets) +{ + const qint64 seconds = (windowMs + 500) / 1000; + return QStringLiteral("PanadapterStream: VITA-49 sequence errors on %1 stream 0x%2: " + "%3 in the last %4 s, %5 since the stream started " + "(%6 packets received)") + .arg(category, QString::number(streamId, 16).rightJustified(8, QLatin1Char('0'))) + .arg(errors) + .arg(seconds) + .arg(totalErrors) + .arg(totalPackets); +} + +} // namespace AetherSDR diff --git a/src/main.cpp b/src/main.cpp index 654e0de511..76671bbbe5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,7 @@ #include "core/SystemInventory.h" #include "core/MacMicPermission.h" #include "core/AutomationServer.h" +#include "QtPlatformChoice.h" #ifdef Q_OS_MAC #include "MacStartupAbortGuard.h" @@ -27,8 +28,11 @@ #include "core/backends/hl2/Hl2EmergencyStop.h" #include +#include +#include #include #include +#include #include #include #include @@ -238,6 +242,12 @@ static int runConfigCli(int argc, char* argv[]) // QT_QPA_PLATFORM already). The presence itself lives in DisplayPresence.h's // accessor (setDetectedDisplayPresence) so SpectrumWidget can read it too. static const char* g_qpaPlatformChoice = nullptr; +// Whether the user's environment set QT_QPA_PLATFORM, read before main() may set +// it, so the start-up platform line can say who chose the value Qt started with. +static bool g_qpaPlatformUserSet = false; +// The -platform argument, which Qt prefers to QT_QPA_PLATFORM; read before +// QApplication removes it from argv. +static std::optional g_qpaPlatformArgument; static const char* displayPresenceName(AetherSDR::DisplayPresence p) { @@ -266,7 +276,8 @@ int main(int argc, char* argv[]) // NOTE ON ORDER: the QT_QPA_PLATFORM block runs BEFORE // GpuSelector::applyAtStartup() below. GpuSelector::willUseWayland() reads - // QT_QPA_PLATFORM to decide whether to apply the X11/GLX NVIDIA vendor hint + // the -platform argument (passed in) or else QT_QPA_PLATFORM to decide + // whether to apply the X11/GLX NVIDIA vendor hint // (__GLX_VENDOR_LIBRARY_NAME), so our platform choice must be in the // environment before it runs — otherwise a headless box that we route to // XWayland/GLX would be mistaken for EGL/Wayland and lose PRIME offload. @@ -278,8 +289,11 @@ int main(int argc, char* argv[]) // "wayland;xcb" is an ordered fallback list, so a host without the Wayland // plugin lands on xcb instead of failing to start (#1389); appimage.yml deploys // the Wayland plugin. Skipped when the user sets QT_QPA_PLATFORM (xcb is the - // documented escape hatch, README next to AETHER_NO_GPU). - if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) { + // documented escape hatch, README next to AETHER_NO_GPU), and when the user + // passes -platform, which Qt prefers to QT_QPA_PLATFORM anyway. + g_qpaPlatformUserSet = qEnvironmentVariableIsSet("QT_QPA_PLATFORM"); + g_qpaPlatformArgument = AetherSDR::QtPlatformChoice::platformArgument(argc, argv); + if (!g_qpaPlatformUserSet && !g_qpaPlatformArgument) { const QByteArray session = qgetenv("XDG_SESSION_TYPE"); if (session == "wayland" && qEnvironmentVariableIsSet("WAYLAND_DISPLAY")) { // Override to xcb only when the session is affirmatively headless (DRM @@ -307,7 +321,7 @@ int main(int argc, char* argv[]) // or QT_D3D_ADAPTER_INDEX (Windows) from the persisted Display-menu choice. // Runs AFTER the QT_QPA_PLATFORM block above so GpuSelector::willUseWayland() // reads the platform we actually chose (see the ORDER note above). - AetherSDR::GpuSelector::applyAtStartup(); + AetherSDR::GpuSelector::applyAtStartup(g_qpaPlatformArgument.value_or(QString())); // Qt 6.12's QtMultimedia defaults to its PipeWire audio backend and // segfaults enumerating devices when it cannot create a PipeWire context @@ -628,6 +642,22 @@ int main(int argc, char* argv[]) << displayPresenceName(AetherSDR::detectedDisplayPresence()) << "-> QT_QPA_PLATFORM" << g_qpaPlatformChoice; } + { + using AetherSDR::QpaRequestSource; + const QpaRequestSource source = g_qpaPlatformArgument ? QpaRequestSource::CommandLine + : g_qpaPlatformUserSet ? QpaRequestSource::User + : g_qpaPlatformChoice ? QpaRequestSource::AetherSDR + : QpaRequestSource::Unset; + const QString requested = g_qpaPlatformArgument + ? *g_qpaPlatformArgument + : qEnvironmentVariable("QT_QPA_PLATFORM"); + const QStringList lines = AetherSDR::QtPlatformChoice::logLines( + requested, source, QGuiApplication::platformName()); + qInfo().noquote() << lines.value(0); + if (lines.size() > 1) { + qWarning().noquote() << lines.at(1); + } + } // Symlink aethersdr.log → latest timestamped file (for Support dialog) const QString symlink = logDir + "/aethersdr.log"; diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index ebdab59c39..9d16936055 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -9672,6 +9672,8 @@ bool RadioModel::handleRemoteAudioRxStreamStatus(const QString& object, case RadioStatusOwnership::RemoteAudioRxAction::Removed: qCDebug(lcProtocol) << "RadioModel: owned remote_audio_rx removed" << streamText; + // Its VITA-49 sequence tracking restarts in handleDaxRxStreamRegistry(), + // which does that for every removed stream id. resetAudioStreamDiagnostics(); logRemoteAudioRxSummary(QStringLiteral("status removed")); break; @@ -10686,6 +10688,12 @@ void RadioModel::handleDaxRxStreamRegistry(const QString& object, // The removed form carries no type= (state-machines.md §7.6) — route // by id; a non-dax_rx id is a harmless no-op in the registry. m_panStream->unregisterDaxStream(stream.streamId); + // Every removed stream, whatever its type (remote_audio_rx has no + // registration in PanadapterStream): the radio can give a re-created + // stream the same id, and its first packet must not be measured + // against the old stream's last sequence count. Restarting only on + // removal never hides a gap in a stream that is still flowing. + m_panStream->restartStreamSequence(stream.streamId); // Re-arm the #1439 nudge one-shot (#4383): a genuine `stream remove` // (band switch / re-create) means the next create for a reused id must // be allowed to nudge again. The transient unbind echo does NOT reach diff --git a/tests/async_log_writer_test.cpp b/tests/async_log_writer_test.cpp index a4682be33b..91473a763a 100644 --- a/tests/async_log_writer_test.cpp +++ b/tests/async_log_writer_test.cpp @@ -706,6 +706,16 @@ void testNegativeCorpus(const QString& dir) {"peer 2001:db8::dead:beef established", "2001:db8"}, {"QRZ session for auditor@example.com failed", "auditor@example.com"}, {"rotator disconnected from audit-rotator.lan", "audit-rotator"}, + // PanadapterStream's default-level VITA lines (#6285 review): the + // address is a field value with no host keyword; the IPv4 and IPv6 + // shape rules cover it. An IPv4 port stays readable; an unbracketed + // IPv6 "addr:port" is masked whole. + {"PanadapterStream: LAN VITA UDP bind addr=192.168.50.37 port=4993 flags=DontShareAddress", + "192.168.50.37"}, + {"PanadapterStream: first UDP packet received after_ms=12 local_port=4993 from=192.168.50.121:4991", + "192.168.50.121"}, + {"PanadapterStream: first UDP packet received after_ms=12 local_port=4993 from=fd12:3456:789a::5:4991", + "fd12:3456"}, }; bool allClean = true; int index = 0; diff --git a/tests/nvidia_afx_pack_log_test.cpp b/tests/nvidia_afx_pack_log_test.cpp new file mode 100644 index 0000000000..f1303f0525 --- /dev/null +++ b/tests/nvidia_afx_pack_log_test.cpp @@ -0,0 +1,98 @@ +// The BNR pack's failure lines on aether.nvafx (#6285). Socket-free: the +// offline import of a missing archive fails before any I/O, and the URL +// stripper is a pure function. install() is never called; it would start a +// real download on a machine with a supported GPU. + +#include "core/NvidiaAfxPack.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace AetherSDR; + +namespace { + +int g_failed = 0; + +void report(const char* name, bool ok) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", name); + if (!ok) { + ++g_failed; + } +} + +QStringList g_lines; + +void capture(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) +{ + const char* level = type == QtWarningMsg ? "W" : type == QtInfoMsg ? "I" : "D"; + g_lines << QStringLiteral("%1 %2: %3") + .arg(QLatin1String(level), QLatin1String(ctx.category ? ctx.category : ""), msg); +} + +} // namespace + +int main(int argc, char** argv) +{ + QTemporaryDir home(QDir::tempPath() + "/aether-afx-pack-log-test-XXXXXX"); + if (!home.isValid()) { + std::printf("[FAIL] create temporary home\n"); + return 1; + } + qputenv("HOME", home.path().toUtf8()); + qputenv("XDG_DATA_HOME", home.path().toUtf8()); + QStandardPaths::setTestModeEnabled(true); + QCoreApplication app(argc, argv); + // The product's default rule set: Warning and Info pass, Debug does not. + QLoggingCategory::setFilterRules(QStringLiteral("aether.*.debug=false")); + qInstallMessageHandler(capture); + + { + NvidiaAfxPack pack; + bool finished = false; + bool ok = true; + QString message; + QObject::connect(&pack, &NvidiaAfxPack::finished, [&](bool o, const QString& m) { + finished = true; + ok = o; + message = m; + }); + pack.installFromFile(home.filePath("missing-afx-pack.tar.zst")); + report("missing archive: finished(false)", finished && !ok); + report("missing archive: message unchanged", message == "archive not found"); + report("missing archive: logged on aether.nvafx as a warning", + g_lines.contains("W aether.nvafx: NvidiaAfxPack: install failed: archive not found")); + report("missing archive: pack is not left busy", !pack.busy()); + } + + report("signed query removed", + NvidiaAfxPack::withoutUrlQueries( + "Error transferring https://objects.example.com/a/b.tar.zst?X-Amz-Signature=abc&X-Amz-Credential=k - server replied: Forbidden") + == "Error transferring https://objects.example.com/a/b.tar.zst - server replied: Forbidden"); + report("fragment removed", + NvidiaAfxPack::withoutUrlQueries("see http://h/x#sha256=deadbeef now") == "see http://h/x now"); + report("closing punctuation after a query is kept", + NvidiaAfxPack::withoutUrlQueries("failed (https://h/x?sig=abc). Retry") + == "failed (https://h/x). Retry"); + report("a query with brackets inside is removed whole", + NvidiaAfxPack::withoutUrlQueries("at https://h/x?a=(1)&sig=abc end") == "at https://h/x end"); + report("a query at the end of the text is removed", + NvidiaAfxPack::withoutUrlQueries("from https://h/x?sig=abc") == "from https://h/x"); + report("an upper-case scheme is stripped too", + NvidiaAfxPack::withoutUrlQueries("from HTTPS://h/x?sig=abc") == "from HTTPS://h/x"); + report("text without URLs unchanged", + NvidiaAfxPack::withoutUrlQueries("checksum mismatch for AFX (corrupt or stale mirror)") + == "checksum mismatch for AFX (corrupt or stale mirror)"); + + qInstallMessageHandler(nullptr); + std::printf("%s\n", g_failed ? "FAILED" : "PASSED"); + return g_failed ? 1 : 0; +} diff --git a/tests/qt_platform_choice_test.cpp b/tests/qt_platform_choice_test.cpp new file mode 100644 index 0000000000..a8d3518d90 --- /dev/null +++ b/tests/qt_platform_choice_test.cpp @@ -0,0 +1,131 @@ +// The start-up "Platform: Qt platform plugin" lines (#6285): which plugin is +// running, who asked for it, and whether Qt fell back from the first choice. +// Pure: QtPlatformChoice takes the values main.cpp reads (QT_QPA_PLATFORM and +// the -platform argument Qt prefers to it), no QGuiApplication. + +#include "QtPlatformChoice.h" +#include "core/GpuSelector.h" + +#include +#include + +#include + +using namespace AetherSDR; + +namespace { + +int g_failed = 0; + +void report(const char* name, bool ok) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", name); + if (!ok) { + ++g_failed; + } +} + +} // namespace + +int main() +{ + report("first of a list", QtPlatformChoice::firstRequested("wayland;xcb") == "wayland"); + report("first drops plugin arguments", QtPlatformChoice::firstRequested("xcb:foo=1") == "xcb"); + report("first of nothing", QtPlatformChoice::firstRequested("").isEmpty()); + + report("wayland-egl is not a fallback from wayland", + !QtPlatformChoice::fellBack("wayland;xcb", "wayland-egl")); + report("xcb is a fallback from wayland;xcb", QtPlatformChoice::fellBack("wayland;xcb", "xcb")); + report("wayland is a fallback from xcb;wayland", QtPlatformChoice::fellBack("xcb;wayland", "wayland")); + report("unset is never a fallback", !QtPlatformChoice::fellBack("", "xcb")); + report("wayland is not a fallback from wayland-egl", + !QtPlatformChoice::fellBack("wayland-egl", "wayland")); + + { + const QStringList l = QtPlatformChoice::logLines("wayland;xcb", QpaRequestSource::AetherSDR, "wayland"); + report("chosen by AetherSDR, loaded: one line", l.size() == 1); + report("chosen by AetherSDR, loaded: text", + l.value(0) == "Platform: Qt platform plugin \"wayland\" (QT_QPA_PLATFORM=wayland;xcb, set by AetherSDR)"); + } + { + const QStringList l = QtPlatformChoice::logLines("wayland;xcb", QpaRequestSource::AetherSDR, "xcb"); + report("fallback: two lines", l.size() == 2); + report("fallback: text", + l.value(1) == "Platform: Qt fell back from \"wayland\" to \"xcb\"; the first plugin in QT_QPA_PLATFORM did not load"); + } + { + const QStringList l = QtPlatformChoice::logLines("offscreen", QpaRequestSource::User, "offscreen"); + report("user setting: text", + l.size() == 1 + && l.value(0) == "Platform: Qt platform plugin \"offscreen\" (QT_QPA_PLATFORM=offscreen, set by the user)"); + } + { + const QStringList l = QtPlatformChoice::logLines("", QpaRequestSource::Unset, "cocoa"); + report("unset: text", + l.size() == 1 + && l.value(0) == "Platform: Qt platform plugin \"cocoa\" (QT_QPA_PLATFORM unset, Qt default)"); + } + + { + // A CR or LF in the request cannot forge a second log line. + const QStringList l = QtPlatformChoice::logLines( + "xcb\n[09:14:02.101] WRN default: forged", QpaRequestSource::User, "xcb"); + report("newline in the request: one line, no line break", + l.size() == 1 && !l.value(0).contains(QLatin1Char('\n')) + && !l.value(0).contains(QLatin1Char('\r'))); + } + { + // Qt prefers -platform to QT_QPA_PLATFORM: an explicit override is not a fallback. + const QStringList l = QtPlatformChoice::logLines("minimal", QpaRequestSource::CommandLine, "minimal"); + report("command line override: one line", l.size() == 1); + report("command line override: text", + l.value(0) == "Platform: Qt platform plugin \"minimal\" (-platform minimal, on the command line)"); + } + { + const QStringList l = QtPlatformChoice::logLines("nosuchplugin;offscreen", QpaRequestSource::CommandLine, + "offscreen"); + report("command line fallback: two lines", l.size() == 2); + report("command line fallback: text", + l.value(1) == "Platform: Qt fell back from \"nosuchplugin\" to \"offscreen\"; the first plugin in -platform did not load"); + } + { + const char* none[] = {"AetherSDR", "--profile", "x"}; + report("no -platform argument", !QtPlatformChoice::platformArgument(3, none).has_value()); + const char* one[] = {"AetherSDR", "-platform", "minimal"}; + report("-platform value", QtPlatformChoice::platformArgument(3, one).value_or("") == "minimal"); + const char* dashes[] = {"AetherSDR", "--platform", "xcb"}; + report("--platform value", QtPlatformChoice::platformArgument(3, dashes).value_or("") == "xcb"); + const char* twice[] = {"AetherSDR", "-platform", "xcb", "-platform", "wayland;xcb"}; + report("the last -platform wins", + QtPlatformChoice::platformArgument(5, twice).value_or("") == "wayland;xcb"); + const char* dangling[] = {"AetherSDR", "-platform"}; + report("-platform with no value is ignored", !QtPlatformChoice::platformArgument(2, dangling).has_value()); + const char* other[] = {"AetherSDR", "-platformtheme", "gtk3"}; + report("-platformtheme is not -platform", !QtPlatformChoice::platformArgument(3, other).has_value()); + } + + { + // GpuSelector's GLX/EGL choice reads the same request Qt will use: the + // -platform argument when there is one, so `-platform xcb` in a Wayland + // session is X11 (GLX). + const auto request = [](const char* const* argv, int argc) { + return QtPlatformChoice::platformArgument(argc, argv).value_or(QString()).toLocal8Bit(); + }; + const char* xcb[] = {"AetherSDR", "-platform", "xcb"}; + report("-platform xcb in a Wayland session is X11", + !GpuSelector::requestPicksWayland(request(xcb, 3), true)); + const char* wl[] = {"AetherSDR", "--platform", "wayland"}; + report("--platform wayland in an X11 session is Wayland", + GpuSelector::requestPicksWayland(request(wl, 3), false)); + report("xcb;wayland runs on xcb", !GpuSelector::requestPicksWayland("xcb;wayland", true)); + report("wayland-egl is Wayland", GpuSelector::requestPicksWayland("wayland-egl", false)); + report("offscreen falls back to the session", + GpuSelector::requestPicksWayland("offscreen", true) + && !GpuSelector::requestPicksWayland("offscreen", false)); + report("no request: the session decides", + GpuSelector::requestPicksWayland("", true) && !GpuSelector::requestPicksWayland("", false)); + } + + std::printf("%s\n", g_failed ? "FAILED" : "PASSED"); + return g_failed ? 1 : 0; +} diff --git a/tests/remote_audio_rx_sequence_restart_test.cpp b/tests/remote_audio_rx_sequence_restart_test.cpp new file mode 100644 index 0000000000..25a7dbacff --- /dev/null +++ b/tests/remote_audio_rx_sequence_restart_test.cpp @@ -0,0 +1,111 @@ +// remote_audio_rx has no registration in PanadapterStream, so RadioModel +// restarts the VITA-49 sequence tracking of every stream the radio reports +// removed (#6285), and of nothing else: a status for a stream still flowing +// must not hide a gap. The radio can give a re-created stream the same +// id; without the restart its first packet is measured against the old +// stream's last count and counted (and logged) as a sequence error. +// Socket-free: a backend with no transport, status lines fed to the model's +// router, and meter-class packets fed straight to processDatagram(). The +// restart is keyed by stream id alone, whatever the packet class. + +#include "TestSettingsProfile.h" +#include "core/backends/IRadioBackend.h" +#include "core/backends/flex/PanadapterStream.h" +#include "models/RadioModel.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace AetherSDR { +class VitaSequenceLossLogTestAccess { +public: + static void feedMeter(PanadapterStream& stream, quint32 streamId, int seq) + { + QByteArray packet(PanadapterStream::VITA49_HEADER_BYTES + 4, '\0'); + auto* raw = reinterpret_cast(packet.data()); + qToBigEndian(0x18000000u | (quint32(seq & 0x0F) << 16), raw); + qToBigEndian(streamId, raw + 4); + qToBigEndian(PanadapterStream::PCC_METER, raw + 12); + qToBigEndian(1, raw + PanadapterStream::VITA49_HEADER_BYTES); + stream.processDatagram(packet); + } +}; +} // namespace AetherSDR + +using namespace AetherSDR; + +namespace { + +int g_failed = 0; + +void report(const char* name, bool ok) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", name); + if (!ok) { + ++g_failed; + } +} + +class Backend final : public IRadioBackend { +public: + RadioCapabilities capabilities() const override { return {}; } + bool ownsRxAudio() const override { return true; } + void connectRadio(const RadioConnectRequest&) override { emit connected(); } + void disconnectRadio() override { emit disconnected(); } + bool isConnected() const override { return false; } + void setSliceFrequency(int, double) override {} + void setSliceMode(int, const QString&) override {} + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + void setPanCenter(const QString&, double, PanCenterIntent) override {} + void setKeying(bool, const TxCoordinator::Operation&, + const TxCoordinator::Completion&) override {} // no TX transport + void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {} + std::unique_ptr stream = std::make_unique(); +}; + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("remote-audio-rx-seq-restart")); + QCoreApplication app(argc, argv); + + RadioModel radio; + auto backend = std::make_unique(); + PanadapterStream* stream = backend->stream.get(); // no init(), no socket + radio.setBackendForTest(std::move(backend), QStringLiteral("rtl"), stream); + + const quint32 id = 0x04000008u; + const QString object = QStringLiteral("stream 0x04000008"); + for (int seq = 0; seq < 3; ++seq) { + VitaSequenceLossLogTestAccess::feedMeter(*stream, id, seq); + } + report("in-order packets count no error", stream->packetErrorCount() == 0); + + // The radio reports the stream; it keeps flowing, so a gap across the + // status line is a real one. + radio.handleStatusForTest(object, {{QStringLiteral("type"), QStringLiteral("remote_audio_rx")}, + {QStringLiteral("compression"), QStringLiteral("none")}}); + VitaSequenceLossLogTestAccess::feedMeter(*stream, id, 5); // 3..4 missing + report("adopted: a gap in a stream still flowing counts", stream->packetErrorCount() == 1); + VitaSequenceLossLogTestAccess::feedMeter(*stream, id, 6); + + // The radio removes it; the next stream with this id starts over. + radio.handleStatusForTest(object + QStringLiteral(" removed"), {}); + VitaSequenceLossLogTestAccess::feedMeter(*stream, id, 0); // old instance ended at 6 + report("removed: a re-created stream's first packet counts no error", + stream->packetErrorCount() == 1); + + VitaSequenceLossLogTestAccess::feedMeter(*stream, id, 4); // 1..3 missing + report("a real gap still counts", stream->packetErrorCount() == 2); + + std::printf("%s\n", g_failed ? "FAILED" : "PASSED"); + return g_failed ? 1 : 0; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index bd9967c948..a122ae2463 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -2862,8 +2862,41 @@ if(ENABLE_NVIDIA_AFX AND ((UNIX AND NOT APPLE) OR WIN32) AND CMAKE_SYSTEM_PROCES target_link_libraries(nvidia_afx_filter_test PRIVATE ${CMAKE_DL_LIBS}) endif() add_test(NAME nvidia_afx_filter_test COMMAND nvidia_afx_filter_test) + + # The BNR pack's failure lines on aether.nvafx that the docs Log Analyzer + # matches (#6285). Socket-free: imports a missing archive, never install(). + add_executable(nvidia_afx_pack_log_test tests/nvidia_afx_pack_log_test.cpp) + target_include_directories(nvidia_afx_pack_log_test PRIVATE src) + target_link_libraries(nvidia_afx_pack_log_test PRIVATE aethercore Qt6::Core) + add_test(NAME nvidia_afx_pack_log_test COMMAND nvidia_afx_pack_log_test) endif() +# PanadapterStream's rate-limited VITA-49 sequence-error line and its text, which +# the docs Log Analyzer matches (#6285). Socket-free: meter packets are fed to +# processDatagram() on a stream that never binds. +add_executable(vita_sequence_loss_log_test tests/vita_sequence_loss_log_test.cpp) +target_include_directories(vita_sequence_loss_log_test PRIVATE src) +target_link_libraries(vita_sequence_loss_log_test PRIVATE aethercore Qt6::Core) +add_test(NAME vita_sequence_loss_log_test COMMAND vita_sequence_loss_log_test) + +# RadioModel restarts remote_audio_rx's VITA-49 sequence tracking when the radio +# reports the stream adopted or removed (#6285). Socket-free: status lines go to +# the model's router and packets straight to processDatagram(). +add_executable(remote_audio_rx_sequence_restart_test tests/remote_audio_rx_sequence_restart_test.cpp) +target_include_directories(remote_audio_rx_sequence_restart_test PRIVATE src tests) +target_link_libraries(remote_audio_rx_sequence_restart_test PRIVATE aethercore Qt6::Core) +add_test(NAME remote_audio_rx_sequence_restart_test COMMAND remote_audio_rx_sequence_restart_test) + +# The start-up "Platform: Qt platform plugin" line and its fallback line (#6285). +# Pure: no QGuiApplication. +add_executable(qt_platform_choice_test + tests/qt_platform_choice_test.cpp + src/QtPlatformChoice.cpp +) +target_include_directories(qt_platform_choice_test PRIVATE src) +target_link_libraries(qt_platform_choice_test PRIVATE Qt6::Core) +add_test(NAME qt_platform_choice_test COMMAND qt_platform_choice_test) + # Pure, headless, hardware-free golden matrix for the consolidated audio # format/rate negotiation policy (#3306). TargetOs is data, so this one binary # exercises the Windows/macOS/Linux ladders regardless of the CI host. diff --git a/tests/vita_sequence_loss_log_test.cpp b/tests/vita_sequence_loss_log_test.cpp new file mode 100644 index 0000000000..b3809c79fd --- /dev/null +++ b/tests/vita_sequence_loss_log_test.cpp @@ -0,0 +1,275 @@ +// The VITA-49 sequence-error log line (#6285): its rate limit and its text, +// then the same line from PanadapterStream itself. Socket-free: the limiter +// takes explicit timestamps, and meter packets are fed straight to +// processDatagram() on a stream that never binds. The prefix is what the docs +// Log Analyzer rule vita-sequence-errors matches. + +#include "core/backends/flex/PanadapterStream.h" +#include "core/backends/flex/VitaSequenceLossReport.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace AetherSDR { +class VitaSequenceLossLogTestAccess { +public: + // A meter packet: header word0 carries the 4-bit sequence count, word3's + // low 16 bits the packet class code; one meter id/value pair as payload. + static void feedMeter(PanadapterStream& stream, quint32 streamId, int seq) + { + QByteArray packet(PanadapterStream::VITA49_HEADER_BYTES + 4, '\0'); + auto* raw = reinterpret_cast(packet.data()); + qToBigEndian(0x18000000u | (quint32(seq & 0x0F) << 16), raw); + qToBigEndian(streamId, raw + 4); + qToBigEndian(PanadapterStream::PCC_METER, raw + 12); + qToBigEndian(1, raw + PanadapterStream::VITA49_HEADER_BYTES); + stream.processDatagram(packet); + } +}; +} // namespace AetherSDR + +using namespace AetherSDR; + +namespace { + +QStringList g_warnings; + +void capture(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) +{ + if (type == QtWarningMsg) { + g_warnings << QString::fromLatin1(ctx.category ? ctx.category : "") + ": " + msg; + } +} + +int g_failed = 0; + +void report(const char* name, bool ok) +{ + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", name); + if (!ok) { + ++g_failed; + } +} + +} // namespace + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + { + VitaSequenceLossLimiter l; + l.start(1000); + report("first error reports at once", l.recordError(4000)); + report("first report counts one error", l.reportErrors() == 1); + report("first window runs from the first packet", l.reportWindowMs() == 3000); + + int lines = 0; + for (qint64 t = 4001; t < 14000; t += 10) { + if (l.recordError(t)) { + ++lines; + } + } + report("no second line inside the interval", lines == 0); + + report("the first error after the interval reports", l.recordError(14000)); + report("it carries every error held back plus itself", l.reportErrors() == 1001); + report("its window is the interval", l.reportWindowMs() == 10000); + } + + { + // A steady error stream yields one line per interval, never one per packet. + VitaSequenceLossLimiter l; + l.start(0); + int lines = 0; + int counted = 0; + for (qint64 t = 0; t <= 60000; t += 5) { + if (l.recordError(t)) { + ++lines; + counted += l.reportErrors(); + } + } + report("one line per 10 s over a minute", lines == 7); + report("lines account for every error up to the last report", counted == 12001); + } + + { + // An isolated error long after the previous report still reports. + VitaSequenceLossLimiter l; + l.start(0); + l.recordError(100); + report("isolated later error reports", l.recordError(500000)); + report("isolated later error counts one", l.reportErrors() == 1); + } + + { + // A stream re-created inside the interval: the rate limit holds across + // the restart, and errors held from the old instance are dropped. + VitaSequenceLossLimiter l; + l.start(0); + report("restart: first instance reports", l.recordError(100)); + l.recordError(200); // held + l.start(2000); + report("restart: a new instance's error inside the interval is held", !l.recordError(3000)); + report("restart: the next report is due after the interval", l.recordError(10100)); + report("restart: it counts only the new instance's errors", l.reportErrors() == 2); + report("restart: its window runs from the new instance", l.reportWindowMs() == 8100); + } + + { + // A line the shared budget refuses is not spent: the error is held and + // reaches this stream's next line, which is still due at once. + VitaSequenceLossLimiter l; + l.start(0); + report("refused: a due line is not reported", !l.recordError(100, false)); + report("refused: the stream's next line is still due", l.due(200)); + report("refused: the next line reports at once", l.recordError(200)); + report("refused: it carries the refused error", l.reportErrors() == 2); + } + + { + // All streams together: at most kLinesPerInterval lines per interval, + // and the next line logged after that says how many were held back. + VitaSequenceLossBudget b; + int allowed = 0; + for (int i = 0; i < 20; ++i) { + if (b.allow(1000 + i)) { + ++allowed; + } + } + report("budget: 8 lines in an interval", allowed == VitaSequenceLossBudget::kLinesPerInterval); + report("budget: the next interval allows again", b.allow(11000)); + report("budget: it reports the lines held back", b.takeSuppressed() == 12); + report("budget: the count resets", b.takeSuppressed() == 0); + report("budget: held-back text", + formatVitaSequenceLossSuppressed(12) + == QStringLiteral("PanadapterStream: 12 VITA-49 sequence error(s) held back: " + "more than 8 line(s) in 10 s across all streams")); + } + + { + // A stream that never saw start() still reports a sane window. + VitaSequenceLossLimiter l; + report("no start(): reports", l.recordError(7000)); + report("no start(): zero window", l.reportWindowMs() == 0); + } + + { + const QString line = formatVitaSequenceLoss(QStringLiteral("FFT"), 0x40000000u, + 12, 10400, 31, 52310); + report("line text", + line == QStringLiteral("PanadapterStream: VITA-49 sequence errors on FFT stream " + "0x40000000: 12 in the last 10 s, 31 since the stream " + "started (52310 packets received)")); + report("stream id padded to 8 hex digits, as the radio lists it", + formatVitaSequenceLoss(QStringLiteral("audio"), 0x04000008u, 1, 0, 1, 2) + .contains(QStringLiteral(" stream 0x04000008: "))); + report("analyzer prefix", + line.startsWith(QStringLiteral("PanadapterStream: VITA-49 sequence errors on "))); + } + + { + // The product's default rules: aether.vita49 is declared QtWarningMsg, + // so the line must be a warning to reach a default log. + QLoggingCategory::setFilterRules(QStringLiteral("aether.*.debug=false")); + qInstallMessageHandler(capture); + PanadapterStream stream; // no init(), no socket + const quint32 id = 0x7000000Au; + for (int seq = 0; seq < 4; ++seq) { + VitaSequenceLossLogTestAccess::feedMeter(stream, id, seq); + } + report("in-order packets log nothing", g_warnings.isEmpty()); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 7); // 4..6 missing + report("a gap logs one warning", g_warnings.size() == 1); + report("on aether.vita49 with the stream, category and counts", + g_warnings.value(0) == QStringLiteral( + "aether.vita49: PanadapterStream: VITA-49 sequence errors on meter stream " + "0x7000000a: 1 in the last 0 s, 1 since the stream started (5 packets received)")); + for (int i = 0; i < 50; ++i) { + VitaSequenceLossLogTestAccess::feedMeter(stream, id, i * 3); + } + report("a burst of gaps inside 10 s adds no line", g_warnings.size() == 1); + report("the live counter still has every error", + stream.categoryStats(PanadapterStream::CatMeter).errors > 40); + qInstallMessageHandler(nullptr); + } + + { + // Disconnect, then a reconnect whose stream reuses the id. The first + // packet is measured against nothing, not against the previous + // session's last count, and the line's counts start over. A fresh + // stream, so the limiter has no recent report that would hide a false + // line. + g_warnings.clear(); + qInstallMessageHandler(capture); + PanadapterStream stream; // no init(), no socket + const quint32 id = 0x7000000Bu; + for (int seq = 0; seq < 3; ++seq) { + VitaSequenceLossLogTestAccess::feedMeter(stream, id, seq); + } + const int totalBefore = stream.packetTotalCount(); + stream.clearRegisteredStreams(); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 0); // previous session ended at 2 + report("reconnect: a reused stream id logs no sequence error", g_warnings.isEmpty()); + report("reconnect: no error counted", + stream.packetErrorCount() == 0 + && stream.categoryStats(PanadapterStream::CatMeter).errors == 0); + report("reconnect: the cumulative packet total is kept", + stream.packetTotalCount() == totalBefore + 1); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 1); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 5); // 2..4 missing + report("reconnect: a real gap still logs at once", + g_warnings.size() == 1 + && g_warnings.value(0) == QStringLiteral( + "aether.vita49: PanadapterStream: VITA-49 sequence errors on meter stream " + "0x7000000b: 1 in the last 0 s, 1 since the stream started (3 packets received)")); + + // A pan or waterfall closed and re-created with the same id restarts too. + // The limiter reported just above, so it would hold a line back: the + // error count is what shows the restart. + const int errorsBefore = stream.packetErrorCount(); + stream.unregisterPanStream(id); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 9); // previous instance ended at 5 + report("unregister pan: a re-created stream counts no sequence error", + stream.packetErrorCount() == errorsBefore); + stream.unregisterWfStream(id); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 2); // previous instance ended at 9 + report("unregister waterfall: a re-created stream counts no sequence error", + stream.packetErrorCount() == errorsBefore); + stream.unregisterDaxStream(id); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 12); + report("unregister DAX: a re-created stream counts no sequence error", + stream.packetErrorCount() == errorsBefore); + stream.unregisterIqStream(id); + VitaSequenceLossLogTestAccess::feedMeter(stream, id, 4); + report("unregister IQ: a re-created stream counts no sequence error", + stream.packetErrorCount() == errorsBefore); + qInstallMessageHandler(nullptr); + } + + { + // Many new stream ids with a gap each, as from a burst of stray + // datagrams: the first error of each id is due at once, but the lines + // stop at the shared budget. + g_warnings.clear(); + qInstallMessageHandler(capture); + PanadapterStream stream; // no init(), no socket + for (quint32 i = 0; i < 20; ++i) { + VitaSequenceLossLogTestAccess::feedMeter(stream, 0x71000000u + i, 0); + VitaSequenceLossLogTestAccess::feedMeter(stream, 0x71000000u + i, 5); + } + report("burst of new ids: lines stop at the shared budget", + g_warnings.size() == VitaSequenceLossBudget::kLinesPerInterval); + report("burst of new ids: every error is still counted", stream.packetErrorCount() == 20); + qInstallMessageHandler(nullptr); + } + + std::printf("%s\n", g_failed ? "FAILED" : "PASSED"); + return g_failed ? 1 : 0; +} diff --git a/tools/docs/snapshot_stable.py b/tools/docs/snapshot_stable.py index 108fc53445..0e2b783e31 100644 --- a/tools/docs/snapshot_stable.py +++ b/tools/docs/snapshot_stable.py @@ -19,6 +19,9 @@ * versions.json is ["stable"]; * stable-version.json's label becomes the version, which the version menu shows; + * every Log Analyzer rule link (src/components/LogAnalyzer/rules.json) + to /next/ whose page is in the new snapshot becomes /, so + users of the release land on its docs, not on Next; * each translation's i18n//docusaurus-plugin-content-docs/current/ is copied to version-stable/ beside it. Re-run `npm run write-translations -- --locale ` afterwards for the @@ -49,6 +52,7 @@ VERSIONED_SIDEBARS = SITE / "versioned_sidebars" / f"version-{VERSION}-sidebars.json" VERSIONS_JSON = SITE / "versions.json" LABEL_JSON = SITE / "stable-version.json" +RULES_JSON = SITE / "src" / "components" / "LogAnalyzer" / "rules.json" I18N = SITE / "i18n" @@ -85,6 +89,48 @@ def replace_tree(src: Path, dest: Path) -> None: tmp.rename(dest) +def page_slugs(docs_dir: Path) -> set[str]: + """The page slugs Docusaurus serves from a docs tree's top level: the + front-matter slug, else /. tools/docs/test_log_rules.mjs reads + the same pages to check rule links.""" + slugs = set() + for f in docs_dir.iterdir(): + if f.suffix not in (".md", ".mdx"): + continue + slug = "/" + f.stem + text = f.read_text(encoding="utf-8") + fm = re.match(r"^---\n(.*?)\n---", text, re.S) + if fm: + m = re.search(r'^slug:\s*"?([^"\n]+)"?\s*$', fm.group(1), re.M) + if m: + slug = m.group(1) if m.group(1).startswith("/") else "/" + m.group(1) + slugs.add(slug) + return slugs + + +def stabilise_rule_links(rules_json: Path, stable_slugs: set[str]) -> list[str]: + """Rewrite "docs": "/next/[#anchor]" to "/[#anchor]" for each + page the Stable snapshot now has. The snapshot is a copy of Next, so its + anchors are there too. Edits the text in place to keep the file's layout. + Returns the links it rewrote.""" + if not rules_json.is_file(): + return [] + changed = [] + + def swap(m: re.Match) -> str: + page, anchor = m.group(2), m.group(3) or "" + if page not in stable_slugs: + return m.group(0) + changed.append(f"/next{page}{anchor}") + return f'{m.group(1)}{page}{anchor}"' + + text = rules_json.read_text(encoding="utf-8") + new = re.sub(r'("docs":\s*")/next(/[^"#]*)(#[^"]*)?"', swap, text) + if new != text: + rules_json.write_text(new, encoding="utf-8") + return changed + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("version", help="the release the snapshot describes, e.g. 26.10.2") @@ -106,6 +152,10 @@ def main() -> int: VERSIONS_JSON.write_text(json.dumps([VERSION]) + "\n", encoding="utf-8") LABEL_JSON.write_text(json.dumps({"label": label}, indent=2) + "\n", encoding="utf-8") + moved = stabilise_rule_links(RULES_JSON, page_slugs(VERSIONED_DOCS)) + for link in moved: + print(f"Log Analyzer rule link {link} -> Stable") + pages = sum(1 for _ in VERSIONED_DOCS.rglob("*.md")) print(f"stable snapshot ({label}): {pages} pages -> {VERSIONED_DOCS.relative_to(REPO)}") print(f"wrote {VERSIONED_SIDEBARS.relative_to(REPO)}, " diff --git a/tools/docs/test_log_rules.mjs b/tools/docs/test_log_rules.mjs index fc0997ad77..5d4a521bd6 100644 --- a/tools/docs/test_log_rules.mjs +++ b/tools/docs/test_log_rules.mjs @@ -150,6 +150,7 @@ for (const f of fs.readdirSync(ruleSampleDir)) { check(s.os && s.os.startsWith('Ubuntu'), `clean.log summary os: ${s.os}`); check(s.radioModel === 'FLEX-8600', `clean.log summary radio model: ${s.radioModel}`); check(s.firmware && s.firmware.startsWith('4.'), `clean.log summary firmware: ${s.firmware}`); + check(s.qtPlatform === 'xcb', `clean.log summary Qt platform plugin: ${s.qtPlatform}`); } function summaryPatterns() {