diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index cc9a951e1ef..bc538e2cf2b 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -585,6 +585,7 @@ export namespace ConfigKey { export const DebugUseNodeFetchFetcher = defineSetting('advanced.debug.useNodeFetchFetcher', ConfigType.Simple, true); export const DebugUseNodeFetcher = defineSetting('advanced.debug.useNodeFetcher', ConfigType.Simple, false); export const DebugUseElectronFetcher = defineSetting('advanced.debug.useElectronFetcher', ConfigType.Simple, true); + export const DebugNodeFetchCache = defineSetting<'off' | 'memory' | 'persistent'>('advanced.debug.nodeFetchCache', ConfigType.Simple, 'memory'); export const AuthProvider = defineSetting('advanced.authProvider', ConfigType.Simple, AuthProviderId.GitHub); export const AuthPermissions = defineSetting('advanced.authPermissions', ConfigType.Simple, AuthPermissionMode.Default); } diff --git a/extensions/copilot/src/platform/networking/common/fetcherService.ts b/extensions/copilot/src/platform/networking/common/fetcherService.ts index ae22b33ad8d..8199d237e25 100644 --- a/extensions/copilot/src/platform/networking/common/fetcherService.ts +++ b/extensions/copilot/src/platform/networking/common/fetcherService.ts @@ -71,8 +71,16 @@ export interface FetchTelemetryEvent { latencyMs: number; statusCode: number | undefined; success: boolean; + /** + * Cache outcome when the caller opted in via {@link FetchOptions.cache}. + * `undefined` when the caller did not opt in or the selected fetcher does + * not implement caching. + */ + cacheStatus?: CacheStatus; } +export type CacheStatus = 'hit' | 'stale-hit' | 'revalidated' | 'miss' | 'bypass'; + /** A basic version of http://developer.mozilla.org/en-US/docs/Web/API/Response */ export class Response { ok = this.status >= 200 && this.status < 300; @@ -92,6 +100,7 @@ export class Response { private readonly _reportEvent: ReportFetchEvent, private readonly _internalId: string, private readonly _hostname: string, + readonly cacheStatus?: CacheStatus, ) { const transformer = { transform: (chunk: Uint8Array, controller: TransformStreamDefaultController) => { @@ -169,6 +178,13 @@ export interface FetchOptions { expectJSON?: boolean; useFetcher?: FetcherId; suppressIntegrationId?: boolean; + /** + * Opportunistic cache hint. When set to `true`, fetchers that implement + * caching (today only the Node fetch fetcher) will route the request + * through an RFC 9111 cache. Fetchers without cache support ignore this + * flag, and fallback to other fetchers continues to work normally. + */ + cache?: boolean; } export interface PaginationOptions extends FetchOptions { diff --git a/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts b/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts index f921570c53a..b7621ae704f 100644 --- a/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts +++ b/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts @@ -7,13 +7,20 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IEnvService } from '../../env/common/envService'; import { collectSingleLineErrorMessage } from '../../log/common/logService'; -import { FetcherId, FetchOptions, IAbortController, isAbortError, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService'; +import { CacheStatus, FetcherId, FetchOptions, IAbortController, isAbortError, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService'; import { IFetcher, userAgentLibraryHeader } from '../common/networking'; +import { VSCODE_CACHE_STATUS_HEADER } from './taggedCacheInterceptor'; + +export type FetchImpl = ( + input: string | globalThis.Request, + init?: RequestInit, + useCache?: boolean, +) => Promise; export abstract class BaseFetchFetcher implements IFetcher { constructor( - private readonly _fetchImpl: typeof fetch | typeof import('electron').net.fetch, + private readonly _fetchImpl: FetchImpl, private readonly _envService: IEnvService, private readonly _fetcherId: FetcherId, private readonly _reportEvent: ReportFetchEvent, @@ -52,7 +59,7 @@ export abstract class BaseFetchFetcher implements IFetcher { const internalId = generateUuid(); const hostname = safeGetHostname(url); try { - const response = await this._fetch(url, method, headers, body, signal, internalId, hostname); + const response = await this._fetch(url, method, headers, body, signal, internalId, hostname, options); this._reportEvent({ internalId, timestamp: Date.now(), outcome: 'success', phase: 'requestResponse', fetcher: this._fetcherId, hostname, statusCode: response.status }); return response; } catch (e) { @@ -89,8 +96,8 @@ export abstract class BaseFetchFetcher implements IFetcher { return items; } - private async _fetch(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', headers: { [name: string]: string }, body: string | undefined, signal: AbortSignal, internalId: string, hostname: string): Promise { - const resp = await this._fetchImpl(url, { method, headers, body, signal }); + private async _fetch(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', headers: { [name: string]: string }, body: string | undefined, signal: AbortSignal, internalId: string, hostname: string, options: FetchOptions): Promise { + const resp = await this._fetchImpl(url, { method, headers, body, signal }, !!options.cache); return new Response( resp.status, resp.statusText, @@ -100,9 +107,24 @@ export abstract class BaseFetchFetcher implements IFetcher { this._reportEvent, internalId, hostname, + this._readCacheStatus(resp.headers, options), ); } + private _readCacheStatus(headers: { get(name: string): string | null }, options: FetchOptions): CacheStatus | undefined { + if (!options.cache) { + return undefined; + } + const stamped = headers.get(VSCODE_CACHE_STATUS_HEADER); + if (stamped === 'hit' || stamped === 'stale-hit' || stamped === 'revalidated' || stamped === 'miss') { + return stamped; + } + // Caller opted in but the response carried no marker — either the + // fetcher does not implement caching or the cache interceptor skipped + // this request. + return 'bypass'; + } + async disconnectAll(): Promise { // Nothing to do } diff --git a/extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts b/extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts index fd0fc99410f..68d9c3da7c7 100644 --- a/extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts +++ b/extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts @@ -8,7 +8,22 @@ import * as undici from 'undici'; import { Lazy } from '../../../util/vs/base/common/lazy'; import { IEnvService } from '../../env/common/envService'; import { HeadersImpl, IHeaders, ReportFetchEvent, WebSocketConnection, WebSocketConnectOptions } from '../common/fetcherService'; -import { BaseFetchFetcher } from './baseFetchFetcher'; +import { BaseFetchFetcher, FetchImpl } from './baseFetchFetcher'; +import { taggedCacheInterceptor } from './taggedCacheInterceptor'; + +type CacheInterceptorOptions = NonNullable[0]>; +type CacheStore = NonNullable; + +type FetchPatchFactory = (options?: { + interceptors?: readonly undici.Dispatcher.DispatcherComposeInterceptor[]; +}) => typeof globalThis.fetch; + +export type NodeFetchCacheMode = 'off' | 'memory' | 'persistent'; + +export interface NodeFetchCacheOptions { + readonly mode: NodeFetchCacheMode; + readonly storeLocation?: string; +} export class NodeFetchFetcher extends BaseFetchFetcher { @@ -18,8 +33,14 @@ export class NodeFetchFetcher extends BaseFetchFetcher { envService: IEnvService, reportEvent: ReportFetchEvent = () => { }, userAgentLibraryUpdate?: (original: string) => string, + cacheOptions: NodeFetchCacheOptions = { mode: 'memory' }, ) { - super(getFetch(), envService, NodeFetchFetcher.ID, reportEvent, userAgentLibraryUpdate); + // Caching requires the host-provided fetch-patch factory so cached requests + // still go through the proxy/CA-injection patch. On older hosts that lack + // the factory, caching is silently disabled. + const factory = (globalThis as any).__vscodeCreateFetchPatch as FetchPatchFactory | undefined; + const interceptor = cacheOptions.mode !== 'off' && factory ? createCacheInterceptor(cacheOptions) : undefined; + super(getFetch(interceptor, factory), envService, NodeFetchFetcher.ID, reportEvent, userAgentLibraryUpdate); } getUserAgentLibrary(): string { @@ -35,10 +56,44 @@ export class NodeFetchFetcher extends BaseFetchFetcher { } } -function getFetch(): typeof globalThis.fetch { - const fetch = (globalThis as any).__vscodePatchedFetch || globalThis.fetch; - return function (input: string | URL | globalThis.Request, init?: RequestInit) { - return fetch(input, { dispatcher: agent.value, ...init }); +function createCacheInterceptor(options: NodeFetchCacheOptions): undici.Dispatcher.DispatcherComposeInterceptor | undefined { + const store = createCacheStore(options); + if (!store) { + return undefined; + } + return taggedCacheInterceptor({ store, type: 'private' }); +} + +function createCacheStore(options: NodeFetchCacheOptions): CacheStore | undefined { + if (options.mode === 'persistent') { + const SqliteCacheStore = (undici as unknown as { cacheStores?: { SqliteCacheStore?: new (init?: object) => CacheStore } }).cacheStores?.SqliteCacheStore; + if (SqliteCacheStore && options.storeLocation) { + try { + return new SqliteCacheStore({ + location: options.storeLocation, + maxCount: 5000, + maxEntrySize: 5 * 1024 * 1024, + }); + } catch { + } + } + } + const MemoryCacheStore = (undici as unknown as { cacheStores?: { MemoryCacheStore?: new (init?: object) => CacheStore } }).cacheStores?.MemoryCacheStore; + if (!MemoryCacheStore) { + return undefined; + } + return new MemoryCacheStore({ maxCount: 1000, maxEntrySize: 5 * 1024 * 1024 }); +} + +function getFetch(cacheInterceptor: undici.Dispatcher.DispatcherComposeInterceptor | undefined, createFetchPatch: FetchPatchFactory | undefined): FetchImpl { + const defaultFetch = (globalThis as any).__vscodePatchedFetch || globalThis.fetch; + const cachedFetch = cacheInterceptor && createFetchPatch ? createFetchPatch({ interceptors: [cacheInterceptor] }) : undefined; + return function (input, init, useCache) { + if (useCache && cachedFetch) { + return cachedFetch(input, init); + } + const dispatcher = (init as { dispatcher?: undici.Dispatcher } | undefined)?.dispatcher ?? agent.value; + return defaultFetch(input, { ...init, dispatcher }); }; } diff --git a/extensions/copilot/src/platform/networking/node/taggedCacheInterceptor.ts b/extensions/copilot/src/platform/networking/node/taggedCacheInterceptor.ts new file mode 100644 index 00000000000..ff1823cd136 --- /dev/null +++ b/extensions/copilot/src/platform/networking/node/taggedCacheInterceptor.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as undici from 'undici'; + +/** + * Internal response header stamped by {@link taggedCacheInterceptor} so the + * fetcher can attribute a response to the cache for telemetry purposes. + */ +export const VSCODE_CACHE_STATUS_HEADER = 'x-vscode-cache-status'; + +export type CacheStatus = 'hit' | 'stale-hit' | 'revalidated' | 'miss'; + +/** + * Wraps undici's built-in `interceptors.cache(...)` and stamps the served + * response with {@link VSCODE_CACHE_STATUS_HEADER} so downstream code can + * tell whether the response came from the cache without relying on header + * heuristics. + * + * Detection is based on observing whether the cache interceptor invoked the + * downstream dispatcher for a given request and whether it added conditional + * revalidation headers — both of which are deterministic for a given code + * path in `undici/lib/interceptor/cache.js`. + */ +export function taggedCacheInterceptor( + cacheOpts: Parameters[0], +): undici.Dispatcher.DispatcherComposeInterceptor { + const cacheInterceptor = undici.interceptors.cache(cacheOpts); + + return (dispatch) => (opts, handler) => { + const state = { networkCalled: false, conditional: false }; + + const countingDispatch: typeof dispatch = (dOpts, dHandler) => { + state.networkCalled = true; + const h = dOpts.headers as Record | undefined; + state.conditional = !!(h && (h['if-modified-since'] || h['if-none-match'])); + return dispatch(dOpts, dHandler); + }; + + const taggingHandler = new Proxy(handler, { + get(target, prop, receiver) { + if (prop === 'onResponseStart') { + return ( + controller: Parameters>[0], + statusCode: number, + headers: unknown, + statusMessage?: string, + ) => { + const status = classify(state, headers); + stampStatus(headers, status); + const orig = Reflect.get(target, prop, receiver) as undici.Dispatcher.DispatchHandler['onResponseStart']; + return orig?.call(target, controller, statusCode, headers as Parameters>[2], statusMessage); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as undici.Dispatcher.DispatchHandler; + + return cacheInterceptor(countingDispatch)(opts, taggingHandler); + }; +} + +function classify( + state: { networkCalled: boolean; conditional: boolean }, + headers: unknown, +): CacheStatus { + if (!state.networkCalled) { + return isStaleWarning(headers) ? 'stale-hit' : 'hit'; + } + if (state.conditional) { + return 'revalidated'; + } + return 'miss'; +} + +function isStaleWarning(headers: unknown): boolean { + const value = readHeader(headers, 'warning'); + return typeof value === 'string' && value.startsWith('110'); +} + +function readHeader(headers: unknown, name: string): string | undefined { + if (!headers || typeof headers !== 'object') { + return undefined; + } + const value = (headers as Record)[name]; + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0]; + } + return undefined; +} + +function stampStatus(headers: unknown, status: CacheStatus): void { + if (headers && typeof headers === 'object' && !Array.isArray(headers)) { + (headers as Record)[VSCODE_CACHE_STATUS_HEADER] = status; + } +} diff --git a/extensions/copilot/src/platform/networking/node/test/taggedCacheInterceptor.spec.ts b/extensions/copilot/src/platform/networking/node/test/taggedCacheInterceptor.spec.ts new file mode 100644 index 00000000000..ad737da829a --- /dev/null +++ b/extensions/copilot/src/platform/networking/node/test/taggedCacheInterceptor.spec.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as undici from 'undici'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('undici', () => { + const stub = { + interceptors: { cache: vi.fn() }, + }; + return { ...stub, default: stub }; +}); + +type FakeDispatch = (opts: { headers?: Record }, handler: undici.Dispatcher.DispatchHandler) => boolean; +type CacheMiddleware = (dispatch: FakeDispatch) => FakeDispatch; + +async function importTagger() { + vi.resetModules(); + const { taggedCacheInterceptor, VSCODE_CACHE_STATUS_HEADER } = await import('../taggedCacheInterceptor'); + const undiciMock = (await import('undici')) as unknown as { interceptors: { cache: ReturnType } }; + return { taggedCacheInterceptor, VSCODE_CACHE_STATUS_HEADER, undiciMock }; +} + +function makeController(): undici.Dispatcher.DispatchController { + return {} as undici.Dispatcher.DispatchController; +} + +async function runTagger(middleware: CacheMiddleware): Promise<{ stamped: string | undefined; downstream: ReturnType }> { + const { taggedCacheInterceptor, VSCODE_CACHE_STATUS_HEADER, undiciMock } = await importTagger(); + undiciMock.interceptors.cache.mockReturnValue(middleware); + + const downstream = vi.fn(((_opts: { headers?: Record }, _handler: undici.Dispatcher.DispatchHandler) => true) as FakeDispatch); + const tagger = taggedCacheInterceptor({} as Parameters[0]); + const intercepted = tagger(downstream as unknown as undici.Dispatcher['dispatch']); + + let stamped: string | undefined; + intercepted({ headers: {} } as undici.Dispatcher.DispatchOptions, { + onResponseStart: (_c, _s, headers) => { + stamped = (headers as Record)[VSCODE_CACHE_STATUS_HEADER]; + }, + }); + return { stamped, downstream }; +} + +describe('taggedCacheInterceptor', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('classifies a cache hit when the downstream dispatch is never invoked', async () => { + const { stamped, downstream } = await runTagger(() => (_opts, handler) => { + handler.onResponseStart?.(makeController(), 200, { age: '60' }, 'OK'); + return true; + }); + + expect(stamped).toBe('hit'); + expect(downstream).not.toHaveBeenCalled(); + }); + + it('classifies a stale hit when the served response carries a 110 warning', async () => { + const { stamped } = await runTagger(() => (_opts, handler) => { + handler.onResponseStart?.(makeController(), 200, { age: '120', warning: '110 - "response is stale"' }, 'OK'); + return true; + }); + + expect(stamped).toBe('stale-hit'); + }); + + it('classifies a revalidation when conditional headers reach the origin', async () => { + const { stamped, downstream } = await runTagger((dispatch) => (_opts, handler) => { + dispatch({ headers: { 'if-none-match': '"abc"' } }, {} as undici.Dispatcher.DispatchHandler); + handler.onResponseStart?.(makeController(), 200, {}, 'OK'); + return true; + }); + + expect(stamped).toBe('revalidated'); + expect(downstream).toHaveBeenCalledTimes(1); + }); + + it('classifies a miss when the cache passes the request through unchanged', async () => { + const { stamped, downstream } = await runTagger((dispatch) => (opts, handler) => { + dispatch(opts, {} as undici.Dispatcher.DispatchHandler); + handler.onResponseStart?.(makeController(), 200, {}, 'OK'); + return true; + }); + + expect(stamped).toBe('miss'); + expect(downstream).toHaveBeenCalledTimes(1); + }); + + it('does not throw when response headers are an array (raw wire format)', async () => { + const { taggedCacheInterceptor, undiciMock } = await importTagger(); + const middleware: CacheMiddleware = () => (_opts, handler) => { + handler.onResponseStart?.(makeController(), 200, ['age', '60'] as unknown as Record, 'OK'); + return true; + }; + undiciMock.interceptors.cache.mockReturnValue(middleware); + + const tagger = taggedCacheInterceptor({} as Parameters[0]); + const intercepted = tagger((() => true) as unknown as undici.Dispatcher['dispatch']); + expect(() => intercepted({ headers: {} } as undici.Dispatcher.DispatchOptions, { onResponseStart: () => { } })).not.toThrow(); + }); + + it('forwards prototype-defined handler methods through the tagging proxy', async () => { + const { taggedCacheInterceptor, undiciMock } = await importTagger(); + const middleware: CacheMiddleware = () => (_opts, handler) => { + handler.onResponseStart?.(makeController(), 200, {}, 'OK'); + handler.onResponseData?.(makeController(), Buffer.from('chunk')); + handler.onResponseEnd?.(makeController(), {}); + return true; + }; + undiciMock.interceptors.cache.mockReturnValue(middleware); + + const calls: string[] = []; + class ProtoHandler { + onResponseStart() { calls.push('start'); } + onResponseData() { calls.push('data'); } + onResponseEnd() { calls.push('end'); } + } + + const tagger = taggedCacheInterceptor({} as Parameters[0]); + const intercepted = tagger((() => true) as unknown as undici.Dispatcher['dispatch']); + intercepted({ headers: {} } as undici.Dispatcher.DispatchOptions, new ProtoHandler() as unknown as undici.Dispatcher.DispatchHandler); + + expect(calls).toEqual(['start', 'data', 'end']); + }); +}); diff --git a/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts b/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts index 13b03bf86e6..4c3fe7725ad 100644 --- a/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts +++ b/extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { Config, ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType, IConfigurationService } from '../../configuration/common/configurationService'; import { INTEGRATION_ID } from '../../endpoint/common/licenseAgreement'; import { IEnvService } from '../../env/common/envService'; +import { IVSCodeExtensionContext } from '../../extContext/common/extensionContext'; import { ILogService } from '../../log/common/logService'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; @@ -15,7 +17,7 @@ import { FetchEvent, FetchOptions, FetchTelemetryEvent, IAbortController, IFetch import { IFetcher } from '../common/networking'; import { fetchWithFallbacks } from '../node/fetcherFallback'; import { NodeFetcher } from '../node/nodeFetcher'; -import { createWebSocket, NodeFetchFetcher } from '../node/nodeFetchFetcher'; +import { createWebSocket, NodeFetchCacheMode, NodeFetchCacheOptions, NodeFetchFetcher } from '../node/nodeFetchFetcher'; import { ElectronFetcher } from './electronFetcher'; export class FetcherService extends Disposable implements IFetcherService { @@ -35,6 +37,7 @@ export class FetcherService extends Disposable implements IFetcherService { @ILogService private readonly _logService: ILogService, @IEnvService private readonly _envService: IEnvService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, ) { super(); this._availableFetchers = fetcher ? [fetcher] : undefined; @@ -106,7 +109,7 @@ export class FetcherService extends Disposable implements IFetcherService { } // Node fetch preferred over Node https in fallbacks. (HTTP2 support) - const nodeFetchFetcher = new NodeFetchFetcher(envService, reportEvent); + const nodeFetchFetcher = new NodeFetchFetcher(envService, reportEvent, undefined, this._resolveNodeFetchCacheOptions(configurationService)); if (useNodeFetchFetcher) { this._logService.info(`Using the Node fetch fetcher.`); fetchers.unshift(nodeFetchFetcher); @@ -125,6 +128,23 @@ export class FetcherService extends Disposable implements IFetcherService { return fetchers; } + private _resolveNodeFetchCacheOptions(configurationService: IConfigurationService): NodeFetchCacheOptions { + const mode = configurationService.getConfig(ConfigKey.Shared.DebugNodeFetchCache) as NodeFetchCacheMode; + if (mode === 'off') { + return { mode: 'off' }; + } + if (mode === 'persistent') { + const storageUri = this._extensionContext.globalStorageUri; + if (storageUri && storageUri.scheme === 'file') { + return { + mode: 'persistent', + storeLocation: path.join(storageUri.fsPath, 'undici-cache.v1.sqlite'), + }; + } + } + return { mode: 'memory' }; + } + getUserAgentLibrary(): string { return this._getAvailableFetchers()[0].getUserAgentLibrary(); } @@ -154,6 +174,7 @@ export class FetcherService extends Disposable implements IFetcherService { latencyMs: Date.now() - start, statusCode: res.status, success: res.ok, + cacheStatus: res.cacheStatus, }); } return res; diff --git a/extensions/copilot/src/platform/networking/vscode-node/test/fetcherServiceCrash.spec.ts b/extensions/copilot/src/platform/networking/vscode-node/test/fetcherServiceCrash.spec.ts index 3a3d638c28b..f01e42bf919 100644 --- a/extensions/copilot/src/platform/networking/vscode-node/test/fetcherServiceCrash.spec.ts +++ b/extensions/copilot/src/platform/networking/vscode-node/test/fetcherServiceCrash.spec.ts @@ -33,6 +33,7 @@ describe('FetcherService network process crash handling', () => { logService, { machineId: '', sessionId: '', vscodeVersion: '', getName: () => 'test', getVersion: () => '0.0.0', getBuildType: () => 'development' as any } as any, configurationService, + { globalStorageUri: undefined } as any, ); service.setExperimentationService(experimentationService); // Inject the fetchers directly @@ -215,6 +216,7 @@ describe('FetcherService network process crash handling', () => { logService, { machineId: '', sessionId: '', vscodeVersion: '', getName: () => 'test', getVersion: () => '0.0.0', getBuildType: () => 'development' as any } as any, configurationService, + { globalStorageUri: undefined } as any, ); (service as any)._availableFetchers = [electronFetcher, nodeFetcher]; // Explicitly do NOT call service.setExperimentationService() diff --git a/extensions/copilot/src/platform/networking/vscode-node/test/nodeFetchFetcherCache.test.ts b/extensions/copilot/src/platform/networking/vscode-node/test/nodeFetchFetcherCache.test.ts new file mode 100644 index 00000000000..140a60f52bb --- /dev/null +++ b/extensions/copilot/src/platform/networking/vscode-node/test/nodeFetchFetcherCache.test.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as http from 'http'; +import { IFetcherService } from '../../common/fetcherService'; +import { createExtensionTestingServices } from '../../../../extension/test/vscode-node/services'; +import { ITestingServicesAccessor } from '../../../test/node/services'; + +suite('NodeFetchFetcher cache - integration', function () { + let server: http.Server; + let origin: string; + let originHits: number; + let accessor: ITestingServicesAccessor; + let fetcher: IFetcherService; + + suiteSetup(async function () { + if (typeof (globalThis as { __vscodeCreateFetchPatch?: unknown }).__vscodeCreateFetchPatch !== 'function') { + this.skip(); + } + + server = http.createServer((req, res) => { + originHits++; + const url = req.url ?? ''; + if (url.startsWith('/cacheable')) { + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'public, max-age=60', + 'x-custom-header': 'preserved', + }); + res.end(JSON.stringify({ hits: originHits, path: url })); + return; + } + if (url.startsWith('/private')) { + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'private, max-age=60', + }); + res.end(JSON.stringify({ hits: originHits })); + return; + } + if (url.startsWith('/etag')) { + if (req.headers['if-none-match'] === '"v1"') { + res.writeHead(304, { 'etag': '"v1"' }); + res.end(); + return; + } + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'public, max-age=60', + 'etag': '"v1"', + }); + res.end(JSON.stringify({ hits: originHits })); + return; + } + if (url.startsWith('/no-store')) { + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }); + res.end(JSON.stringify({ hits: originHits })); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + origin = `http://127.0.0.1:${port}`; + }); + + suiteTeardown(async () => { + if (!server) { + return; + } + await new Promise(resolve => server.close(() => resolve())); + }); + + setup(() => { + originHits = 0; + accessor = createExtensionTestingServices().createTestingAccessor(); + fetcher = accessor.get(IFetcherService); + }); + + test('serves the second cache-opted request from the in-memory store', async () => { + const first = await fetcher.fetch(`${origin}/cacheable?key=hit-miss`, { callSite: 'test', cache: true }); + const firstBody = await first.json(); + const second = await fetcher.fetch(`${origin}/cacheable?key=hit-miss`, { callSite: 'test', cache: true }); + const secondBody = await second.json(); + + assert.strictEqual(originHits, 1, 'origin should be hit exactly once'); + assert.strictEqual(first.cacheStatus, 'miss'); + assert.strictEqual(second.cacheStatus, 'hit'); + assert.deepStrictEqual(secondBody, firstBody, 'cached body should match origin body'); + assert.notStrictEqual(second.headers.get('age'), null, 'cache hit should carry age header'); + }); + + test('leaves requests uncached when the caller does not opt in', async () => { + const first = await fetcher.fetch(`${origin}/cacheable?key=no-opt-in`, { callSite: 'test' }); + await first.text(); + const second = await fetcher.fetch(`${origin}/cacheable?key=no-opt-in`, { callSite: 'test' }); + await second.text(); + + assert.strictEqual(originHits, 2, 'every request should hit the origin'); + assert.strictEqual(first.cacheStatus, undefined); + assert.strictEqual(second.cacheStatus, undefined); + }); + + test('respects cache-control: no-store from the origin', async () => { + const first = await fetcher.fetch(`${origin}/no-store?key=no-store`, { callSite: 'test', cache: true }); + await first.text(); + const second = await fetcher.fetch(`${origin}/no-store?key=no-store`, { callSite: 'test', cache: true }); + await second.text(); + + assert.strictEqual(originHits, 2, 'no-store responses must not be served from cache'); + assert.strictEqual(first.cacheStatus, 'miss'); + assert.strictEqual(second.cacheStatus, 'miss'); + }); + + test('does not cache POST requests', async () => { + const first = await fetcher.fetch(`${origin}/cacheable?key=post`, { callSite: 'test', cache: true, method: 'POST', body: '{}' }); + await first.text(); + const second = await fetcher.fetch(`${origin}/cacheable?key=post`, { callSite: 'test', cache: true, method: 'POST', body: '{}' }); + await second.text(); + + assert.strictEqual(originHits, 2, 'POST requests must always reach the origin'); + assert.strictEqual(first.cacheStatus, 'miss'); + assert.strictEqual(second.cacheStatus, 'miss'); + }); + + test('caches different URLs independently', async () => { + const a1 = await fetcher.fetch(`${origin}/cacheable?key=urlA`, { callSite: 'test', cache: true }); + await a1.text(); + const b1 = await fetcher.fetch(`${origin}/cacheable?key=urlB`, { callSite: 'test', cache: true }); + await b1.text(); + const a2 = await fetcher.fetch(`${origin}/cacheable?key=urlA`, { callSite: 'test', cache: true }); + await a2.text(); + const b2 = await fetcher.fetch(`${origin}/cacheable?key=urlB`, { callSite: 'test', cache: true }); + await b2.text(); + + assert.strictEqual(originHits, 2, 'each distinct URL should hit the origin once'); + assert.strictEqual(a1.cacheStatus, 'miss'); + assert.strictEqual(b1.cacheStatus, 'miss'); + assert.strictEqual(a2.cacheStatus, 'hit'); + assert.strictEqual(b2.cacheStatus, 'hit'); + }); + + test('keys cache entries by query string', async () => { + const v1 = await fetcher.fetch(`${origin}/cacheable?v=1`, { callSite: 'test', cache: true }); + await v1.text(); + const v2 = await fetcher.fetch(`${origin}/cacheable?v=2`, { callSite: 'test', cache: true }); + await v2.text(); + + assert.strictEqual(originHits, 2, 'different query strings should not collide'); + assert.strictEqual(v1.cacheStatus, 'miss'); + assert.strictEqual(v2.cacheStatus, 'miss'); + }); + + test('preserves status, body and response headers on a cache hit', async () => { + const first = await fetcher.fetch(`${origin}/cacheable?key=headers`, { callSite: 'test', cache: true }); + const firstBody = await first.json(); + const second = await fetcher.fetch(`${origin}/cacheable?key=headers`, { callSite: 'test', cache: true }); + const secondBody = await second.json(); + + assert.strictEqual(originHits, 1); + assert.strictEqual(second.status, first.status); + assert.strictEqual(second.statusText, first.statusText); + assert.strictEqual(second.headers.get('content-type'), 'application/json'); + assert.strictEqual(second.headers.get('x-custom-header'), 'preserved'); + assert.deepStrictEqual(secondBody, firstBody); + }); + + test('caches Cache-Control: private responses in the private store', async () => { + const first = await fetcher.fetch(`${origin}/private?key=private`, { callSite: 'test', cache: true }); + await first.text(); + const second = await fetcher.fetch(`${origin}/private?key=private`, { callSite: 'test', cache: true }); + await second.text(); + + assert.strictEqual(originHits, 1, 'private responses should be cached in the private store'); + assert.strictEqual(first.cacheStatus, 'miss'); + assert.strictEqual(second.cacheStatus, 'hit'); + }); + + test('reports revalidated when the origin returns 304 Not Modified', async () => { + const first = await fetcher.fetch(`${origin}/etag?key=etag`, { callSite: 'test', cache: true }); + const firstBody = await first.json(); + const second = await fetcher.fetch(`${origin}/etag?key=etag`, { callSite: 'test', cache: true, headers: { 'cache-control': 'no-cache' } }); + const secondBody = await second.json(); + + assert.strictEqual(originHits, 2, 'a 304 still counts as an origin request'); + assert.strictEqual(first.cacheStatus, 'miss'); + assert.strictEqual(second.cacheStatus, 'revalidated'); + assert.strictEqual(second.status, 200, 'revalidated response is served with the cached 200 status'); + assert.deepStrictEqual(secondBody, firstBody, 'revalidated response carries the cached body'); + }); + + test('coalesces or caches concurrent requests so the second is not an extra miss', async () => { + const [a, b] = await Promise.all([ + fetcher.fetch(`${origin}/cacheable?key=concurrent`, { callSite: 'test', cache: true }), + fetcher.fetch(`${origin}/cacheable?key=concurrent`, { callSite: 'test', cache: true }), + ]); + await Promise.all([a.text(), b.text()]); + const third = await fetcher.fetch(`${origin}/cacheable?key=concurrent`, { callSite: 'test', cache: true }); + await third.text(); + assert.ok(originHits <= 2, `origin hit at most twice for concurrent fetches, got ${originHits}`); + assert.strictEqual(third.cacheStatus, 'hit'); + }); +}); diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index b03c5e1a747..05062a44e3f 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -89,12 +89,14 @@ export class TelemetryService extends BaseTelemetryService { "owner": "lramos15", "comment": "Telemetry about fetch requests made by the extension, tracking request counts and latency per call site.", "callSite": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The call site identifier for the fetch request." }, + "cacheStatus": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Cache outcome for callers that opted in: 'hit', 'stale-hit', 'revalidated', 'miss', 'bypass'. Empty string when caching was not requested." }, "latencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The latency of the fetch request in milliseconds." }, "statusCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The HTTP status code returned by the fetch request." } } */ this.sendMSFTTelemetryEvent('fetchTelemetry', { callSite: new TelemetryTrustedValue(event.callSite), + cacheStatus: event.cacheStatus ?? '', }, { latencyMs: event.latencyMs, statusCode: event.statusCode, diff --git a/package-lock.json b/package-lock.json index d66f20bf3ba..f76a83d76b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.41.0", + "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -4044,9 +4044,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.41.0.tgz", - "integrity": "sha512-xdjSPUu6DyC7+RBRftrj06OBG/xVLc0dsxhhwMzwfd9/pOGm8j4Zc70arq1jQb0s7EF4m9dAFoNjmSigfzN25A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", + "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 5c55cb1c919..6a56b956940 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.41.0", + "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/remote/package-lock.json b/remote/package-lock.json index 98ec6babcd6..34d559ec1ba 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -17,7 +17,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.41.0", + "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -586,9 +586,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.41.0.tgz", - "integrity": "sha512-xdjSPUu6DyC7+RBRftrj06OBG/xVLc0dsxhhwMzwfd9/pOGm8j4Zc70arq1jQb0s7EF4m9dAFoNjmSigfzN25A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", + "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index 8d7a7a7f06e..80e06e711be 100644 --- a/remote/package.json +++ b/remote/package.json @@ -12,7 +12,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.41.0", + "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 9a96891bf86..f06627f88c2 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -142,9 +142,12 @@ function patchGlobalFetch(params: ProxyAgentParams, configProvider: ExtHostConfi const originalFetch = globalThis.fetch; // eslint-disable-next-line local/code-no-any-casts (globalThis as any).__vscodeOriginalFetch = originalFetch; - const patchedFetch = proxyAgent.createFetchPatch(params, originalFetch, resolveProxyURL); + const createPatchedFetch = (options?: proxyAgent.CreateFetchPatchOptions) => proxyAgent.createFetchPatch(params, originalFetch, resolveProxyURL, options); + const patchedFetch = createPatchedFetch(); // eslint-disable-next-line local/code-no-any-casts (globalThis as any).__vscodePatchedFetch = patchedFetch; + // eslint-disable-next-line local/code-no-any-casts + (globalThis as any).__vscodeCreateFetchPatch = createPatchedFetch; let useElectronFetch = false; if (!initData.remote.isRemote) { useElectronFetch = configProvider.getConfiguration('http').get('electronFetch', useElectronFetchDefault);