|
5 | 5 |
|
6 | 6 | import { beforeEach, describe, expect, suite, test, vi } from 'vitest'; |
7 | 7 | import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; |
| 8 | +import { Barrier } from '../../../../util/vs/base/common/async'; |
8 | 9 | import { URI } from '../../../../util/vs/base/common/uri'; |
9 | 10 | import { IAuthenticationService } from '../../../authentication/common/authentication'; |
10 | 11 | import { ICAPIClientService } from '../../../endpoint/common/capiClient'; |
@@ -232,4 +233,176 @@ suite('RemoteContentExclusion', () => { |
232 | 233 | expect(mockGitService.getRepositoryFetchUrlsCallCount).toBe(0); |
233 | 234 | }); |
234 | 235 | }); |
| 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 | + }); |
235 | 403 | }); |
| 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