feat(copilot): opt-in HTTP cache for the Node fetch fetcher (#317721)

* feat(copilot): opt-in HTTP cache for the Node fetch fetcher

Adds an opportunistic cache support to the Copilot Node fetch path. The cache
is strictly opt-in per request and composes with the existing VSCode proxy
and CA-injection patch.

- `__vscodeCreateFetchPatch({ interceptors })` lets the extension host
  build a second proxy-aware `fetch` with extra undici interceptors. The
  default `__vscodePatchedFetch` is unchanged.
- `NodeFetchFetcher` builds an undici cache interceptor once at
  construction time and uses the factory to produce a `cachedFetch` that
  routes through both the proxy patch and the cache. Requests are tagged
  with an internal `__copilotCachePatch` marker (stripped before fetch);
  unmarked requests keep going through the regular patched fetch. When
  the host lacks the factory, caching is silently disabled so requests
  never bypass the proxy patch.
- `FetchOptions.cache?: boolean` — opportunistic hint. Fetchers without
  cache support ignore it; fallback to other fetchers is unaffected.
- `Response.cacheStatus` and `FetchTelemetryEvent.cacheStatus`:
  `'hit' | 'stale-hit' | 'revalidated' | 'miss' | 'bypass'`.
- New setting `github.copilot.advanced.debug.nodeFetchCache`:
  `'off' | 'memory' | 'persistent'` (default `'memory'`). `'persistent'`
  uses undici's SQLite store under the extension's global storage
  (`undici-cache.v1.sqlite`) when available, otherwise falls back to
  memory.
- New `taggedCacheInterceptor` wraps `undici.interceptors.cache` and
  stamps a private `VSCODE_CACHE_STATUS_HEADER` on the response so the
  base fetcher can read the outcome without parsing undici internals.
- `BaseFetchFetcher` exposes an overridable `_buildRequestInit` hook and
  reports `cacheStatus` on `Response` and `fetchTelemetry`.

Notes
- No behavior change for callers that don't set `cache: true`.
- The cache interceptor is constructed once per fetcher instance; the
  composed dispatcher chain is reused so connection pooling is preserved.
- Depends on https://github.com/microsoft/vscode-proxy-agent/pull/100

For https://github.com/microsoft/vscode/issues/308310

* fix: undici integration tests

- drop the `age` header gate from classify(): undici's cache interceptor
only adds if-modified-since / if-none-match when revalidating a stored
entry, so `state.conditional` alone is a sufficient signal. The age header
is not guaranteed on a revalidated 200, which caused 'revalidated' to be
misreported as 'miss'.

- the etag integration test used
`cache-control: max-age=0, must-revalidate`, which undici treats as
already-stale on arrival and refuses to store (cache-handler.js bails when
`now >= absoluteStaleAt`), so there was nothing to revalidate on the second
call. Switch the origin to `public, max-age=60` and pass
`cache-control: no-cache` on the second request to drive undici's
needsRevalidation() path, which dispatches with if-none-match and serves
the cached body on 304.

* chore: fix lint

* chore: cleanup cache marker in favor of function arg
This commit is contained in:
Robo
2026-05-21 11:07:34 -07:00
committed by GitHub
parent bd01ba3948
commit 9cd7264dd1
15 changed files with 587 additions and 24 deletions

View File

@@ -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<AuthProviderId>('advanced.authProvider', ConfigType.Simple, AuthProviderId.GitHub);
export const AuthPermissions = defineSetting<AuthPermissionMode>('advanced.authPermissions', ConfigType.Simple, AuthPermissionMode.Default);
}

View File

@@ -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<Uint8Array>) => {
@@ -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<T> extends FetchOptions {

View File

@@ -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<globalThis.Response>;
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<Response> {
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<Response> {
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<void> {
// Nothing to do
}

View File

@@ -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<Parameters<typeof undici.interceptors.cache>[0]>;
type CacheStore = NonNullable<CacheInterceptorOptions['store']>;
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 });
};
}

View File

@@ -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<typeof undici.interceptors.cache>[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<string, string> | 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<NonNullable<undici.Dispatcher.DispatchHandler['onResponseStart']>>[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<NonNullable<undici.Dispatcher.DispatchHandler['onResponseStart']>>[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<string, unknown>)[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<string, string>)[VSCODE_CACHE_STATUS_HEADER] = status;
}
}

View File

@@ -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<string, string> }, 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<typeof vi.fn> } };
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<typeof vi.fn> }> {
const { taggedCacheInterceptor, VSCODE_CACHE_STATUS_HEADER, undiciMock } = await importTagger();
undiciMock.interceptors.cache.mockReturnValue(middleware);
const downstream = vi.fn(((_opts: { headers?: Record<string, string> }, _handler: undici.Dispatcher.DispatchHandler) => true) as FakeDispatch);
const tagger = taggedCacheInterceptor({} as Parameters<typeof undici.interceptors.cache>[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<string, string>)[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<string, string>, 'OK');
return true;
};
undiciMock.interceptors.cache.mockReturnValue(middleware);
const tagger = taggedCacheInterceptor({} as Parameters<typeof undici.interceptors.cache>[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<typeof undici.interceptors.cache>[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']);
});
});

View File

@@ -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;

View File

@@ -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()

View File

@@ -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<void>(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<void>(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');
});
});

View File

@@ -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,

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<boolean>('electronFetch', useElectronFetchDefault);