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