Skip to content

Commit cb24d7a

Browse files
committed
Fix race condition in content exclusion that causes intermittent bypass
Concurrent isIgnored() calls could cache stale 'not ignored' results when a content exclusion rule fetch was in progress. This happened because: 1. After yielding at getRepositoryFetchUrls, a concurrent caller could have already started a fetch and seeded the cache with empty patterns. The current caller would skip the fetch (patterns already seeded, lastRuleFetch just set) and match against empty rules. 2. The glob result cache was only cleared at the start of the fetch, so any 'false' entries written during the fetch window persisted until the next 30-minute refresh. Fix: - Re-check _contentExclusionFetchPromise after the getRepositoryFetchUrls yield point and wait for any in-progress fetch before pattern matching. - Clear both glob and regex result caches at the end of the fetch to invalidate stale entries written by concurrent callers. Fixes github/Copilot-Controls#650
1 parent b5dd5ac commit cb24d7a

2 files changed

Lines changed: 185 additions & 1 deletion

File tree

extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ export class RemoteContentExclusion implements IDisposable {
124124
this._logService.trace(`Fetching content exclusions, due to ${this.shouldFetchContentExclusionRules(repoMetadata) ? 'repository change' : 'stale cache'}.`);
125125
this._lastRuleFetch = Date.now();
126126
await raceCancellationError(this.makeContentExclusionRequest(), token);
127+
} else if (this._contentExclusionFetchPromise) {
128+
// A concurrent caller may have started a fetch while we were awaiting
129+
// getRepositoryFetchUrls above. Wait for it to complete so we match
130+
// against the actual rules instead of the empty seed entries.
131+
await raceCancellationError(this._contentExclusionFetchPromise, token);
127132
}
128133

129134
const minimatchConfig = {
@@ -259,7 +264,9 @@ export class RemoteContentExclusion implements IDisposable {
259264
* Not recommended to call directly and instead use {@link makeContentExclusionRequest} as that ensures only one call is pending at any time
260265
*/
261266
private async _contentExclusionRequest(): Promise<void> {
262-
// Clear the result cache as new rules will come and therefore it is no longer valid
267+
// Clear the result cache as new rules will come and therefore it is no longer valid.
268+
// Note: we clear again at the end of this method to invalidate any stale entries
269+
// written by concurrent isIgnored() calls that ran while the fetch was in flight.
263270
this._ignoreGlobResultCache.clear();
264271
const startTime = Date.now();
265272
const capiClientService = this._capiClientService;
@@ -300,6 +307,10 @@ export class RemoteContentExclusion implements IDisposable {
300307
await updateRulesForRepos(batch);
301308
}
302309
this._lastRuleFetch = Date.now();
310+
// Clear result caches again to invalidate any stale entries written by concurrent
311+
// isIgnored() calls that matched against the empty seed entries during the fetch.
312+
this._ignoreGlobResultCache.clear();
313+
this._ignoreRegexResultCache.clear();
303314
this._logService.info(`Fetched content exclusion rules in ${Date.now() - startTime}ms`);
304315

305316
// Log the fetched rules to the request logger for debugging visibility

extensions/copilot/src/platform/ignore/node/test/remoteContentExclusion.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { beforeEach, describe, expect, suite, test, vi } from 'vitest';
77
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
8+
import { Barrier } from '../../../../util/vs/base/common/async';
89
import { URI } from '../../../../util/vs/base/common/uri';
910
import { IAuthenticationService } from '../../../authentication/common/authentication';
1011
import { ICAPIClientService } from '../../../endpoint/common/capiClient';
@@ -232,4 +233,176 @@ suite('RemoteContentExclusion', () => {
232233
expect(mockGitService.getRepositoryFetchUrlsCallCount).toBe(0);
233234
});
234235
});
236+
237+
describe('concurrent isIgnored calls', () => {
238+
test('concurrent call must not cache false while rules are being fetched', async () => {
239+
// Reproduces the exact race condition from github/Copilot-Controls#650:
240+
//
241+
// Timeline without fix:
242+
// 1. Call A and Call B both enter isIgnored(), both yield at getRepositoryFetchUrls
243+
// 2. Call A resumes first: shouldFetchContentExclusionRules() seeds empty patterns
244+
// in _contentExclusionCache, sets _lastRuleFetch, starts CAPI fetch — yields
245+
// 3. Call B resumes: shouldFetchContentExclusionRules() returns false (already seeded),
246+
// stale-time check passes (_lastRuleFetch just set), skips fetch entirely.
247+
// Matches against EMPTY patterns → caches false → returns false. BUG!
248+
// 4. CAPI fetch completes with real rules, but Call B's stale false persists.
249+
//
250+
// With fix: Call B sees _contentExclusionFetchPromise is set and waits for it.
251+
252+
const repoRoot = '/workspace/my-repo';
253+
254+
// Per-call barriers so we can control exactly when each call's
255+
// getRepositoryFetchUrls resolves.
256+
const gitBarrierA = new Barrier();
257+
const gitBarrierB = new Barrier();
258+
let gitCallIndex = 0;
259+
mockGitService.getRepositoryFetchUrls = vi.fn().mockImplementation(() => {
260+
const barrier = gitCallIndex === 0 ? gitBarrierA : gitBarrierB;
261+
gitCallIndex++;
262+
return barrier.wait().then(() => ({
263+
rootUri: URI.file(repoRoot),
264+
remoteFetchUrls: ['https://github.com/org/repo.git']
265+
}));
266+
});
267+
268+
// CAPI fetch is gated by its own barrier so it doesn't resolve
269+
// until we explicitly release it — after Call B has had a chance
270+
// to reach the pattern matching code.
271+
const capiBarrier = new Barrier();
272+
mockCAPIClientService.setMockResponse({
273+
ok: true,
274+
json: () => capiBarrier.wait().then(() => [{
275+
rules: [{ paths: ['**/keyword/**'], source: { name: 'org', type: 'Organization' } }],
276+
last_updated_at: Date.now()
277+
}]),
278+
} as any);
279+
280+
const fileA = URI.file('/workspace/my-repo/keyword/keyword.py');
281+
const fileB = URI.file('/workspace/my-repo/keyword/extra.py');
282+
283+
// Start both calls — both will block at getRepositoryFetchUrls
284+
const resultA = remoteContentExclusion.isIgnored(fileA, CancellationToken.None);
285+
const resultB = remoteContentExclusion.isIgnored(fileB, CancellationToken.None);
286+
287+
// Step 2: Let Call A resume first. It will call shouldFetchContentExclusionRules()
288+
// (seeding empty patterns), then start the CAPI fetch which blocks on capiBarrier.
289+
gitBarrierA.open();
290+
// Flush microtasks so Call A progresses through shouldFetchContentExclusionRules
291+
// and into makeContentExclusionRequest before Call B gets to run.
292+
await flushMicrotasks();
293+
294+
// Step 3: Now let Call B resume. Without the fix, it would skip the
295+
// fetch and match against empty patterns. With the fix, it sees the
296+
// in-progress _contentExclusionFetchPromise and waits.
297+
gitBarrierB.open();
298+
await flushMicrotasks();
299+
300+
// Step 4: Release the CAPI response so real rules load.
301+
capiBarrier.open();
302+
303+
// Both files are inside keyword/ and must be excluded.
304+
expect(await resultA).toBe(true);
305+
expect(await resultB).toBe(true);
306+
});
307+
308+
test('post-fetch cache clear invalidates stale entries from concurrent callers', async () => {
309+
// Tests the second part of the fix: clearing _ignoreGlobResultCache
310+
// at the end of _contentExclusionRequest().
311+
//
312+
// If a concurrent call somehow wrote a false entry during the fetch,
313+
// the post-fetch clear ensures the very next call re-evaluates against
314+
// the real rules instead of returning the stale cached false.
315+
316+
const repoRoot = '/workspace/my-repo';
317+
318+
// Call A resolves immediately, Call B is gated by a barrier.
319+
const gitBarrierB = new Barrier();
320+
let gitCallIndex = 0;
321+
mockGitService.getRepositoryFetchUrls = vi.fn().mockImplementation(() => {
322+
const immediate = gitCallIndex === 0;
323+
gitCallIndex++;
324+
if (immediate) {
325+
return Promise.resolve({
326+
rootUri: URI.file(repoRoot),
327+
remoteFetchUrls: ['https://github.com/org/repo.git']
328+
});
329+
}
330+
return gitBarrierB.wait().then(() => ({
331+
rootUri: URI.file(repoRoot),
332+
remoteFetchUrls: ['https://github.com/org/repo.git']
333+
}));
334+
});
335+
336+
// CAPI responds with rules after a barrier
337+
const capiBarrier = new Barrier();
338+
let capiCallCount = 0;
339+
mockCAPIClientService.setMockResponse({
340+
ok: true,
341+
json: () => {
342+
capiCallCount++;
343+
return capiBarrier.wait().then(() => [{
344+
rules: [{ paths: ['**/keyword/**'], source: { name: 'org', type: 'Organization' } }],
345+
last_updated_at: Date.now()
346+
}]);
347+
},
348+
} as any);
349+
350+
const fileA = URI.file('/workspace/my-repo/keyword/keyword.py');
351+
const fileB = URI.file('/workspace/my-repo/keyword/extra.py');
352+
const fileC = URI.file('/workspace/my-repo/keyword/third.py');
353+
354+
// Call A starts — its git resolves immediately, triggers CAPI fetch, blocks on capiBarrier
355+
const resultA = remoteContentExclusion.isIgnored(fileA, CancellationToken.None);
356+
await flushMicrotasks();
357+
358+
// Call B starts — blocks at gitBarrierB
359+
const resultB = remoteContentExclusion.isIgnored(fileB, CancellationToken.None);
360+
361+
// Release Call B's git barrier. It will now enter the shouldFetch/else-if
362+
// path. With the fix, it waits on the CAPI fetch.
363+
gitBarrierB.open();
364+
await flushMicrotasks();
365+
366+
// Release CAPI — rules load, post-fetch cache clear runs
367+
capiBarrier.open();
368+
369+
expect(await resultA).toBe(true);
370+
expect(await resultB).toBe(true);
371+
372+
// A third sequential call should also correctly exclude (post-fetch
373+
// cache clear wiped any stale entries, so this re-evaluates with real rules)
374+
const resultC = await remoteContentExclusion.isIgnored(fileC, CancellationToken.None);
375+
expect(resultC).toBe(true);
376+
});
377+
378+
test('should correctly exclude files when rules arrive after concurrent calls start', async () => {
379+
// Similar to above but with the non-git-file path (no repository)
380+
mockGitService.setRepositoryFetchUrls(undefined);
381+
382+
// Use a barrier to control when the CAPI request resolves
383+
const capiBarrier = new Barrier();
384+
mockCAPIClientService.setMockResponse({
385+
ok: true,
386+
json: () => capiBarrier.wait().then(() => [{
387+
rules: [{ paths: ['**/secret/**'], source: { name: 'org', type: 'Organization' } }],
388+
last_updated_at: Date.now()
389+
}]),
390+
} as any);
391+
392+
const secretFile = URI.file('/project/secret/config.py');
393+
394+
// Start isIgnored — it will trigger a fetch that blocks on the capiBarrier
395+
const resultPromise = remoteContentExclusion.isIgnored(secretFile, CancellationToken.None);
396+
397+
// Release CAPI response so rules load
398+
capiBarrier.open();
399+
400+
expect(await resultPromise).toBe(true);
401+
});
402+
});
235403
});
404+
405+
/** Flush pending microtasks by yielding to the event loop. */
406+
function flushMicrotasks(): Promise<void> {
407+
return new Promise(resolve => setTimeout(resolve, 0));
408+
}

0 commit comments

Comments
 (0)