diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml
index 7d047ab6f83..f253595f375 100644
--- a/.github/workflows/universe-ci.yml
+++ b/.github/workflows/universe-ci.yml
@@ -165,21 +165,44 @@ jobs:
kill -0 "$GATEWAY_PID"
curl -fsS "http://127.0.0.1:$GATEWAY_PORT/__gateway/health"
- # The harness now fails the run when a page never finishes loading, when a
+ # The harness fails the run when a page never finishes loading, when a
# chart panel draws nothing, or when a failure state says nothing about
# why. Those three states passed every other gate here and shipped.
- - name: Measured contrast, accessibility, overflow, unfinished pages, and screenshots
+ #
+ # Split into two passes that finish, rather than one sweep that does not.
+ # Every combination of thirteen routes, seven data states, three themes
+ # and seven widths is nineteen hundred page loads, which does not fit the
+ # job timeout, and a gate that cannot finish protects nothing.
+ #
+ # The broad pass walks every route at a phone and a desktop width with
+ # real data, which is where overflow, broken images and a page that never
+ # finishes show up. The deep pass takes the routes this suite holds to a
+ # terminal state through every failure state, every theme and the widths
+ # that change the layout, which is where a page that lies about why it is
+ # empty shows up. Measured at about two and a half minutes and sixteen
+ # minutes respectively.
+ - name: Every route renders and finishes, at a phone and a desktop width
working-directory: scripts/universe/visual-qa
- run: node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts
+ run: >-
+ node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-broad
+ --states=populated --themes=default --viewports=375,1440
+
+ - name: The gated routes hold up across themes, widths and every failure state
+ working-directory: scripts/universe/visual-qa
+ run: >-
+ node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts
+ --routes=graphs,mining,protocols,home,blocks,tx,address --viewports=320,768,1440
- name: Forced colours and 200 percent zoom
working-directory: scripts/universe/visual-qa
- run: node modes-check.mjs --base=http://127.0.0.1:8123
+ run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT
- name: Keep the contrast report and the route screenshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: universe-visual-qa
- path: scripts/universe/visual-qa/artifacts
+ path: |
+ scripts/universe/visual-qa/artifacts
+ scripts/universe/visual-qa/artifacts-broad
retention-days: 30
diff --git a/backend/src/index.ts b/backend/src/index.ts
index c3101f0b3d3..7f8ad59cc4c 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -390,7 +390,10 @@ class Server {
if (config.WALLETS.ENABLED) {
servicesRoutes.initRoutes(this.app);
}
- if (!config.MEMPOOL.OFFICIAL) {
+ // These routes only proxy a hosted mempool API. With no such API
+ // configured they could answer nothing but 500; leaving them unmounted
+ // makes them 404 like any other route this deployment does not serve.
+ if (!config.MEMPOOL.OFFICIAL && config.EXTERNAL_DATA_SERVER.MEMPOOL_API) {
aboutRoutes.initRoutes(this.app);
}
}
diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts
index 3ea44443d7c..faac92c98d7 100644
--- a/frontend/src/app/components/address-graph/address-graph.component.ts
+++ b/frontend/src/app/components/address-graph/address-graph.component.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { echarts, EChartsOption } from '@app/graphs/echarts';
import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs';
-import { catchError, map, switchMap, tap } from 'rxjs/operators';
+import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators';
import { AddressTxSummary, ChainStats } from '@interfaces/electrs.interface';
import { ElectrsApiService } from '@app/services/electrs-api.service';
import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
@@ -109,7 +109,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
return of(null);
}),
)),
- this.stateService.conversions$
+ this.stateService.conversions$.pipe(startWith(null))
]).pipe(
switchMap(([redraw, addressSummary, conversions]) => {
this.conversions = conversions;
diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html
index e1ea8a3bb59..ee685552c36 100644
--- a/frontend/src/app/components/address/address.component.html
+++ b/frontend/src/app/components/address/address.component.html
@@ -150,11 +150,18 @@
+ Loading transactions
-
+
diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.ts b/frontend/src/app/components/blocks-list/blocks-list.component.ts
index 9414fc1aaf6..95136cacf69 100644
--- a/frontend/src/app/components/blocks-list/blocks-list.component.ts
+++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts
@@ -1,7 +1,7 @@
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, Inject, LOCALE_ID } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, combineLatest, EMPTY, Observable, throwError, timer, of, Subscription } from 'rxjs';
-import { catchError, debounceTime, filter, map, retry, scan, skip, switchMap, tap, throttleTime } from 'rxjs/operators';
+import { catchError, debounceTime, filter, map, retry, scan, skip, startWith, switchMap, tap, throttleTime, timeout } from 'rxjs/operators';
import { BlockExtended } from '@interfaces/node-api.interface';
import { ApiService } from '@app/services/api.service';
import { StateService } from '@app/services/state.service';
@@ -12,6 +12,13 @@ import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { LoadState, classifyLoadFailure, isRetryableFailure } from '@app/shared/load-state';
+/**
+ * Overall budget for one page of blocks, retries included. The same figure
+ * toLoadState uses, so a request that hangs here is bounded the way every
+ * other remote read on the product is.
+ */
+const BLOCKS_REQUEST_DEADLINE_MS = 20_000;
+
@Component({
selector: 'app-blocks-list',
templateUrl: './blocks-list.component.html',
@@ -151,9 +158,14 @@ export class BlocksList implements OnInit {
if (!isRetryableFailure(classifyLoadFailure(error))) {
return throwError(() => error);
}
- return timer(2000 * attempt);
+ return timer(1000 * attempt);
},
}),
+ // Retrying bounds a request that fails. It does nothing for one
+ // that never answers at all, which is the shape a hung backend
+ // has: no error to catch, so the skeletons stayed forever. The
+ // budget covers the retries above, matching toLoadState.
+ timeout({ first: BLOCKS_REQUEST_DEADLINE_MS }),
catchError((error) => {
this.failureState = { status: 'error', reason: classifyLoadFailure(error), at: Date.now() };
this.isLoading = false;
@@ -171,7 +183,12 @@ export class BlocksList implements OnInit {
}
this.lastBlockHeight = blocks[0].height;
return of(blocks);
- })
+ }),
+ // The socket carries live tip updates, not the page itself. Without a
+ // seed, combineLatest holds the whole table until the socket speaks,
+ // so a deployment whose REST answers but whose socket is quiet showed
+ // skeletons indefinitely with no failure to report.
+ startWith([] as BlockExtended[]),
)
])
.pipe(
diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html
index 65dbd7545aa..3f997eff650 100644
--- a/frontend/src/app/components/difficulty/difficulty.component.html
+++ b/frontend/src/app/components/difficulty/difficulty.component.html
@@ -131,29 +131,37 @@
-
-
-
-
+
+ @if ((liveFeed$ | async)?.status === 'error') {
+
No live epoch data: the chain feed is not answering.
+ } @else {
+
-
-
-
-
+
-
+ }
;
isLoadingWebSocket$: Observable;
+ /**
+ * Whether the socket that carries the epoch has given up. Both sources here
+ * are socket fed and neither can error, so without this the placeholders
+ * would go on claiming an answer was coming for as long as the tab was open.
+ */
+ liveFeed$: Observable>;
difficultyEpoch$: Observable;
mode: 'difficulty' | 'halving' = 'difficulty';
@@ -76,6 +83,7 @@ export class DifficultyComponent implements OnInit {
ngOnInit(): void {
this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$;
+ this.liveFeed$ = this.stateService.liveFeed$;
this.difficultyEpoch$ = combineLatest([
this.stateService.blocks$,
this.stateService.difficultyAdjustment$,
diff --git a/frontend/src/app/components/fees-box/fees-box.component.html b/frontend/src/app/components/fees-box/fees-box.component.html
index f969c66449d..3604e6a85a2 100644
--- a/frontend/src/app/components/fees-box/fees-box.component.html
+++ b/frontend/src/app/components/fees-box/fees-box.component.html
@@ -54,31 +54,39 @@
High Priority
-
-
-
-
-
+
+ @if ((liveFeed$ | async)?.status === 'error') {
+
No live fee estimates: the chain feed is not answering.
+ } @else {
+
-
-
-
-
-
-
+ }
diff --git a/frontend/src/app/components/fees-box/fees-box.component.scss b/frontend/src/app/components/fees-box/fees-box.component.scss
index 1c7d31f4464..d02a54a0729 100644
--- a/frontend/src/app/components/fees-box/fees-box.component.scss
+++ b/frontend/src/app/components/fees-box/fees-box.component.scss
@@ -164,4 +164,18 @@
&.prority {
width: 25%;
}
-}
\ No newline at end of file
+}
+
+// What stands in for the numbers when the live feed has given up. It holds the
+// height the estimates occupy, so the panels below it do not jump when the
+// placeholders are replaced by a sentence.
+.feed-unavailable {
+ align-items: center;
+ color: var(--u-text-secondary);
+ display: flex;
+ font-size: var(--u-text-sm);
+ justify-content: center;
+ margin: 0;
+ min-height: 50px;
+ text-align: center;
+}
diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts
index 728b2be9e98..815aa18fa4a 100644
--- a/frontend/src/app/components/fees-box/fees-box.component.ts
+++ b/frontend/src/app/components/fees-box/fees-box.component.ts
@@ -5,6 +5,7 @@ import { Recommendedfees } from '@interfaces/websocket.interface';
import { feeLevels } from '@app/app.constants';
import { map, startWith, tap } from 'rxjs/operators';
import { ThemeService } from '@app/services/theme.service';
+import { LoadState } from '@app/shared/load-state';
@Component({
selector: 'app-fees-box',
@@ -15,6 +16,12 @@ import { ThemeService } from '@app/services/theme.service';
})
export class FeesBoxComponent implements OnInit, OnDestroy {
isLoading$: Observable
;
+ /**
+ * Whether the socket that carries these numbers has given up. It is the only
+ * source for them and it never errors, so without this the placeholders here
+ * would go on claiming an answer was coming for as long as the tab was open.
+ */
+ liveFeed$: Observable>;
recommendedFees$: Observable;
themeStateSubscription: Subscription;
gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))';
@@ -28,6 +35,7 @@ export class FeesBoxComponent implements OnInit, OnDestroy {
) { }
ngOnInit(): void {
+ this.liveFeed$ = this.stateService.liveFeed$;
this.isLoading$ = combineLatest(
this.stateService.isLoadingWebSocket$.pipe(startWith(false)),
this.stateService.loadingIndicators$.pipe(startWith({ mempool: 0 })),
diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html
index ba64a24430f..3c47d584f9a 100644
--- a/frontend/src/app/dashboard/dashboard.component.html
+++ b/frontend/src/app/dashboard/dashboard.component.html
@@ -2,13 +2,13 @@
- @if (chainStatus$ | async; as chainStatus) {
- @if (chainStatus.status === 'loading') {
+ @if (liveFeed$ | async; as liveFeed) {
+ @if (liveFeed.status === 'loading') {
Loading live chain data
}
- @if (chainStatus.status === 'error') {
-
+ @if (liveFeed.status === 'error') {
+
}
}
diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts
index 347d122fafc..05491b43376 100644
--- a/frontend/src/app/dashboard/dashboard.component.ts
+++ b/frontend/src/app/dashboard/dashboard.component.ts
@@ -1,6 +1,6 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription, timer } from 'rxjs';
-import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, startWith, switchMap, take, takeUntil, tap, throttleTime } from 'rxjs/operators';
+import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { AuditStatus, BlockExtended, CurrentPegs, FederationAddress, FederationUtxo, OptimizedMempoolStats, PegsVolume, RecentPeg, TransactionStripped } from '@interfaces/node-api.interface';
import { MempoolInfo, ReplacementInfo } from '@interfaces/websocket.interface';
import { ApiService } from '@app/services/api.service';
@@ -52,12 +52,10 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
transactionsWeightPerSecondOptions: any;
isLoadingWebSocket$: Observable
;
/**
- * Whether live chain data ever arrived. The dashboard is fed by the socket,
- * so when the chain backend is down every panel would otherwise hold its
- * skeleton forever with nothing said about why.
+ * Whether live chain data ever arrived. Owned by the state service, because
+ * the panels on this page have to reach the same verdict as the banner.
*/
- chainStatus$: Observable>;
- private chainRetry$ = new Subject();
+ liveFeed$: Observable>;
liquidPegsMonth$: Observable;
currentPeg$: Observable;
auditStatus$: Observable;
@@ -112,9 +110,9 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
this.stateService.focusSearchInputDesktop();
}
- retryChain(): void {
+ retryLiveFeed(): void {
this.websocketService.reconnectWebsocket();
- this.chainRetry$.next();
+ this.stateService.retryLiveFeed();
}
ngOnDestroy(): void {
@@ -135,22 +133,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
this.websocketService.startTrackRbfSummary();
this.network$ = merge(of(''), this.stateService.networkChanged$);
- // The first socket payload normally lands within a second or two. When
- // nothing has arrived well past that, the page says so instead of leaving
- // the reader to stare at skeletons. A late arrival still clears the panel.
- this.chainStatus$ = this.chainRetry$.pipe(
- startWith(undefined),
- switchMap(() => {
- const firstChainData$ = merge(this.stateService.blocks$, this.stateService.mempoolInfo$).pipe(take(1));
- return merge(
- firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))),
- timer(10_000).pipe(
- takeUntil(firstChainData$),
- map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })),
- ),
- ).pipe(startWith>({ status: 'loading' }));
- }),
- );
+ this.liveFeed$ = this.stateService.liveFeed$;
this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$
.pipe(
map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100)
diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts
index bb8f97c4dda..88971cc392b 100644
--- a/frontend/src/app/services/state.service.ts
+++ b/frontend/src/app/services/state.service.ts
@@ -1,11 +1,12 @@
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
-import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
+import { ReplaySubject, BehaviorSubject, Subject, fromEvent, merge, Observable, timer } from 'rxjs';
import { Transaction } from '@interfaces/electrs.interface';
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface';
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
-import { filter, map, scan, share, shareReplay } from 'rxjs/operators';
+import { filter, map, scan, share, shareReplay, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
+import { LoadState } from '@app/shared/load-state';
import { StorageService } from '@app/services/storage.service';
import { hasTouchScreen } from '@app/shared/pipes/bytes-pipe/utils';
import { ActiveFilter } from '@app/shared/filters.utils';
@@ -45,6 +46,12 @@ export interface Customization {
export type SignaturesMode = 'all' | 'interesting' | 'none' | null;
+/**
+ * How long the socket has to say anything before the live feed is treated as
+ * having given up. The first payload normally lands within a second or two.
+ */
+const LIVE_FEED_DEADLINE_MS = 10_000;
+
export interface Env {
MAINNET_ENABLED: boolean;
TESTNET_ENABLED: boolean;
@@ -193,6 +200,19 @@ export class StateService {
blockTransactions$ = new Subject();
walletTransactions$ = new Subject();
isLoadingWebSocket$ = new ReplaySubject(1);
+ /**
+ * Whether live chain data ever arrived.
+ *
+ * The panels on the dashboards are fed by the socket and none of them can
+ * fail: when the chain backend is down the socket connects and then says
+ * nothing at all, so a component waiting on `blocks$` waits forever and holds
+ * its placeholder forever with it. This is the one place that decides the
+ * feed has given up, so the banner that says so and the panels that stop
+ * pulsing are reading the same answer instead of racing a copy of the same
+ * timer each.
+ */
+ liveFeed$: Observable>;
+ private liveFeedRetry$ = new Subject();
isLoadingMempool$ = new BehaviorSubject(true);
vbytesPerSecond$ = new ReplaySubject(1);
previousRetarget$ = new ReplaySubject(1);
@@ -356,6 +376,25 @@ export class StateService {
this.blocks$ = this.blocksSubject$.pipe(filter(blocks => blocks != null && blocks.length > 0));
+ // A late arrival still clears this, so the deadline costs nothing when the
+ // feed is merely slow. Shared and reference counted: one timer runs however
+ // many panels are watching, and it starts again when a dashboard is next
+ // opened rather than holding a verdict from an earlier visit.
+ this.liveFeed$ = this.liveFeedRetry$.pipe(
+ startWith(undefined),
+ switchMap(() => {
+ const firstChainData$ = merge(this.blocks$, this.mempoolInfo$).pipe(take(1));
+ return merge(
+ firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))),
+ timer(LIVE_FEED_DEADLINE_MS).pipe(
+ takeUntil(firstChainData$),
+ map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })),
+ ),
+ ).pipe(startWith>({ status: 'loading' }));
+ }),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+
const savedTimePreference = this.storageService.getValue('time-preference-ltr');
const rtlLanguage = (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he'));
// default time direction is right-to-left, unless locale is a RTL language
@@ -493,6 +532,14 @@ export class StateService {
this.blockScrolling$.next(value);
}
+ /**
+ * Gives the live feed a fresh deadline. Reconnecting the socket is the
+ * caller's job: this service does not own the connection.
+ */
+ retryLiveFeed(): void {
+ this.liveFeedRetry$.next();
+ }
+
isLiquid() {
return this.network === 'liquid' || this.network === 'liquidtestnet';
}
diff --git a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss
index 05bc701f749..92413ae5651 100644
--- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss
+++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss
@@ -8,12 +8,12 @@
}
.chain-note {
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
font-size: 0.9rem;
}
.registry-version {
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
font-size: 0.85rem;
}
@@ -31,7 +31,7 @@
p {
margin: 0;
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
}
}
@@ -54,10 +54,9 @@
}
.protocol-row {
- display: flex;
- flex-wrap: wrap;
+ display: grid;
+ grid-template-columns: 23rem minmax(0, 1fr) max-content;
align-items: center;
- justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid var(--box-bg);
@@ -81,16 +80,37 @@
align-items: center;
gap: 0.35rem 0.5rem;
min-width: 0;
+ justify-self: start;
}
}
- @media (max-width: 575px) {
+ @media (max-width: 899px) {
.protocol-row {
- align-items: flex-start;
- flex-direction: column;
+ grid-template-columns: minmax(0, 1fr) max-content;
.protocol-state {
- width: 100%;
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+
+ .protocol-evidence {
+ grid-column: 2;
+ grid-row: 1;
+ margin: 0;
+ justify-self: end;
+ }
+ }
+ }
+
+ @media (max-width: 575px) {
+ .protocol-row {
+ grid-template-columns: minmax(0, 1fr);
+
+ .protocol-evidence {
+ grid-column: 1;
+ grid-row: 3;
+ margin-top: 0.25rem;
+ justify-self: start;
}
}
}
@@ -125,12 +145,12 @@
&.chip-disabled {
border-style: dashed;
border-color: var(--transparent-fg);
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
}
&.chip-unknown {
border-style: dashed;
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
}
// Reachable and answering, but too far back to describe the present. It
@@ -158,6 +178,7 @@
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
+ justify-self: end;
}
.registry-summary {
@@ -168,7 +189,7 @@
.no-protocols {
margin: 2rem 0;
text-align: center;
- color: var(--transparent-fg);
+ color: var(--u-text-muted);
}
.loading-skeleton {
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 560803913dc..15f64e2d46f 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -803,7 +803,7 @@ h1, h2, h3 {
// follows: in the blocks table that is the next row, and the figure was cut
// in half by it. The reserve is only made where a label actually exists.
&:has(.progress-text) {
- margin-bottom: 1.1em;
+ margin-bottom: calc(1.1em + 4px);
}
.progress-bar {
@@ -818,7 +818,7 @@ h1, h2, h3 {
left: 0;
position: absolute;
text-align: center;
- top: calc(100% + 3px);
+ top: calc(100% + 7px);
white-space: nowrap;
width: 100%;
}
diff --git a/scripts/universe/gateway-restart.test.mjs b/scripts/universe/gateway-restart.test.mjs
index 5b59cdd9df2..e7eb8e9b7fa 100644
--- a/scripts/universe/gateway-restart.test.mjs
+++ b/scripts/universe/gateway-restart.test.mjs
@@ -21,28 +21,36 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const GATEWAY = join(HERE, 'gateway.mjs');
const ROOT = join(HERE, '..', '..', 'frontend', 'dist', 'mempool', 'browser');
-const GATEWAY_PORT = 8794;
-const UPSTREAM_PORT = 8795;
+// Each test takes its own pair of ports. Sharing them let a gateway from a
+// finished test still be listening when the next one started, and the next
+// test then measured the wrong process.
+let nextPort = 8794;
+function reservePorts() {
+ const gatewayPort = nextPort;
+ const upstreamPort = nextPort + 1;
+ nextPort += 2;
+ return { gatewayPort, upstreamPort };
+}
-function startGateway() {
+function startGateway({ gatewayPort, upstreamPort }) {
return spawn(process.execPath, [GATEWAY], {
env: {
...process.env,
UNIVERSE_GATEWAY_HOST: '127.0.0.1',
- UNIVERSE_GATEWAY_PORT: String(GATEWAY_PORT),
- UNIVERSE_GATEWAY_BACKEND: `http://127.0.0.1:${UPSTREAM_PORT}`,
- UNIVERSE_GATEWAY_OVERLAY: `http://127.0.0.1:${UPSTREAM_PORT}`,
+ UNIVERSE_GATEWAY_PORT: String(gatewayPort),
+ UNIVERSE_GATEWAY_BACKEND: `http://127.0.0.1:${upstreamPort}`,
+ UNIVERSE_GATEWAY_OVERLAY: `http://127.0.0.1:${upstreamPort}`,
UNIVERSE_GATEWAY_ROOT: ROOT,
},
stdio: 'ignore',
});
}
-function ask(path = '/api/v1/backend-info') {
+function ask(gatewayPort, path = '/api/v1/backend-info') {
return new Promise((resolve) => {
const started = Date.now();
http
- .get({ host: '127.0.0.1', port: GATEWAY_PORT, path }, (response) => {
+ .get({ host: '127.0.0.1', port: gatewayPort, path }, (response) => {
response.resume();
response.on('end', () => resolve({ status: response.statusCode, ms: Date.now() - started }));
})
@@ -51,19 +59,20 @@ function ask(path = '/api/v1/backend-info') {
}
test('a request waits for an upstream that is restarting, rather than failing', async (t) => {
- const gateway = startGateway();
+ const ports = reservePorts();
+ const gateway = startGateway(ports);
t.after(() => gateway.kill());
await sleep(1200);
// Nothing is listening yet, exactly as during a restart.
- const inFlight = ask();
+ const inFlight = ask(ports.gatewayPort);
await sleep(1000);
const upstream = http.createServer((_, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"ok":true}');
});
- await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, '127.0.0.1', resolve));
+ await new Promise((resolve) => upstream.listen(ports.upstreamPort, '127.0.0.1', resolve));
t.after(() => upstream.close());
const bridged = await inFlight;
@@ -71,13 +80,38 @@ test('a request waits for an upstream that is restarting, rather than failing',
});
test('an upstream that is genuinely gone is still reported, and promptly', async (t) => {
- const gateway = startGateway();
+ const ports = reservePorts();
+ const gateway = startGateway(ports);
t.after(() => gateway.kill());
await sleep(1200);
- const dead = await ask();
+ const dead = await ask(ports.gatewayPort);
assert.equal(dead.status, 502, 'a dead upstream must be a gateway failure, never an empty success');
// Bounded well inside the page's own request budget, so the interface still
// reaches a terminal state quickly.
assert.ok(dead.ms < 12_000, `gave up after ${dead.ms}ms, which is too long to hold a reader`);
});
+
+test('a reader who leaves mid-retry does not take the gateway down', async (t) => {
+ // This crashed the gateway in CI. With no upstream listening, a request
+ // enters the retry loop; the client then goes away, and the write that
+ // follows throws from a timer callback where there is no request to fail.
+ const ports = reservePorts();
+ const gateway = startGateway(ports);
+ t.after(() => gateway.kill());
+ await sleep(1200);
+
+ for (let i = 0; i < 6; i++) {
+ const request = http.get({ host: '127.0.0.1', port: ports.gatewayPort, path: '/api/v1/backend-info' });
+ request.on('error', () => undefined);
+ // Abandon the request while it is still waiting on a refused upstream.
+ await sleep(300);
+ request.destroy();
+ }
+
+ // Long enough for every abandoned retry to have fired.
+ await sleep(6000);
+
+ const stillServing = await ask(ports.gatewayPort, '/');
+ assert.notEqual(stillServing.status, 0, 'the gateway died while serving abandoned requests');
+});
diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs
index dc4180ada4f..54fc825c248 100644
--- a/scripts/universe/gateway.mjs
+++ b/scripts/universe/gateway.mjs
@@ -197,8 +197,38 @@ function proxy(request, response, route) {
// proxies that changes state carries one, so this never retries a write.
const replayable = request.method === 'GET' || request.method === 'HEAD';
let attempt = 0;
+ let pendingRetry = null;
+ let clientGone = false;
+
+ // A reader who navigates away mid-retry leaves a response nothing can be
+ // written to. Writing to it anyway throws from a timer callback, where there
+ // is no request to fail: it takes the whole gateway down. Track the client
+ // and stop the moment it leaves.
+ const abandon = () => {
+ clientGone = true;
+ if (pendingRetry) {
+ clearTimeout(pendingRetry);
+ pendingRetry = null;
+ }
+ };
+ response.on('close', abandon);
+ request.on('aborted', abandon);
+
+ const failClosed = () => {
+ if (clientGone || response.headersSent || response.writableEnded) return;
+ try {
+ // A dead upstream is reported as a gateway failure, never as an empty
+ // success: a caller must be able to tell the two apart.
+ response.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
+ response.end(JSON.stringify({ error: 'upstream-unavailable' }));
+ } catch {
+ // The client went away between the check and the write.
+ response.destroy();
+ }
+ };
const send = () => {
+ if (clientGone) return;
const options = {
protocol: upstream.protocol,
hostname: upstream.hostname,
@@ -209,16 +239,27 @@ function proxy(request, response, route) {
timeout: UPSTREAM_TIMEOUT_MS,
};
const proxied = http.request(options, (upstreamResponse) => {
- // API responses are data, never documents, so they get the headers
- // without a content policy.
- response.writeHead(
- upstreamResponse.statusCode || 502,
- withSecurityHeaders(upstreamResponse.headers, false),
- );
+ if (clientGone) {
+ upstreamResponse.destroy();
+ return;
+ }
+ try {
+ // API responses are data, never documents, so they get the headers
+ // without a content policy.
+ response.writeHead(
+ upstreamResponse.statusCode || 502,
+ withSecurityHeaders(upstreamResponse.headers, false),
+ );
+ } catch {
+ upstreamResponse.destroy();
+ response.destroy();
+ return;
+ }
upstreamResponse.pipe(response);
});
proxied.on('timeout', () => proxied.destroy(new Error('upstream timeout')));
proxied.on('error', (error) => {
+ if (clientGone) return;
if (response.headersSent) {
response.destroy();
return;
@@ -226,18 +267,21 @@ function proxy(request, response, route) {
if (replayable && upstreamIsRestarting(error) && attempt < RESTART_RETRY_DELAYS_MS.length) {
const delay = RESTART_RETRY_DELAYS_MS[attempt];
attempt += 1;
- setTimeout(send, delay);
+ pendingRetry = setTimeout(() => {
+ pendingRetry = null;
+ send();
+ }, delay);
return;
}
- // A dead upstream is reported as a gateway failure, never as an empty
- // success: a caller must be able to tell the two apart.
- response.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
- response.end(JSON.stringify({ error: 'upstream-unavailable' }));
+ failClosed();
});
if (replayable) {
proxied.end();
} else {
request.pipe(proxied);
+ // A request body that stops arriving must not leave the upstream socket
+ // open forever.
+ request.on('error', () => proxied.destroy());
}
};
diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs
index f72bb472728..fe3598bb895 100644
--- a/scripts/universe/visual-qa/capture.mjs
+++ b/scripts/universe/visual-qa/capture.mjs
@@ -88,6 +88,13 @@ const THEMES = ['default', 'dark', 'contrast'];
*/
const SETTLE_DEADLINE_MS = 15_000;
+/**
+ * A failure fixture has nothing to fetch, so it should reach its terminal
+ * state almost at once. Waiting the full budget on every one of those turned
+ * a matrix that used to take minutes into one that could not finish.
+ */
+const FAILURE_SETTLE_DEADLINE_MS = 4_000;
+
const STATES = ['populated', ...Object.keys(stateOverrides)];
function pick(list, key, idKey = 'id') {
@@ -211,7 +218,17 @@ function socketState(state) {
transactions: fixtures['/api/mempool/recent'],
rbfLatestSummary: fixtures['rbf-latest-summary'],
conversions: { USD: 96_400, EUR: 89_100, time: 1_772_100_000 },
- loadingIndicators: { mempool: 100, blocks: 100 },
+ loadingIndicators: {
+ mempool: 100,
+ blocks: 100,
+ // How far along the address's transaction history is. The address page
+ // only draws its progress bar when the socket reports progress for that
+ // address, so without this the one state that reaches the
+ // transaction-list wait would render its skeletons and no bar at all,
+ // and the bar would go on being unreviewed for the same reason the
+ // branch itself was.
+ ...(state === 'address-txs-loading' ? { [`address-${sampleIds.ADDRESS}`]: 62 } : {}),
+ },
backendInfo: { hostname: 'universe-explorer', version: '3.3.1', gitCommit: 'fixture0', lightning: false, chainSync },
};
}
@@ -377,12 +394,13 @@ async function run() {
let progress;
let settledAfterMs = null;
try {
- const deadline = Date.now() + SETTLE_DEADLINE_MS;
+ const budget = state === 'populated' ? SETTLE_DEADLINE_MS : FAILURE_SETTLE_DEADLINE_MS;
+ const deadline = Date.now() + budget;
for (;;) {
progress = await page.evaluate(progressProbe);
const busy = (progress.spinners?.length ?? 0) > 0 || (progress.skeletons ?? 0) > 0;
if (!busy || Date.now() >= deadline) {
- settledAfterMs = busy ? null : Date.now() - (deadline - SETTLE_DEADLINE_MS);
+ settledAfterMs = busy ? null : Date.now() - (deadline - budget);
break;
}
await page.waitForTimeout(250);
@@ -399,6 +417,20 @@ async function run() {
overflowBy, consoleErrors, brokenImages, violations, contrast, progress, settledAfterMs,
});
} catch (error) {
+ // A server that has gone away is not a page that failed. Reporting
+ // it as one buries the actual event under a hundred identical
+ // lines and sends the reader looking at the application.
+ if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(String(error))) {
+ await page.close().catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ console.error(
+ `\nThe server at ${BASE} stopped answering partway through the run ` +
+ `(at ${route.id}/${state}/${theme}@${viewport.id}). ` +
+ `Nothing was measured after that point, so this run proves nothing.`,
+ );
+ process.exit(2);
+ }
findings.push({
route: route.id, routeName: route.name, state, theme, viewport: viewport.id,
error: String(error).slice(0, 400),
@@ -444,6 +476,18 @@ async function run() {
*/
export const GATED_ROUTES = new Set(['graphs', 'mining', 'protocols', 'home', 'blocks', 'tx', 'address']);
+/**
+ * Fixtures that hold a request open on purpose, to photograph a wait.
+ *
+ * These are judged on whether the wait is announced, not on having finished,
+ * and they are not failure states: nothing has gone wrong, so demanding a
+ * status panel would be demanding the page report a fault it does not have.
+ * `loading` holds every request; `address-txs-loading` holds only the address
+ * transaction list, which is the wait pagination leaves behind and the one the
+ * blanket fixture can never reach.
+ */
+const WAITING_STATES = new Set(['loading', 'address-txs-loading']);
+
export function progressFailures(report) {
const failures = [];
for (const f of report.findings) {
@@ -466,9 +510,9 @@ export function progressFailures(report) {
failures.push(`${where}: chart ${chart.selector} (${chart.width}x${chart.height}) drew nothing`);
}
}
- } else if (f.state === 'loading') {
- // This fixture holds every request open on purpose, to photograph the
- // waiting state. Asking it to have finished would be asking the wrong
+ } else if (WAITING_STATES.has(f.state)) {
+ // These fixtures hold requests open on purpose, to photograph the
+ // waiting state. Asking them to have finished would be asking the wrong
// question; what matters is that the wait is announced rather than being
// a blank rectangle. The deadline itself is covered by the unit tests
// around the request lifecycle, which run far longer than this harness
diff --git a/scripts/universe/visual-qa/fixtures.mjs b/scripts/universe/visual-qa/fixtures.mjs
index 64b441bdab0..4b38b9b1e56 100644
--- a/scripts/universe/visual-qa/fixtures.mjs
+++ b/scripts/universe/visual-qa/fixtures.mjs
@@ -301,6 +301,23 @@ export const stateOverrides = {
// Requests never resolve, so every surface stays in its loading state.
loading: { '**': { hang: true } },
+ // The address resolves, its transactions do not.
+ //
+ // The address page has two independent waits, and the blanket `loading`
+ // fixture above can only ever photograph the first: with every request held
+ // open the page never gets past `isLoadingAddress`, so the branch that waits
+ // for the transaction list, the one a reader actually meets on a slow page
+ // or when they ask for more, was never on screen for any check to see. This
+ // fixture answers the summary and holds only the transaction request, which
+ // is the state pagination and "load more" leave behind.
+ //
+ // The harness falls back to a prefix match, so this reaches the whole
+ // `/txs` subtree. That is the intent: every route that feeds the transaction
+ // list waits, and nothing else does.
+ 'address-txs-loading': {
+ [`/api/address/${ADDRESS}/txs`]: { hang: true },
+ },
+
// An address that has never been used.
'address-empty': {
[`/api/address/${ADDRESS}`]: {
diff --git a/scripts/universe/visual-qa/progress-gate.test.mjs b/scripts/universe/visual-qa/progress-gate.test.mjs
index 3e29b9386be..6e9f2281314 100644
--- a/scripts/universe/visual-qa/progress-gate.test.mjs
+++ b/scripts/universe/visual-qa/progress-gate.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { progressFailures } from './capture.mjs';
+import { GATED_ROUTES, progressFailures } from './capture.mjs';
/**
* The gate that would have stopped the release.
@@ -96,6 +96,63 @@ test('a page that waits with nothing on screen saying so fails the run', () => {
assert.match(failures[0], /nothing on screen that says so/);
});
+/**
+ * Being reported is not the same as being blocked on. A finding whose route is
+ * outside GATED_ROUTES is printed as known work and the run still passes, so a
+ * route counts as finished only once the gate actually holds it. Nothing
+ * asserted which of the two a route got, which is the one thing the gated set
+ * exists to decide. These use the same route-from-line rule the run applies.
+ */
+const blocks = (line) => GATED_ROUTES.has(line.split('/')[0]);
+
+const on = (route, state, progress) => progressFailures({
+ findings: [{
+ route, routeName: route, state, theme: 'default', viewport: '1280',
+ progress: { spinners: [], skeletons: 0, charts: [], statusPanels: [], loadingAnnouncements: [], textLength: 900, skeletonOnly: false, ...progress },
+ }],
+});
+
+test('the chain pages hold a backend outage rather than only reporting it', () => {
+ for (const route of ['home', 'blocks']) {
+ const failures = on(route, 'chain-down', { skeletons: 15, statusPanels: [] });
+ assert.equal(failures.length, 1, `${route} raised nothing`);
+ assert.match(failures[0], /nothing said about why/);
+ assert.ok(blocks(failures[0]), `${route} is reported but does not block`);
+ }
+});
+
+test('the transaction and address pages hold an unannounced wait', () => {
+ for (const route of ['tx', 'address']) {
+ const failures = on(route, 'loading', { skeletons: 6, loadingAnnouncements: [] });
+ assert.equal(failures.length, 1, `${route} raised nothing`);
+ assert.match(failures[0], /nothing on screen that says so/);
+ assert.ok(blocks(failures[0]), `${route} is reported but does not block`);
+ }
+});
+
+test('the routes already covered stay held', () => {
+ for (const route of ['graphs', 'mining', 'protocols']) {
+ assert.ok(GATED_ROUTES.has(route), `${route} silently stopped being gated`);
+ }
+});
+
+test('the address transaction wait is judged as a wait, not as a failure', () => {
+ // Only the transaction list is held open, so the page is not in a failure
+ // state and owes no status panel. What it owes is a word about what it is
+ // waiting for, which is the announcement on the transaction-list branch.
+ assert.deepEqual(
+ progressFailures(withProgress('address-txs-loading', {
+ skeletons: 2, loadingAnnouncements: ['Loading transactions'],
+ })),
+ [],
+ );
+ const failures = progressFailures(withProgress('address-txs-loading', {
+ skeletons: 2,
+ }));
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /nothing on screen that says so/);
+});
+
test('a page with nothing to fetch owes the reader no loader', () => {
// The docs and source pages render from what is already in the bundle. Under
// the loading fixture they simply appear, and demanding a spinner from them