Skip to content

Commit 1ec8a30

Browse files
committed
Add tests and feedback update
1 parent 9c3ba29 commit 1ec8a30

5 files changed

Lines changed: 298 additions & 17 deletions

File tree

extensions/copilot/src/platform/telemetry/node/baseExperimentationService.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,49 @@ export class UserInfoStore extends Disposable {
116116
}
117117
}
118118

119-
export type TASClientDelegateFn = (globalState: vscode.Memento, userInfoStore: UserInfoStore) => ITASExperimentationService;
119+
/**
120+
* A one-way switch used to neutralize a superseded delegate. `tas-client`'s `dispose()` only
121+
* stops polling; an already in-flight fetch can still complete and write shared state. Revoking
122+
* the gate makes that delegate's storage/telemetry writes no-ops so it cannot overwrite the
123+
* memento (`VSCode.ABExp.FeatureData`) or `abexp.assignmentcontext` after being replaced.
124+
*/
125+
export class RevocationGate {
126+
private _revoked = false;
127+
get isRevoked(): boolean {
128+
return this._revoked;
129+
}
130+
revoke(): void {
131+
this._revoked = true;
132+
}
133+
}
134+
135+
/** Wraps a memento so writes are dropped once the gate is revoked (reads still pass through). */
136+
class RevocableMemento implements vscode.Memento {
137+
constructor(
138+
private readonly _actual: vscode.Memento,
139+
private readonly _gate: RevocationGate,
140+
) { }
141+
142+
keys(): readonly string[] {
143+
return this._actual.keys();
144+
}
145+
146+
get<T>(key: string): T | undefined;
147+
get<T>(key: string, defaultValue: T): T;
148+
get<T>(key: string, defaultValue?: T): T | undefined {
149+
return defaultValue === undefined ? this._actual.get<T>(key) : this._actual.get<T>(key, defaultValue);
150+
}
151+
152+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
153+
update(key: string, value: any): Thenable<void> {
154+
if (this._gate.isRevoked) {
155+
return Promise.resolve();
156+
}
157+
return this._actual.update(key, value);
158+
}
159+
}
160+
161+
export type TASClientDelegateFn = (memento: vscode.Memento, userInfoStore: UserInfoStore, gate: RevocationGate) => ITASExperimentationService;
120162

121163
export class BaseExperimentationService extends Disposable implements IExperimentationService {
122164

@@ -169,8 +211,16 @@ export class BaseExperimentationService extends Disposable implements IExperimen
169211

170212
private _createDelegate(): ITASExperimentationService {
171213
const generation = ++this._delegateGeneration;
172-
const delegate = this._delegateFn(this._globalState, this._userInfoStore);
173-
this._delegateDisposable.value = toDisposable(() => delegate.dispose());
214+
const gate = new RevocationGate();
215+
const memento = new RevocableMemento(this._globalState, gate);
216+
const delegate = this._delegateFn(memento, this._userInfoStore, gate);
217+
// Revoke this generation's storage/telemetry writes and stop its polling when it is
218+
// superseded (a newer delegate is assigned) or the service is disposed, so a still
219+
// in-flight fetch cannot overwrite the shared memento / assignment context afterwards.
220+
this._delegateDisposable.value = toDisposable(() => {
221+
gate.revoke();
222+
delegate.dispose();
223+
});
174224
delegate.initialFetch.then(() => {
175225
if (generation !== this._delegateGeneration || this._store.isDisposed) {
176226
return; // superseded by a newer delegate, or the service was disposed

extensions/copilot/src/platform/telemetry/test/node/experimentation.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
7+
import type * as vscode from 'vscode';
78
import { IExperimentationService as ITASExperimentationService } from 'vscode-tas-client';
9+
import { mock } from '../../../../util/common/test/simpleMock';
10+
import { Event } from '../../../../util/vs/base/common/event';
811
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
912
import { CopilotToken, createTestExtendedTokenInfo } from '../../../authentication/common/copilotToken';
1013
import { ICopilotTokenStore } from '../../../authentication/common/copilotTokenStore';
1114
import { IConfigurationService } from '../../../configuration/common/configurationService';
1215
import { IVSCodeExtensionContext } from '../../../extContext/common/extensionContext';
1316
import { ILogService } from '../../../log/common/logService';
17+
import { FetchOptions, HeadersImpl, IFetcherService, Response } from '../../../networking/common/fetcherService';
1418
import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services';
1519
import { TreatmentsChangeEvent } from '../../common/nullExperimentationService';
16-
import { BaseExperimentationService, TASClientDelegateFn, UserInfoStore } from '../../node/baseExperimentationService';
20+
import { createTasFetch } from '../../vscode-node/tasFetch';
21+
import { BaseExperimentationService, RevocationGate, TASClientDelegateFn, UserInfoStore } from '../../node/baseExperimentationService';
1722

1823

1924
function toExpectedTreatment(name: string, org: string | undefined, sku: string | undefined): string | undefined {
@@ -45,6 +50,31 @@ class TestExperimentationService extends BaseExperimentationService {
4550
}
4651
}
4752

53+
/** Captures the memento + revocation gate handed to each delegate generation. */
54+
class RevocationTestExperimentationService extends BaseExperimentationService {
55+
public readonly captured: { memento: vscode.Memento; gate: RevocationGate }[];
56+
57+
constructor(
58+
@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext,
59+
@ICopilotTokenStore tokenStore: ICopilotTokenStore,
60+
@IConfigurationService configurationService: IConfigurationService,
61+
@ILogService logService: ILogService
62+
) {
63+
const captured: { memento: vscode.Memento; gate: RevocationGate }[] = [];
64+
const delegateFn: TASClientDelegateFn = (memento, userInfoStore, gate) => {
65+
captured.push({ memento, gate });
66+
return new MockTASExperimentationService(userInfoStore);
67+
};
68+
69+
super(delegateFn, extensionContext, tokenStore, configurationService, logService);
70+
this.captured = captured;
71+
}
72+
73+
recreate(): void {
74+
this.recreateDelegate();
75+
}
76+
}
77+
4878
class MockTASExperimentationService implements ITASExperimentationService {
4979
private _initializePromise: Promise<void> | undefined;
5080
private _initialFetch: Promise<void> | undefined;
@@ -169,6 +199,27 @@ describe('ExP Service Tests', () => {
169199
});
170200
};
171201

202+
it('revokes a superseded delegate so its late writes are dropped', async () => {
203+
const svc = accessor.get(IInstantiationService).createInstance(RevocationTestExperimentationService);
204+
const globalState = accessor.get(IVSCodeExtensionContext).globalState;
205+
206+
expect(svc.captured.length).toBe(1);
207+
svc.recreate();
208+
expect(svc.captured.length).toBe(2);
209+
210+
const [gen1, gen2] = svc.captured;
211+
expect(gen1.gate.isRevoked).toBe(true);
212+
expect(gen2.gate.isRevoked).toBe(false);
213+
214+
// A superseded (revoked) generation's writes are dropped; the current one's land.
215+
await gen1.memento.update('exp.revoke.test', 'stale');
216+
expect(globalState.get('exp.revoke.test')).toBeUndefined();
217+
await gen2.memento.update('exp.revoke.test', 'fresh');
218+
expect(globalState.get('exp.revoke.test')).toBe('fresh');
219+
220+
svc.dispose();
221+
});
222+
172223
it('should return treatments based on copilot token', async () => {
173224
await expService.hasTreatments();
174225
let expectedTreatment = toExpectedTreatment('a', undefined, undefined);
@@ -774,3 +825,39 @@ describe('ExP Service delegate recreation', () => {
774825
service.dispose();
775826
});
776827
});
828+
829+
/**
830+
* Records every request routed through the fetcher service so a test can assert that both TAS
831+
* endpoints go through it (proxy-aware transport) with the expected method and call site.
832+
*/
833+
class RecordingFetcherService extends mock<IFetcherService>() {
834+
public readonly calls: { url: string; method: string; callSite: string; body?: string }[] = [];
835+
836+
override readonly onDidFetch = Event.None;
837+
override readonly onDidCompleteFetch = Event.None;
838+
839+
override getUserAgentLibrary(): string {
840+
return 'test-fetcher';
841+
}
842+
843+
override fetch(url: string, options: FetchOptions): Promise<Response> {
844+
this.calls.push({ url, method: options.method ?? 'GET', callSite: options.callSite, body: options.body });
845+
return Promise.resolve(Response.fromText(200, 'OK', new HeadersImpl({}), '{}', 'test-stub'));
846+
}
847+
}
848+
849+
describe('TAS proxy transport adapter', () => {
850+
851+
it('routes both the legacy GET and the assignments POST through the fetcher service with the expected call sites', async () => {
852+
const fetcher = new RecordingFetcherService();
853+
const tasFetch = createTasFetch(fetcher);
854+
855+
await tasFetch('https://default.exp-tas.com/vscode/ab', { method: 'GET', headers: { 'X-Legacy': '1' } });
856+
await tasFetch('https://exp.example.test/vscode/api/v1/assignments', { method: 'POST', headers: { 'X-New': '1' }, body: '{"parameters":{}}' });
857+
858+
expect(fetcher.calls).toEqual([
859+
{ url: 'https://default.exp-tas.com/vscode/ab', method: 'GET', callSite: 'exp.legacy', body: undefined },
860+
{ url: 'https://exp.example.test/vscode/api/v1/assignments', method: 'POST', callSite: 'exp.assignments', body: '{"parameters":{}}' },
861+
]);
862+
});
863+
});

extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import { IVSCodeExtensionContext } from '../../extContext/common/extensionContex
1616
import { ILogService } from '../../log/common/logService';
1717
import { IFetcherService } from '../../networking/common/fetcherService';
1818
import { FetcherService } from '../../networking/vscode-node/fetcherServiceImpl';
19-
import { ITelemetryService } from '../common/telemetry';
20-
import { BaseExperimentationService, UserInfoStore } from '../node/baseExperimentationService';
19+
import { IExperimentationTelemetry, ITelemetryService } from '../common/telemetry';
20+
import { BaseExperimentationService, RevocationGate, UserInfoStore } from '../node/baseExperimentationService';
21+
import { createTasFetch } from './tasFetch';
2122

2223
function getTargetPopulation(isPreRelease: boolean): TargetPopulation {
2324
if (isPreRelease) {
@@ -277,24 +278,20 @@ export class MicrosoftExperimentationService extends BaseExperimentationService
277278
const version = context.extension.packageJSON['version'];
278279
const targetPopulation = getTargetPopulation(envService.isPreRelease());
279280
let self: MicrosoftExperimentationService | undefined = undefined;
280-
const delegateFn = (globalState: vscode.Memento, userInfoStore: UserInfoStore) => {
281-
const wrappedMemento = new ExpMementoWrapper(globalState, envService);
281+
const delegateFn = (memento: vscode.Memento, userInfoStore: UserInfoStore, gate: RevocationGate) => {
282+
const wrappedMemento = new ExpMementoWrapper(memento, envService);
282283
const exp = copilotTokenStore.copilotToken?.endpoints?.exp;
283284
const assignmentsEndpoint = exp ? `${exp.replace(/\/+$/, '')}/api/v1/assignments` : undefined;
284285
// Route both the legacy (GET) and assignments (POST) requests through the extension's
285286
// fetcher service so they get proxy handling, retries/fallback, and the standard user-agent.
286-
const tasFetch = (url: string, init: { method: 'GET' | 'POST'; headers: Record<string, string>; body?: string }) =>
287-
fetcherService.fetch(url, {
288-
method: init.method,
289-
headers: init.headers,
290-
body: init.body,
291-
callSite: init.method === 'POST' ? 'exp.assignments' : 'exp.legacy',
292-
});
287+
const tasFetch = createTasFetch(fetcherService);
293288
return getExperimentationServiceFromConfig({
294289
extensionName: id,
295290
extensionVersion: version,
296291
targetPopulation,
297-
telemetry: telemetryService,
292+
// Wrapped per generation so a superseded delegate's in-flight fetch cannot write
293+
// telemetry (e.g. overwrite `abexp.assignmentcontext`) after being replaced.
294+
telemetry: new RevocableExpTelemetry(telemetryService, gate),
298295
memento: wrappedMemento,
299296
filterProviders: [
300297
new GithubAccountFilterProvider(userInfoStore, logService),
@@ -334,6 +331,32 @@ export class MicrosoftExperimentationService extends BaseExperimentationService
334331
}
335332
}
336333

334+
/**
335+
* Wraps the telemetry service so a superseded delegate's writes are dropped once its generation
336+
* gate is revoked, preventing a stale in-flight fetch from overwriting shared telemetry
337+
* properties (notably `abexp.assignmentcontext`) after a newer delegate has replaced it.
338+
*/
339+
class RevocableExpTelemetry implements IExperimentationTelemetry {
340+
constructor(
341+
private readonly _actual: IExperimentationTelemetry,
342+
private readonly _gate: RevocationGate,
343+
) { }
344+
345+
setSharedProperty(name: string, value: string): void {
346+
if (this._gate.isRevoked) {
347+
return;
348+
}
349+
this._actual.setSharedProperty(name, value);
350+
}
351+
352+
postEvent(eventName: string, props: Map<string, string>): void {
353+
if (this._gate.isRevoked) {
354+
return;
355+
}
356+
this._actual.postEvent(eventName, props);
357+
}
358+
}
359+
337360
class ExpMementoWrapper implements vscode.Memento {
338361

339362
constructor(
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IFetcherService } from '../../networking/common/fetcherService';
7+
8+
/**
9+
* Builds the transport that TAS uses for both the legacy (GET) and the assignments (POST)
10+
* endpoints, routing every request through the extension's fetcher service so they get proxy
11+
* handling, retries/fallback, and the standard user-agent. Wiring both endpoints to this single
12+
* adapter is what keeps the assignments call from silently bypassing proxy handling; the method
13+
* determines the call site used for fetch telemetry.
14+
*/
15+
export function createTasFetch(fetcherService: IFetcherService) {
16+
return (url: string, init: { method: 'GET' | 'POST'; headers: Record<string, string>; body?: string }) =>
17+
fetcherService.fetch(url, {
18+
method: init.method,
19+
headers: init.headers,
20+
body: init.body,
21+
callSite: init.method === 'POST' ? 'exp.assignments' : 'exp.legacy',
22+
});
23+
}

0 commit comments

Comments
 (0)