diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/service-status-navigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/service-status-navigation.e2e.ts new file mode 100644 index 0000000000..f312f2c486 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/service-status-navigation.e2e.ts @@ -0,0 +1,71 @@ +import { expect, test } from '../fixtures/e2e-test'; + +test('transient API failures only open Service Status when the health probe fails', async ({ e2eScenario, page }) => { + const webSocketErrors: string[] = []; + const serviceStatusNavigations: string[] = []; + let healthRequests = 0; + + page.on('console', (message) => { + if (message.type() === 'error' && message.text().includes('[WebSocketClient]')) { + webSocketErrors.push(message.text()); + } + }); + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && new URL(frame.url()).pathname === '/next/status') { + serviceStatusNavigations.push(frame.url()); + } + }); + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/health') { + healthRequests += 1; + } + }); + + const targetUrl = '/next/project/list?project=test-project#details'; + let transientFailuresRemaining = 1; + await page.route('**/api/v2/projects**', async (route) => { + if (transientFailuresRemaining > 0) { + transientFailuresRemaining -= 1; + await route.fulfill({ + body: JSON.stringify({ status: 503, title: 'Controlled transient failure' }), + contentType: 'application/problem+json', + status: 503 + }); + return; + } + + await route.continue(); + }); + + await test.step('stay on the current page when the service is healthy', async () => { + await page.goto(targetUrl); + + await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); + await expect(page.getByText(e2eScenario.projectName, { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => healthRequests).toBe(1); + expect(serviceStatusNavigations).toEqual([]); + expect(webSocketErrors).toEqual([]); + }); + + await page.unroute('**/api/v2/projects**'); + await page.route('**/api/v2/projects**', async (route) => { + await route.fulfill({ + body: JSON.stringify({ status: 503, title: 'Controlled service failure' }), + contentType: 'application/problem+json', + status: 503 + }); + }); + await page.route('**/health', async (route) => { + await route.fulfill({ body: 'Unavailable', contentType: 'text/plain', status: 503 }); + }); + + await test.step('coalesce the redirect and preserve the current URL when the service is unavailable', async () => { + await page.reload(); + await expect(page).toHaveURL(/\/next\/status(?:[?#]|$)/); + + const statusUrl = new URL(page.url()); + expect(statusUrl.searchParams.get('redirect')).toBe(targetUrl); + expect(serviceStatusNavigations).toHaveLength(1); + expect(webSocketErrors).toEqual([]); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts new file mode 100644 index 0000000000..1cbadc2ce5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildServiceStatusUrl, createServiceStatusRedirector } from './service-status-redirect'; + +describe('buildServiceStatusUrl', () => { + it('preserves the current path, query, and hash as an encoded redirect', () => { + const url = new URL('https://example.test/next/stack/most-frequent-errors?project=project-1&filter=status%3Aopen#details'); + + const result = buildServiceStatusUrl('/next/status', url); + + expect(new URL(result, url.origin).searchParams.get('redirect')).toBe( + '/next/stack/most-frequent-errors?project=project-1&filter=status%3Aopen#details' + ); + }); +}); + +describe('createServiceStatusRedirector', () => { + it('coalesces concurrent health checks and stays on the current page when the service is healthy', async () => { + let resolveHealth!: (value: boolean) => void; + const healthResult = new Promise((resolve) => { + resolveHealth = resolve; + }); + const checkHealth = vi.fn(() => healthResult); + const navigate = vi.fn(async () => undefined); + const redirect = createServiceStatusRedirector({ checkHealth, navigate }); + + const first = redirect(); + const second = redirect(); + resolveHealth(true); + await Promise.all([first, second]); + + expect(checkHealth).toHaveBeenCalledOnce(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('briefly caches a healthy result to bound repeated probes', async () => { + let currentTime = 1000; + const checkHealth = vi.fn(async () => true); + const redirect = createServiceStatusRedirector({ + checkHealth, + healthyCacheMilliseconds: 5000, + navigate: vi.fn(async () => undefined), + now: () => currentTime + }); + + await redirect(); + currentTime = 5999; + await redirect(); + currentTime = 6000; + await redirect(); + + expect(checkHealth).toHaveBeenCalledTimes(2); + }); + + it('coalesces navigation when the service is unavailable', async () => { + let resolveHealth!: (value: boolean) => void; + let resolveNavigation!: () => void; + const healthResult = new Promise((resolve) => { + resolveHealth = resolve; + }); + const navigationResult = new Promise((resolve) => { + resolveNavigation = resolve; + }); + const checkHealth = vi.fn(() => healthResult); + const navigate = vi.fn(() => navigationResult); + const redirect = createServiceStatusRedirector({ checkHealth, navigate }); + + const first = redirect(); + const second = redirect(); + resolveHealth(false); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce()); + resolveNavigation(); + await Promise.all([first, second]); + + expect(checkHealth).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledOnce(); + }); + + it('treats a failed health probe as unavailable', async () => { + const navigate = vi.fn(async () => undefined); + const redirect = createServiceStatusRedirector({ + checkHealth: vi.fn(async () => { + throw new Error('network unavailable'); + }), + navigate + }); + + await redirect(); + + expect(navigate).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts new file mode 100644 index 0000000000..fdf3b90e60 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts @@ -0,0 +1,44 @@ +const DEFAULT_HEALTHY_CACHE_MILLISECONDS = 5000; + +interface ServiceStatusRedirectorOptions { + checkHealth: () => Promise; + healthyCacheMilliseconds?: number; + navigate: () => Promise; + now?: () => number; +} + +export function buildServiceStatusUrl(statusPath: string, currentUrl: Pick): string { + const redirect = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`; + return `${statusPath}?${new URLSearchParams({ redirect }).toString()}`; +} + +export function createServiceStatusRedirector(options: ServiceStatusRedirectorOptions): () => Promise { + const healthyCacheMilliseconds = options.healthyCacheMilliseconds ?? DEFAULT_HEALTHY_CACHE_MILLISECONDS; + const now = options.now ?? Date.now; + let healthyUntil = 0; + let redirectPromise: null | Promise = null; + + async function redirect(): Promise { + try { + if (await options.checkHealth()) { + healthyUntil = now() + healthyCacheMilliseconds; + return; + } + } catch { + // A failed probe means the service cannot be reached. + } + + await options.navigate(); + } + + return () => { + if (now() < healthyUntil) { + return Promise.resolve(); + } + + redirectPromise ??= redirect().finally(() => { + redirectPromise = null; + }); + return redirectPromise; + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts index be7aa97b10..d18b9df698 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts @@ -47,8 +47,9 @@ export class WebSocketClient { private _url: null | string = null; private accessToken: null | string = null; private connectionTimeoutId: null | ReturnType = null; - private forcedClose: boolean = false; private hasConnectedBefore: boolean = false; + private intentionallyClosedSockets = new WeakSet(); + private reconnectAfterClose: boolean = false; private reconnectAttempts: number = 0; private reconnectTimeoutId: null | ReturnType = null; private terminalAuthFailure: boolean = false; @@ -90,13 +91,14 @@ export class WebSocketClient { } public close(): boolean { + this.reconnectAfterClose = false; clearTimeout(this.reconnectTimeoutId!); this.reconnectTimeoutId = null; clearTimeout(this.connectionTimeoutId!); this.connectionTimeoutId = null; if (this.ws) { - this.forcedClose = true; + this.intentionallyClosedSockets.add(this.ws); this.ws.close(); return true; } @@ -106,17 +108,32 @@ export class WebSocketClient { } public connect() { + if (this.ws) { + if (this.intentionallyClosedSockets.has(this.ws)) { + this.reconnectAfterClose = true; + } + + return; + } + + if (this.readyState === WebSocket.CONNECTING || this.readyState === WebSocket.OPEN) { + return; + } + // isReconnect means: have we successfully connected before? const isReconnect: boolean = this.hasConnectedBefore; // Reset state this.readyState = WebSocket.CONNECTING; - this.forcedClose = false; + + let socket: WebSocket; try { - this.ws = new WebSocket(`${this.url}?access_token=${this.accessToken}`); + socket = new WebSocket(`${this.url}?access_token=${this.accessToken}`); + this.ws = socket; this.onConnecting(isReconnect); } catch (error) { + this.readyState = WebSocket.CLOSED; console.error('[WebSocketClient] Failed to create WebSocket', error); throw error; } @@ -126,13 +143,17 @@ export class WebSocketClient { const timeout = this._options.connectionTimeout ?? 10000; this.connectionTimeoutId = setTimeout(() => { this.connectionTimeoutId = null; - if (this.ws && this.readyState === WebSocket.CONNECTING) { + if (this.ws === socket && this.readyState === WebSocket.CONNECTING) { console.warn(`[WebSocketClient] Connection timeout after ${timeout}ms`); - this.ws.close(); + socket.close(); } }, timeout); - this.ws.onopen = (event: Event) => { + socket.onopen = (event: Event) => { + if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) { + return; + } + clearTimeout(this.connectionTimeoutId!); this.connectionTimeoutId = null; this.readyState = WebSocket.OPEN; @@ -141,14 +162,24 @@ export class WebSocketClient { this.onOpen(event, isReconnect); }; - this.ws.onclose = (event: CloseEvent) => { + socket.onclose = (event: CloseEvent) => { + const wasIntentionallyClosed = this.intentionallyClosedSockets.delete(socket); + if (this.ws !== socket) { + return; + } + clearTimeout(this.connectionTimeoutId!); this.connectionTimeoutId = null; this.ws = null; - if (this.forcedClose) { + if (wasIntentionallyClosed) { this.readyState = WebSocket.CLOSED; this.onClose(event); + if (this.reconnectAfterClose) { + this.reconnectAfterClose = false; + this.connect(); + } + return; } @@ -167,6 +198,7 @@ export class WebSocketClient { } // Calculate reconnection delay with exponential backoff + this.readyState = WebSocket.CLOSED; this.reconnectAttempts++; const delay = this.getReconnectDelay(this.reconnectAttempts); @@ -181,11 +213,19 @@ export class WebSocketClient { }, delay); }; - this.ws.onmessage = (event) => { + socket.onmessage = (event) => { + if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) { + return; + } + this.onMessage(event); }; - this.ws.onerror = (event) => { + socket.onerror = (event) => { + if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) { + return; + } + console.error('[WebSocketClient] onerror triggered', { event, readyState: this.readyState, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts index 0bd8e5e7b4..62248bdc2d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts @@ -167,6 +167,21 @@ describe('WebSocketClient', () => { expect(client.readyState).toBe(WebSocket.CLOSED); }); + + it('should not report an error when a connecting socket is intentionally closed', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onError = vi.fn(); + const client = createClient(); + client.onError = onError; + + client.connect(); + client.close(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(consoleError).not.toHaveBeenCalledWith('[WebSocketClient] onerror triggered', expect.anything()); + expect(onError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); }); describe('Reconnection Logic', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index 91424cede8..ff6c3c52ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { Toaster } from '$comp/ui/sonner'; import { accessToken } from '$features/auth/index.svelte'; import { handleUnexpectedUnauthorized } from '$features/auth/unauthorized'; + import { buildServiceStatusUrl, createServiceStatusRedirector } from '$features/status/service-status-redirect'; import { type FetchClientContext, ProblemDetails, setAccessTokenFunc, setBaseUrl, setRequestOptions, useMiddleware } from '@foundatiofx/fetchclient'; import { error } from '@sveltejs/kit'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; @@ -34,6 +35,24 @@ }); setAccessTokenFunc(() => accessToken.current); + const redirectToServiceStatus = createServiceStatusRedirector({ + checkHealth: async () => { + const response = await fetch('/health', { + cache: 'no-store', + signal: AbortSignal.timeout(5000) + }); + return response.ok; + }, + navigate: async () => { + const url = page.url; + if (url.pathname.startsWith(resolve('/status'))) { + return; + } + + await goto(buildServiceStatusUrl(resolve('/status'), url), { replaceState: true }); + } + }); + useMiddleware(async (ctx: FetchClientContext, next: () => Promise) => { await next(); @@ -47,12 +66,11 @@ } else if (status === 404 && !ctx.options.expectedStatusCodes?.includes(404)) { throw error(404, 'Not found'); } else if ([0, 408, 503].includes(status) && !ctx.options.expectedStatusCodes?.includes(status)) { - const url = page.url; - if (url.pathname.startsWith('/next/status')) { + if (page.url.pathname.startsWith(resolve('/status'))) { return; } - await goto(`${resolve('/status')}?redirect=${url.pathname}`, { replaceState: true }); + await redirectToServiceStatus(); } });