[fix](trx-frontend-http): stop the decode history replay giving up at 20s
Reloading a second time sometimes showed history the first load did not, and the safety valve is why: it called one function that both released the buffered live decodes and tore the history worker down, so any load where the replay had not finished inside twenty seconds — a large backlog, a cold cache, a slow link — dropped whatever had not arrived, without a word. A reload got another go at it, and the second one is faster because everything is cached by then. Those are two separate things now. At the timeout the live decodes are released so the panels are not held back, the replay carries on, and the progress says so. The fallback's error path retries once and then says "Decode history unavailable" rather than leaving the operator to guess whether there was anything to see. The progress is no longer a scrim. It was fixed to the whole viewport with a wash over the page — the waterfall, the decode panels, all of it — for the length of the replay, which is exactly when there is something worth watching. It is a corner card with a bar: indeterminate while the payload is on the wire, then filling as N of M messages replay. None of this was reachable from a test. /decode/history answers in CBOR and the worker reads the body as CBOR unconditionally, but the fixture served JSON, so every browser run had been exercising the client's retry path and never its history path. It encodes CBOR now, including the 64-bit form the millisecond timestamps need, and decode-flow serves 1200 records and holds the client to restoring all of them on the first load, showing progress while it does, and never covering the page with it. Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -2065,6 +2065,7 @@ var loadingSub = requiredElement("loading-sub");
|
|||||||
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -2214,10 +2215,19 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var overviewDrawPending = false;
|
var overviewDrawPending = false;
|
||||||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
|
||||||
if (!decodeHistoryOverlayEl) return;
|
if (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||||
|
if (decodeHistoryProgressBarEl) {
|
||||||
|
if (fraction == null) {
|
||||||
|
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||||
|
decodeHistoryProgressBarEl.style.width = "";
|
||||||
|
} else {
|
||||||
|
delete decodeHistoryOverlayEl.dataset.phase;
|
||||||
|
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
||||||
@@ -7519,16 +7529,15 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue = [];
|
const historyGroupQueue = [];
|
||||||
const liveBuffer = [];
|
const liveBuffer = [];
|
||||||
function flushLiveBuffer() {
|
function releaseLiveBuffer() {
|
||||||
|
if (historySettled) return;
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try {
|
try {
|
||||||
dispatchDecodeMessage(msg);
|
dispatchDecodeMessage(msg);
|
||||||
@@ -7537,19 +7546,23 @@ function connectDecode() {
|
|||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
historyBatchDrainScheduled = false;
|
historyBatchDrainScheduled = false;
|
||||||
@@ -7603,17 +7616,25 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
if (historyRetried) {
|
||||||
flushLiveBuffer();
|
showHint("Decode history unavailable", 3e3);
|
||||||
|
finishHistoryReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyRetried = true;
|
||||||
|
historyFallbackStarted = false;
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||||
|
setTimeout(() => {
|
||||||
|
startDecodeHistoryFallback();
|
||||||
|
}, 2e3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function startDecodeHistoryWorkerReplay() {
|
function startDecodeHistoryWorkerReplay() {
|
||||||
@@ -7674,10 +7695,9 @@ function connectDecode() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const historyTimeout = setTimeout(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 2e4);
|
}, 2e4);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
decodeSource = new EventSource("/decode");
|
decodeSource = new EventSource("/decode");
|
||||||
@@ -7699,7 +7719,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 1e4);
|
setTimeout(connectDecode, 1e4);
|
||||||
|
|||||||
@@ -1660,11 +1660,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<div class="decode-history-overlay-card">
|
<div class="history-progress-text">
|
||||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||||
<script defer src="/vendor/leaflet.js"></script>
|
<script defer src="/vendor/leaflet.js"></script>
|
||||||
|
|||||||
@@ -2209,6 +2209,74 @@ small { color: var(--text-muted); }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Decode history loads in the corner, not over the page. It was a full-screen
|
||||||
|
scrim: the operator could not read the waterfall, the decode panels or
|
||||||
|
anything else while a large history replayed, and the replay is exactly when
|
||||||
|
there is something to watch. */
|
||||||
|
.history-progress {
|
||||||
|
position: fixed;
|
||||||
|
left: var(--space-4);
|
||||||
|
bottom: var(--space-4);
|
||||||
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: min(20rem, calc(100vw - 2rem));
|
||||||
|
padding: 0.6rem 0.75rem 0.7rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-light) 70%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
|
||||||
|
box-shadow: 0 10px 24px color-mix(in srgb, var(--bg) 35%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--dur-base) var(--ease-standard),
|
||||||
|
visibility var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.history-progress.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.history-progress-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.history-progress-title {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.history-progress-sub {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.history-progress-track {
|
||||||
|
height: 0.28rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: color-mix(in srgb, var(--border-light) 45%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-progress-bar {
|
||||||
|
display: block;
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--accent-green);
|
||||||
|
transition: width var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
/* Indeterminate while the payload is still on the wire. */
|
||||||
|
.history-progress[data-phase="fetching"] .history-progress-bar {
|
||||||
|
width: 35%;
|
||||||
|
animation: trx-history-sweep 1.1s var(--ease-standard) infinite;
|
||||||
|
}
|
||||||
|
@keyframes trx-history-sweep {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(285%); }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.history-progress { bottom: calc(5.9rem + env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
.decode-history-overlay {
|
.decode-history-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ const loadingSub = requiredElement("loading-sub");
|
|||||||
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -880,10 +881,29 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let overviewDrawPending = false;
|
let overviewDrawPending = false;
|
||||||
function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
|
// Progress, in the corner. This used to dim the whole page behind a scrim,
|
||||||
|
// which hid the waterfall and the decode panels for as long as the replay ran
|
||||||
|
// — and a replay only happens when there is a backlog worth watching arrive.
|
||||||
|
// `fraction` null leaves the bar indeterminate, for the part where the payload
|
||||||
|
// is still on the wire and there is nothing to count yet.
|
||||||
|
function setDecodeHistoryOverlayVisible(
|
||||||
|
visible: boolean,
|
||||||
|
title = "",
|
||||||
|
sub = "",
|
||||||
|
fraction: number | null = null,
|
||||||
|
) {
|
||||||
if (!decodeHistoryOverlayEl) return;
|
if (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||||
|
if (decodeHistoryProgressBarEl) {
|
||||||
|
if (fraction == null) {
|
||||||
|
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||||
|
decodeHistoryProgressBarEl.style.width = "";
|
||||||
|
} else {
|
||||||
|
delete decodeHistoryOverlayEl.dataset.phase;
|
||||||
|
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6494,36 +6514,44 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
||||||
const liveBuffer: DecodeMessage[] = [];
|
const liveBuffer: DecodeMessage[] = [];
|
||||||
function flushLiveBuffer() {
|
// Live decodes wait behind the history so the panels stay in order. Letting
|
||||||
|
// them through is not the same as being finished, and conflating the two is
|
||||||
|
// what made a slow history disappear: the safety valve released the buffer
|
||||||
|
// and tore the worker down with it, so whatever had not arrived never did.
|
||||||
|
function releaseLiveBuffer() {
|
||||||
|
if (historySettled) return;
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try { dispatchDecodeMessage(msg); } catch (_) {}
|
try { dispatchDecodeMessage(msg); } catch (_) {}
|
||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
@@ -6585,17 +6613,26 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err: unknown) => {
|
}, (err: unknown) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
// One retry, then say so. Failing silently here is why the history
|
||||||
flushLiveBuffer();
|
// sometimes only turned up on a second reload: nothing asked again and
|
||||||
|
// nothing said anything was missing.
|
||||||
|
if (historyRetried) {
|
||||||
|
showHint("Decode history unavailable", 3000);
|
||||||
|
finishHistoryReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyRetried = true;
|
||||||
|
historyFallbackStarted = false;
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||||
|
setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6664,12 +6701,13 @@ function connectDecode() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety valve: if the history fetch hangs, unblock after 20 s.
|
// Safety valve: after 20 s, stop holding live decodes back — but keep
|
||||||
|
// loading. The history is what the operator is waiting for, and dropping it
|
||||||
|
// on the floor at the timeout is not something they can even see happen.
|
||||||
const historyTimeout = setTimeout(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 20000);
|
}, 20000);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
|
|
||||||
@@ -6692,7 +6730,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 10000);
|
setTimeout(connectDecode, 10000);
|
||||||
|
|||||||
@@ -97,3 +97,70 @@ try {
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored history, which is what is on screen a second after a page load. The
|
||||||
|
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
|
||||||
|
// left the client on its retry path and the history path untested.
|
||||||
|
const HISTORY_AIS = 900;
|
||||||
|
const HISTORY_APRS = 300;
|
||||||
|
const historyFixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
mode: "AIS",
|
||||||
|
history: {
|
||||||
|
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
|
||||||
|
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
|
||||||
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
|
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
|
lat: 54.3, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const replay = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replay.page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
// While it loads, the operator can still see the radio. This used to be a
|
||||||
|
// full-screen scrim over everything for as long as the replay ran.
|
||||||
|
const samples = [];
|
||||||
|
for (let attempt = 0; attempt < 30; attempt++) {
|
||||||
|
samples.push(await replay.page.evaluate(() => {
|
||||||
|
const element = document.getElementById("decode-history-overlay");
|
||||||
|
if (!element || element.classList.contains("is-hidden")) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
await replay.page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
const shown = samples.filter(Boolean);
|
||||||
|
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
|
||||||
|
for (const sample of shown) {
|
||||||
|
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
|
||||||
|
assert.equal(sample.coversCentre, false, "the progress sits over the page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// And all of it arrives, on the first load.
|
||||||
|
await replay.page.waitForTimeout(2000);
|
||||||
|
const restored = await replay.page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
|
||||||
|
}));
|
||||||
|
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
|
||||||
|
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
|
||||||
|
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
|
||||||
|
|
||||||
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await replay.browser.close();
|
||||||
|
await historyFixture.close();
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,68 @@ const CONTENT_TYPES = new Map([
|
|||||||
[".woff2", "font/woff2"],
|
[".woff2", "font/woff2"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
|
||||||
|
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
|
||||||
|
// every run exercising the client's retry path instead of its history path.
|
||||||
|
function encodeCbor(value) {
|
||||||
|
const chunks = [];
|
||||||
|
const head = (major, length) => {
|
||||||
|
if (length < 24) return Buffer.from([(major << 5) | length]);
|
||||||
|
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
|
||||||
|
if (length < 0x10000) {
|
||||||
|
const buffer = Buffer.alloc(3);
|
||||||
|
buffer[0] = (major << 5) | 25;
|
||||||
|
buffer.writeUInt16BE(length, 1);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (length < 0x1_0000_0000) {
|
||||||
|
const buffer = Buffer.alloc(5);
|
||||||
|
buffer[0] = (major << 5) | 26;
|
||||||
|
buffer.writeUInt32BE(length, 1);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
|
||||||
|
const buffer = Buffer.alloc(9);
|
||||||
|
buffer[0] = (major << 5) | 27;
|
||||||
|
buffer.writeBigUInt64BE(BigInt(length), 1);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
const write = (item) => {
|
||||||
|
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
|
||||||
|
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
|
||||||
|
if (typeof item === "number") {
|
||||||
|
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
|
||||||
|
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
|
||||||
|
const buffer = Buffer.alloc(9);
|
||||||
|
buffer[0] = 0xfb;
|
||||||
|
buffer.writeDoubleBE(item, 1);
|
||||||
|
chunks.push(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const bytes = Buffer.from(item, "utf8");
|
||||||
|
chunks.push(head(3, bytes.length), bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
chunks.push(head(4, item.length));
|
||||||
|
item.forEach(write);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(item);
|
||||||
|
chunks.push(head(5, entries.length));
|
||||||
|
for (const [key, entryValue] of entries) {
|
||||||
|
const keyBytes = Buffer.from(key, "utf8");
|
||||||
|
chunks.push(head(3, keyBytes.length), keyBytes);
|
||||||
|
write(entryValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write(value);
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||||
|
|
||||||
function assetPath(urlPath) {
|
function assetPath(urlPath) {
|
||||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||||
// so a deep link or a refresh serves the SPA shell, not a 404.
|
// so a deep link or a refresh serves the SPA shell, not a 404.
|
||||||
@@ -67,6 +129,7 @@ export async function startWebFixture({
|
|||||||
meterDb = -70,
|
meterDb = -70,
|
||||||
decodes = [],
|
decodes = [],
|
||||||
mode = "FM",
|
mode = "FM",
|
||||||
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
@@ -163,7 +226,6 @@ export async function startWebFixture({
|
|||||||
["/status", status],
|
["/status", status],
|
||||||
["/bookmarks", bookmarks],
|
["/bookmarks", bookmarks],
|
||||||
["/bandplan.json", bandplan],
|
["/bandplan.json", bandplan],
|
||||||
["/decode/history", {}],
|
|
||||||
["/api/recorder/status", []],
|
["/api/recorder/status", []],
|
||||||
["/api/recorder/files", []],
|
["/api/recorder/files", []],
|
||||||
]);
|
]);
|
||||||
@@ -235,6 +297,12 @@ export async function startWebFixture({
|
|||||||
// "type" naming the decoder, snake_case fields inside. The mini views, the
|
// "type" naming the decoder, snake_case fields inside. The mini views, the
|
||||||
// map markers and the history all hang off it, and serving nothing left
|
// map markers and the history all hang off it, and serving nothing left
|
||||||
// every one of them untested.
|
// every one of them untested.
|
||||||
|
if (url.pathname === "/decode/history") {
|
||||||
|
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
|
||||||
|
response.writeHead(200, { "content-type": "application/cbor" });
|
||||||
|
response.end(encodeCbor(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.pathname === "/decode") {
|
if (url.pathname === "/decode") {
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
|
|||||||
Reference in New Issue
Block a user