diff --git a/web/ui/module/codemirror-promql/src/client/prometheus.test.ts b/web/ui/module/codemirror-promql/src/client/prometheus.test.ts new file mode 100644 index 0000000000..c872edbb69 --- /dev/null +++ b/web/ui/module/codemirror-promql/src/client/prometheus.test.ts @@ -0,0 +1,97 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { HTTPPrometheusClient, CachedPrometheusClient } from './prometheus'; + +describe('HTTPPrometheusClient destroy', () => { + it('should be safe to call destroy multiple times', () => { + const client = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + // First call + client.destroy(); + // Second call should not throw + expect(() => client.destroy()).not.toThrow(); + }); + + it('should abort in-flight requests when destroy is called', async () => { + let abortSignal: AbortSignal | null | undefined; + + const mockFetch = (_url: RequestInfo, init?: RequestInit): Promise => { + abortSignal = init?.signal; + // Return a promise that never resolves to simulate an in-flight request + return new Promise(() => {}); + }; + + const client = new HTTPPrometheusClient({ + url: 'http://localhost:8080', + fetchFn: mockFetch, + }); + + // Start a request (don't await it) + client.labelNames(); + + // Verify the signal was captured and not aborted yet + expect(abortSignal).toBeDefined(); + expect(abortSignal?.aborted).toBe(false); + + // Destroy the client + client.destroy(); + + // Verify the request was aborted + expect(abortSignal?.aborted).toBe(true); + }); +}); + +describe('CachedPrometheusClient destroy', () => { + it('should be safe to call destroy multiple times', () => { + const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + const cachedClient = new CachedPrometheusClient(httpClient); + + // First call + cachedClient.destroy(); + // Second call should not throw + expect(() => cachedClient.destroy()).not.toThrow(); + }); + + it('should call destroy on the underlying HTTPPrometheusClient', () => { + const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + + let destroyCalled = false; + const originalDestroy = httpClient.destroy.bind(httpClient); + httpClient.destroy = () => { + destroyCalled = true; + originalDestroy(); + }; + + const cachedClient = new CachedPrometheusClient(httpClient); + cachedClient.destroy(); + + expect(destroyCalled).toBe(true); + }); + + it('should handle underlying clients without destroy method', () => { + // Create a minimal PrometheusClient without destroy + const minimalClient = { + labelNames: () => Promise.resolve([]), + labelValues: () => Promise.resolve([]), + metricMetadata: () => Promise.resolve({}), + series: () => Promise.resolve([]), + metricNames: () => Promise.resolve([]), + flags: () => Promise.resolve({}), + }; + + const cachedClient = new CachedPrometheusClient(minimalClient); + + // Should not throw even though underlying client has no destroy + expect(() => cachedClient.destroy()).not.toThrow(); + }); +}); diff --git a/web/ui/module/codemirror-promql/src/client/prometheus.ts b/web/ui/module/codemirror-promql/src/client/prometheus.ts index 165549ac82..91de148f3c 100644 --- a/web/ui/module/codemirror-promql/src/client/prometheus.ts +++ b/web/ui/module/codemirror-promql/src/client/prometheus.ts @@ -39,6 +39,9 @@ export interface PrometheusClient { // flags returns flag values that prometheus was configured with. flags(): Promise>; + + // destroy is called to release all resources held by this client + destroy?(): void; } export interface CacheConfig { @@ -88,6 +91,7 @@ export class HTTPPrometheusClient implements PrometheusClient { // when calling it, thus the indirection via another function wrapper. private readonly fetchFn: FetchFn = (input: RequestInfo, init?: RequestInit): Promise => fetch(input, init); private requestHeaders: Headers = new Headers(); + private readonly abortControllers: Set = new Set(); constructor(config: PrometheusConfig) { this.url = config.url ? config.url : ''; @@ -199,11 +203,22 @@ export class HTTPPrometheusClient implements PrometheusClient { }); } + destroy(): void { + for (const controller of this.abortControllers) { + controller.abort(); + } + this.abortControllers.clear(); + } + private fetchAPI(resource: string, init?: RequestInit): Promise { + const controller = new AbortController(); + this.abortControllers.add(controller); + if (init) { init.headers = this.requestHeaders; + init.signal = controller.signal; } else { - init = { headers: this.requestHeaders }; + init = { headers: this.requestHeaders, signal: controller.signal }; } return this.fetchFn(this.url + resource, init) .then((res) => { @@ -221,6 +236,9 @@ export class HTTPPrometheusClient implements PrometheusClient { throw new Error('missing "data" field in response JSON'); } return apiRes.data; + }) + .finally(() => { + this.abortControllers.delete(controller); }); } @@ -448,4 +466,8 @@ export class CachedPrometheusClient implements PrometheusClient { return flags; }); } + + destroy(): void { + this.client.destroy?.(); + } } diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.ts index bb5f4d9d36..fc79b6fcd6 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.ts @@ -575,6 +575,10 @@ export class HybridComplete implements CompleteStrategy { return this.prometheusClient; } + destroy(): void { + this.prometheusClient?.destroy?.(); + } + promQL(context: CompletionContext): Promise | CompletionResult | null { const { state, pos } = context; const tree = syntaxTree(state).resolve(pos, -1); diff --git a/web/ui/module/codemirror-promql/src/complete/index.ts b/web/ui/module/codemirror-promql/src/complete/index.ts index b3902c3b6b..dd73857639 100644 --- a/web/ui/module/codemirror-promql/src/complete/index.ts +++ b/web/ui/module/codemirror-promql/src/complete/index.ts @@ -19,6 +19,7 @@ import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; // Every different completion mode must implement this interface. export interface CompleteStrategy { promQL(context: CompletionContext): Promise | CompletionResult | null; + destroy?(): void; } // CompleteConfiguration should be used to customize the autocompletion. diff --git a/web/ui/module/codemirror-promql/src/promql.test.ts b/web/ui/module/codemirror-promql/src/promql.test.ts new file mode 100644 index 0000000000..787747cc5e --- /dev/null +++ b/web/ui/module/codemirror-promql/src/promql.test.ts @@ -0,0 +1,58 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PromQLExtension } from './promql'; +import { CompleteStrategy } from './complete'; +import { CompletionResult } from '@codemirror/autocomplete'; + +describe('PromQLExtension destroy', () => { + it('should be safe to call destroy multiple times', () => { + const extension = new PromQLExtension(); + // First call + extension.destroy(); + // Second call should not throw + expect(() => extension.destroy()).not.toThrow(); + }); + + it('should call destroy on the complete strategy if available', () => { + const extension = new PromQLExtension(); + + // Set up a mock complete strategy with destroy + let destroyCalled = false; + const mockCompleteStrategy: CompleteStrategy = { + promQL: (): CompletionResult | null => null, + destroy: () => { + destroyCalled = true; + }, + }; + + extension.setComplete({ completeStrategy: mockCompleteStrategy }); + extension.destroy(); + + expect(destroyCalled).toBe(true); + }); + + it('should handle complete strategies without destroy method', () => { + const extension = new PromQLExtension(); + + // Set up a mock complete strategy without destroy + const mockCompleteStrategy: CompleteStrategy = { + promQL: (): CompletionResult | null => null, + }; + + extension.setComplete({ completeStrategy: mockCompleteStrategy }); + + // Should not throw even though complete strategy has no destroy + expect(() => extension.destroy()).not.toThrow(); + }); +}); diff --git a/web/ui/module/codemirror-promql/src/promql.ts b/web/ui/module/codemirror-promql/src/promql.ts index 506cd1348b..859442559f 100644 --- a/web/ui/module/codemirror-promql/src/promql.ts +++ b/web/ui/module/codemirror-promql/src/promql.ts @@ -79,6 +79,10 @@ export class PromQLExtension { return this; } + destroy(): void { + this.complete.destroy?.(); + } + asExtension(languageType = LanguageType.PromQL): Extension { const language = promQLLanguage(languageType); let extension: Extension = [language];