diff --git a/src/features/views/workspacePythonContext.ts b/src/features/views/workspacePythonContext.ts index 4a5cb76cb..9d83a9c4b 100644 --- a/src/features/views/workspacePythonContext.ts +++ b/src/features/views/workspacePythonContext.ts @@ -1,6 +1,9 @@ -import { Disposable, Uri } from 'vscode'; +import { CancellationTokenSource, Disposable, FileType, Uri } from 'vscode'; import { executeCommand } from '../../common/command.api'; import { traceError } from '../../common/logging'; +import { createSimpleDebounce } from '../../common/utils/debounce'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; +import { stat } from '../../common/workspace.fs.apis'; import { createFileSystemWatcher, findFiles, @@ -11,6 +14,9 @@ import { export const PYTHON_WORKSPACE_KEY = 'python-envs.workspaceHasPython'; +/** How long deletes and workspace folder changes must settle before the workspace is scanned again. */ +export const RESCAN_DEBOUNCE_MS = 500; + /** * Files that identify a workspace as a Python one. * @@ -27,53 +33,262 @@ const EXCLUDE = '**/{node_modules,.git,site-packages}/**'; // `createFileSystemWatcher` has no exclude option, so the same folders are filtered in the handlers. const EXCLUDE_RE = /[\\/](node_modules|\.git|site-packages)[\\/]/; -async function hasPythonContent(): Promise { - const markers = await findFiles(MARKER_GLOB, EXCLUDE, 1); - if (markers.length > 0) { - return true; - } - // `findFiles` only searches workspace folders, so also cover a standalone Python file opened without a folder. +// `findFiles` only searches workspace folders, so also cover a standalone Python file opened without a folder. +function hasOpenPythonDocument(): boolean { return getOpenTextDocuments().some((doc) => doc.languageId === 'python'); } +/** Whether deleting `deleted` also removes `uri`, because it is the same file or one of its parent folders. */ +function isRemovedBy(uri: string, deleted: string): boolean { + return uri === deleted || uri.startsWith(`${deleted}/`); +} + /** * Keeps the `python-envs.workspaceHasPython` context key up to date. It controls whether the Python * activity bar icon and its views are shown. + * + * Every `findFiles` call is a full workspace search, and a branch switch can create or delete + * thousands of marker files at once, so events never scan directly: + * - a created marker that is a file, or an opened Python document, proves the answer is `true`; + * - one marker known to exist is remembered, so deleting any other marker only re-checks that it still exists; + * - deleting that marker, or a folder containing it, and workspace folder changes share one debounced rescan; + * - only one scan runs at a time, and it is rerun if the marker it found was deleted during the search. + * + * Scans are not timed out: creates and opened Python documents override a slow scan, and workspace + * folder changes cancel it. + * + * The watcher does not report markers removed with a folder whose name does not match the glob, so + * the context can stay `true` until a later marker delete re-checks the remembered marker. */ export function registerWorkspacePythonContext(disposables: Disposable[]): void { - // Every refresh takes a ticket and only the newest one may write the context key, so a slow older - // scan can never overwrite the result of a newer one. - let latestTicket = 0; + let hasPython: boolean | undefined; + // The value VS Code last accepted, and whether an update is in flight. + let published: boolean | undefined; + let publishing = false; + // A marker file known to exist. While it does, the answer stays `true`. + let witness: string | undefined; + let running: CancellationTokenSource | undefined; + // Markers deleted during the running scan, which may have returned one of them. + const deletedDuringScan = new Set(); + // Set when a rescan was requested while a scan was running. + let rescanAfterRunning = false; + // `SimpleDebounce` has no `cancel()`, so a pending rescan is dropped by clearing this flag. + let rescanPending = false; + let disposed = false; + let nextCheckId = 0; + // Created markers whose `stat` is in flight, so a later delete or folder change can invalidate them. + const pendingChecks = new Map(); + + // Sends one update at a time, always with the latest value, so a slow update cannot overwrite a newer one. + const publish = async (): Promise => { + publishing = true; + while (!disposed && hasPython !== undefined && hasPython !== published) { + const value = hasPython; + try { + await executeCommand('setContext', PYTHON_WORKSPACE_KEY, value); + published = value; + } catch (ex) { + traceError('Failed to update the Python workspace context', ex); + // Let the next update retry. + if (hasPython === value) { + hasPython = undefined; + } + } + } + publishing = false; + }; + + const setHasPython = (value: boolean): void => { + if (value === hasPython) { + return; + } + hasPython = value; + if (!publishing) { + void publish(); + } + }; + + const debouncedRescan = createSimpleDebounce(RESCAN_DEBOUNCE_MS, () => { + if (rescanPending) { + rescanPending = false; + void scan(); + } + }); + + const scheduleRescan = (): void => { + if (!disposed) { + rescanPending = true; + debouncedRescan.trigger(); + } + }; + + // The watcher misses some deletes, such as markers removed with their folder, so the witness is re-checked. + const verifyWitness = async (): Promise => { + const candidate = witness; + if (disposed || candidate === undefined) { + return; + } + let exists = false; + try { + exists = ((await stat(Uri.parse(candidate))).type & FileType.File) === FileType.File; + } catch (ex) { + if (!isFileNotFoundError(ex)) { + traceError('Failed to inspect the found Python marker', ex); + } + // Treated as missing; the rescan finds the marker if it still exists. + } + if (!exists && !disposed && witness === candidate) { + witness = undefined; + scheduleRescan(); + } + }; + + const debouncedVerifyWitness = createSimpleDebounce(RESCAN_DEBOUNCE_MS, () => void verifyWitness()); - const refresh = async (): Promise => { - const ticket = ++latestTicket; + const scan = async (): Promise => { + if (disposed) { + return; + } + if (running) { + rescanAfterRunning = true; + return; + } + const cts = new CancellationTokenSource(); + running = cts; + // The scan finds a new witness; until then, deletes are checked against its result instead. + witness = undefined; + let result: boolean | undefined; try { - const hasPython = await hasPythonContent(); - if (ticket === latestTicket) { - await executeCommand('setContext', PYTHON_WORKSPACE_KEY, hasPython); + const [found] = await findFiles(MARKER_GLOB, EXCLUDE, 1, cts.token); + if (!cts.token.isCancellationRequested) { + const key = found?.toString(); + if (key !== undefined && [...deletedDuringScan].some((deleted) => isRemovedBy(key, deleted))) { + rescanAfterRunning = true; + } else { + witness = key; + result = key !== undefined || hasOpenPythonDocument(); + } } } catch (ex) { - traceError('Failed to update the Python workspace context', ex); + if (!cts.token.isCancellationRequested) { + traceError('Failed to search the workspace for Python files', ex); + } + } finally { + cts.dispose(); + running = undefined; + deletedDuringScan.clear(); + } + if (result !== undefined) { + setHasPython(result); + } + if (rescanAfterRunning) { + rescanAfterRunning = false; + scheduleRescan(); + } + }; + + // Overrides any running or pending scan, which could only report a stale answer. + const markHasPython = (): void => { + if (disposed) { + return; + } + running?.cancel(); + rescanPending = false; + rescanAfterRunning = false; + setHasPython(true); + }; + + const onMarkerCreated = (uri: Uri): void => { + if (disposed || EXCLUDE_RE.test(uri.fsPath)) { + return; + } + // While the answer is settled as `true`, a new marker cannot change it. + if (hasPython === true && !running && !rescanPending) { + return; + } + const key = uri.toString(); + if (pendingChecks.has(key)) { + return; } + const checkId = ++nextCheckId; + pendingChecks.set(key, checkId); + // The watcher also reports directories that match the glob, such as a `tools.py` folder. + stat(uri).then( + (fileStat) => { + if (pendingChecks.get(key) !== checkId) { + return; + } + pendingChecks.delete(key); + if ((fileStat.type & FileType.File) === FileType.File) { + // Keep an existing witness, which is less likely to be a short-lived file. + witness ??= key; + markHasPython(); + } + }, + (ex) => { + if (pendingChecks.get(key) !== checkId) { + return; + } + pendingChecks.delete(key); + // A short-lived file, such as a temporary one, cannot change the answer. + if (isFileNotFoundError(ex)) { + return; + } + traceError('Failed to inspect a created Python marker', ex); + // A search still finds the marker if it is a file. + scheduleRescan(); + }, + ); }; - const onMarkerChanged = (uri: Uri): void => { - if (!EXCLUDE_RE.test(uri.fsPath)) { - void refresh(); + const onMarkerDeleted = (uri: Uri): void => { + if (disposed || EXCLUDE_RE.test(uri.fsPath)) { + return; + } + const key = uri.toString(); + pendingChecks.delete(key); + // While the witness exists, deleting another marker cannot change the answer, so only check that it does. + if (witness !== undefined && !isRemovedBy(witness, key)) { + debouncedVerifyWitness.trigger(); + return; + } + witness = undefined; + if (running && !running.token.isCancellationRequested) { + // The running scan checks its result against this. + deletedDuringScan.add(key); + } else if (hasPython !== false) { + scheduleRescan(); } }; const watcher = createFileSystemWatcher(MARKER_GLOB, false, true, false); disposables.push( watcher, - watcher.onDidCreate(onMarkerChanged), - watcher.onDidDelete(onMarkerChanged), - onDidChangeWorkspaceFolders(() => void refresh()), + watcher.onDidCreate(onMarkerCreated), + watcher.onDidDelete(onMarkerDeleted), + onDidChangeWorkspaceFolders(() => { + // The witness, pending checks and running scan may belong to a removed folder. + witness = undefined; + pendingChecks.clear(); + rescanAfterRunning = false; + running?.cancel(); + scheduleRescan(); + }), onDidOpenTextDocument((doc) => { if (doc.languageId === 'python') { - void refresh(); + markHasPython(); } }), + new Disposable(() => { + disposed = true; + pendingChecks.clear(); + debouncedRescan.dispose(); + debouncedVerifyWitness.dispose(); + running?.cancel(); + }), ); - void refresh(); -} \ No newline at end of file + if (hasOpenPythonDocument()) { + markHasPython(); + } + void scan(); +} diff --git a/src/test/features/views/workspacePythonContext.unit.test.ts b/src/test/features/views/workspacePythonContext.unit.test.ts index d20c6cd8b..71eb06b66 100644 --- a/src/test/features/views/workspacePythonContext.unit.test.ts +++ b/src/test/features/views/workspacePythonContext.unit.test.ts @@ -1,13 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { FileType, Uri } from 'vscode'; import * as commandApis from '../../../common/command.api'; import * as logging from '../../../common/logging'; import * as workspaceApis from '../../../common/workspace.apis'; +import * as workspaceFs from '../../../common/workspace.fs.apis'; import { MARKER_GLOB, PYTHON_WORKSPACE_KEY, registerWorkspacePythonContext, + RESCAN_DEBOUNCE_MS, } from '../../../features/views/workspacePythonContext'; type Listener = (e: T) => void; @@ -23,13 +26,15 @@ function flush(): Promise { return new Promise((resolve) => setImmediate(resolve)); } -const marker = (fsPath: string) => ({ fsPath }) as any; +const marker = (fsPath: string) => Uri.file(fsPath); suite('Workspace Python context', () => { + let clock: sinon.SinonFakeTimers; let findFilesStub: sinon.SinonStub; let openDocsStub: sinon.SinonStub; let executeCommandStub: sinon.SinonStub; let traceErrorStub: sinon.SinonStub; + let statStub: sinon.SinonStub; let watcher: { dispose: sinon.SinonStub; onDidCreate: sinon.SinonStub; onDidDelete: sinon.SinonStub }; let foldersStub: sinon.SinonStub; let openStub: sinon.SinonStub; @@ -45,12 +50,28 @@ suite('Workspace Python context', () => { assert.strictEqual(c.args[1], PYTHON_WORKSPACE_KEY); return c.args[2]; }); + /** Runs the debounced rescan, then lets the scan it starts settle. */ + const settle = async () => { + await clock.tickAsync(RESCAN_DEBOUNCE_MS); + await flush(); + }; + const tokenOfScan = (index: number) => findFilesStub.getCall(index).args[3]; setup(() => { + // Only timers are faked, so `flush` (setImmediate) still lets promises settle. + clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); findFilesStub = sinon.stub(workspaceApis, 'findFiles').resolves([]); openDocsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); executeCommandStub = sinon.stub(commandApis, 'executeCommand').resolves(undefined); traceErrorStub = sinon.stub(logging, 'traceError'); + statStub = sinon.stub(workspaceFs, 'stat').callsFake((uri: any) => + Promise.resolve({ + type: uri.fsPath.endsWith('tools.py') ? FileType.Directory : FileType.File, + ctime: 0, + mtime: 0, + size: 0, + }), + ); const disposable = { dispose: sinon.stub() }; watcher = { @@ -65,6 +86,7 @@ suite('Workspace Python context', () => { }); teardown(() => { + disposables.forEach((d) => d.dispose()); sinon.restore(); }); @@ -89,20 +111,35 @@ suite('Workspace Python context', () => { assert.deepStrictEqual(contextValues(), [true]); }); + test('publishes the initial search despite deletes of other markers', async () => { + const pending = makeDeferred(); + findFilesStub.onFirstCall().returns(pending.promise); + registerWorkspacePythonContext(disposables); + for (let i = 0; i < 10; i++) { + fireDelete(`/ws/tmp${i}.py`); + await clock.tickAsync(RESCAN_DEBOUNCE_MS); + } + pending.resolve([marker('/ws/main.py')]); + await settle(); + assert.deepStrictEqual(contextValues(), [true]); + assert.strictEqual(findFilesStub.callCount, 1); + }); + test('searches for a single hit, excluding dependency folders', async () => { registerWorkspacePythonContext(disposables); await flush(); - const [include, exclude, maxResults] = findFilesStub.firstCall.args; + const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; assert.strictEqual(include, MARKER_GLOB); assert.ok(String(exclude).includes('site-packages')); assert.ok(String(exclude).includes('node_modules')); assert.strictEqual(maxResults, 1); + assert.ok(token, 'the scan should be cancellable'); }); test('registers its watcher and listeners for disposal', () => { registerWorkspacePythonContext(disposables); assert.ok(disposables.includes(watcher)); - assert.strictEqual(disposables.length, 5); + assert.strictEqual(disposables.length, 6); }); }); @@ -120,49 +157,416 @@ suite('Workspace Python context', () => { }); suite('transitions', () => { - test('creating the first marker turns the context on', async () => { + test('creating the first marker turns the context on without a rescan', async () => { registerWorkspacePythonContext(disposables); await flush(); - findFilesStub.resolves([marker('/ws/app/main.py')]); fireCreate('/ws/app/main.py'); - await flush(); + await settle(); assert.deepStrictEqual(contextValues(), [false, true]); + assert.strictEqual(findFilesStub.callCount, 1); + }); + + test('creating a marker-named directory does not turn the context on', async () => { + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/tools.py'); + await flush(); + assert.deepStrictEqual(contextValues(), [false]); + assert.strictEqual(findFilesStub.callCount, 1); + }); + + test('a delete after create cancels an unconfirmed file event', async () => { + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/main.py'); + fireDelete('/ws/main.py'); + await flush(); + assert.deepStrictEqual(contextValues(), [false]); }); - test('deleting the last marker turns the context off', async () => { + test('deleting the last marker turns the context off after the debounce', async () => { findFilesStub.resolves([marker('/ws/main.py')]); registerWorkspacePythonContext(disposables); await flush(); findFilesStub.resolves([]); fireDelete('/ws/main.py'); await flush(); + assert.strictEqual(findFilesStub.callCount, 1, 'the rescan should wait for the debounce'); + await settle(); assert.deepStrictEqual(contextValues(), [true, false]); }); + test('deleting other markers does not search while the found marker remains', async () => { + findFilesStub.resolves([marker('/ws/main.pyi')]); + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 5; i++) { + fireCreate(`/ws/tmp${i}.py`); + await settle(); + fireDelete(`/ws/tmp${i}.py`); + await settle(); + } + fireDelete('/ws/main.py'); // a prefix of the found marker's path, but neither it nor a parent folder + await settle(); + assert.strictEqual(findFilesStub.callCount, 1); + const inspected = new Set(statStub.getCalls().map((c) => c.args[0].toString())); + assert.deepStrictEqual( + [...inspected], + [marker('/ws/main.pyi').toString()], + 'only the found marker is re-checked', + ); + assert.deepStrictEqual(contextValues(), [true]); + }); + + test('a found marker removed without a delete event is caught by a later delete', async () => { + findFilesStub.resolves([marker('/ws/scripts/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + // Deleting `scripts` is not reported, because the folder name does not match the glob. + statStub.rejects(Object.assign(new Error('gone'), { code: 'FileNotFound' })); + findFilesStub.resolves([]); + fireDelete('/ws/other.py'); + await settle(); // re-checks the found marker + await settle(); // rescans + assert.strictEqual(findFilesStub.callCount, 2); + assert.strictEqual(traceErrorStub.callCount, 0); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('deleting the folder containing the found marker turns the context off', async () => { + findFilesStub.resolves([marker('/ws/tools.py/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + findFilesStub.resolves([]); + fireDelete('/ws/tools.py'); + await settle(); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('a delete only invalidates the pending check for the same URI', async () => { + const pending = makeDeferred(); + statStub.returns(pending.promise); + const created = marker('/ws/main.py').with({ scheme: 'vscode-remote', authority: 'first' }); + registerWorkspacePythonContext(disposables); + await flush(); + (watcher.onDidCreate.firstCall.args[0] as Listener)(created); + (watcher.onDidDelete.firstCall.args[0] as Listener)(created.with({ authority: 'second' })); + pending.resolve({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); + await flush(); + assert.deepStrictEqual(contextValues(), [false, true]); + }); + test('changing workspace folders re-evaluates the context', async () => { registerWorkspacePythonContext(disposables); await flush(); findFilesStub.resolves([marker('/other/requirements.txt')]); fireFoldersChanged(); - await flush(); + await settle(); assert.deepStrictEqual(contextValues(), [false, true]); }); - test('opening a Python document re-evaluates the context', async () => { + test('a workspace change invalidates pending marker checks', async () => { + const pending = makeDeferred(); + statStub.returns(pending.promise); registerWorkspacePythonContext(disposables); await flush(); - openDocsStub.returns([{ languageId: 'python' }]); - fireDocOpened('python'); + fireCreate('/ws/main.py'); + fireFoldersChanged(); + pending.resolve({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); + await settle(); + assert.deepStrictEqual(contextValues(), [false]); + assert.strictEqual(findFilesStub.callCount, 2); + }); + + test('opening a Python document turns the context on without a rescan', async () => { + registerWorkspacePythonContext(disposables); await flush(); + fireDocOpened('python'); + await settle(); assert.deepStrictEqual(contextValues(), [false, true]); + assert.strictEqual(findFilesStub.callCount, 1); }); test('opening a non-Python document does not trigger a rescan', async () => { registerWorkspacePythonContext(disposables); await flush(); fireDocOpened('markdown'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [false]); + }); + + test('only updates the context when the value changes', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + fireDelete('/ws/main.py'); + await settle(); + fireCreate('/ws/new.py'); + fireDocOpened('python'); await flush(); + assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [true]); + }); + }); + + suite('event bursts', () => { + test('a burst of creates does not scan the workspace', async () => { + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 1000; i++) { + fireCreate(`/ws/pkg/mod${i}.py`); + } + await settle(); + assert.strictEqual(findFilesStub.callCount, 1, 'only the initial scan should have run'); + assert.deepStrictEqual(contextValues(), [false, true]); + }); + + test('creates are not inspected while the workspace is known to have Python', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 1000; i++) { + fireCreate(`/ws/pkg/mod${i}.py`); + } + await settle(); + assert.strictEqual(statStub.callCount, 0); + assert.strictEqual(findFilesStub.callCount, 1); + }); + + test('repeated create events for the same marker are inspected once', async () => { + const pending = makeDeferred(); + statStub.returns(pending.promise); + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/main.py'); + fireCreate('/ws/main.py'); + assert.strictEqual(statStub.callCount, 1); + pending.resolve({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); + await flush(); + assert.deepStrictEqual(contextValues(), [false, true]); + }); + + test('a burst of deletes is coalesced into one rescan', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 1000; i++) { + fireDelete(i === 0 ? '/ws/main.py' : `/ws/pkg/mod${i}.py`); + } + await clock.tickAsync(RESCAN_DEBOUNCE_MS - 1); + assert.strictEqual(findFilesStub.callCount, 1); + await clock.tickAsync(1); + await flush(); + assert.strictEqual(findFilesStub.callCount, 2); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + }); + + test('a burst of deletes re-checks the found marker once', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 1000; i++) { + fireDelete(`/ws/pkg/mod${i}.py`); + } + await settle(); + await settle(); + assert.strictEqual(statStub.callCount, 1); + assert.strictEqual(findFilesStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [true]); + }); + + test('deletes are ignored while the workspace has no Python', async () => { + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 1000; i++) { + fireDelete(`/ws/pkg/mod${i}.py`); + } + await settle(); assert.strictEqual(findFilesStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [false]); + }); + + test('a burst of workspace folder changes is coalesced into one rescan', async () => { + registerWorkspacePythonContext(disposables); + await flush(); + for (let i = 0; i < 50; i++) { + fireFoldersChanged(); + } + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + }); + + test('a create cancels a pending rescan', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + fireDelete('/ws/main.py'); + fireCreate('/ws/new/main.py'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [true]); + + findFilesStub.resolves([]); + fireDelete('/ws/new/main.py'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + }); + + suite('overlapping scans', () => { + test('a result deleted during its scan is rechecked once, never concurrently', async () => { + let inFlight = 0; + let maxInFlight = 0; + const pending: { resolve: (value: any[]) => void }[] = []; + findFilesStub.callsFake(() => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + const deferred = makeDeferred(); + pending.push(deferred); + return deferred.promise.finally(() => inFlight--); + }); + + registerWorkspacePythonContext(disposables); + pending[0].resolve([marker('/ws/main.py')]); + await flush(); + + fireDelete('/ws/main.py'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2, 'the debounced rescan should be running'); + + for (let i = 0; i < 1000; i++) { + fireDelete(`/ws/pkg/mod${i}.py`); + } + await settle(); + assert.strictEqual(findFilesStub.callCount, 2, 'no second scan while one is running'); + + pending[1].resolve([marker('/ws/pkg/mod0.py')]); + await settle(); + assert.strictEqual(findFilesStub.callCount, 3, 'exactly one follow up scan'); + pending[2].resolve([]); + await settle(); + + assert.strictEqual(findFilesStub.callCount, 3); + assert.strictEqual(maxInFlight, 1); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('an empty result is kept when other markers are deleted during its scan', async () => { + const rescan = makeDeferred(); + findFilesStub.onFirstCall().resolves([marker('/ws/main.py')]); + findFilesStub.onSecondCall().returns(rescan.promise); + registerWorkspacePythonContext(disposables); + await flush(); + fireDelete('/ws/main.py'); + await settle(); + fireDelete('/ws/other.py'); + rescan.resolve([]); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('a slow search is never abandoned', async () => { + const slowScan = makeDeferred(); + registerWorkspacePythonContext(disposables); + await flush(); + findFilesStub.onSecondCall().returns(slowScan.promise); + fireFoldersChanged(); + await settle(); + await clock.tickAsync(60_000); + assert.strictEqual(tokenOfScan(1).isCancellationRequested, false); + slowScan.resolve([marker('/other/main.py')]); + await flush(); + assert.deepStrictEqual(contextValues(), [false, true]); + }); + + test('a create during a scan wins over a stale empty result', async () => { + const initial = makeDeferred(); + findFilesStub.returns(initial.promise); + + registerWorkspacePythonContext(disposables); + fireCreate('/ws/main.py'); + await flush(); + assert.deepStrictEqual(contextValues(), [true]); + assert.strictEqual(tokenOfScan(0).isCancellationRequested, true, 'the stale scan should be cancelled'); + + initial.resolve([]); + await settle(); + assert.deepStrictEqual(contextValues(), [true]); + assert.strictEqual(findFilesStub.callCount, 1); + }); + + test('deleting a created marker while its cancelled scan finishes triggers a rescan', async () => { + const initial = makeDeferred(); + findFilesStub.onFirstCall().returns(initial.promise); + findFilesStub.onSecondCall().resolves([]); + registerWorkspacePythonContext(disposables); + fireCreate('/ws/main.py'); + await flush(); + fireDelete('/ws/main.py'); + initial.resolve([]); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('a delete while a cancelled scan finishes triggers a rescan', async () => { + const initial = makeDeferred(); + findFilesStub.onFirstCall().returns(initial.promise); + findFilesStub.onSecondCall().resolves([]); + registerWorkspacePythonContext(disposables); + fireDocOpened('python'); + await flush(); + assert.strictEqual(tokenOfScan(0).isCancellationRequested, true); + + fireDelete('/ws/main.py'); + initial.resolve([]); + await settle(); + assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('a workspace change cancels a scan for the previous workspace', async () => { + const staleScan = makeDeferred(); + findFilesStub.onFirstCall().resolves([marker('/old/main.py')]); + findFilesStub.onSecondCall().returns(staleScan.promise); + findFilesStub.onThirdCall().resolves([]); + registerWorkspacePythonContext(disposables); + await flush(); + + fireFoldersChanged(); + await settle(); + fireFoldersChanged(); + assert.strictEqual(tokenOfScan(1).isCancellationRequested, true); + staleScan.resolve([marker('/old/main.py')]); + await flush(); + fireCreate('/new/main.py'); + await flush(); + fireDelete('/new/main.py'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 3); + assert.deepStrictEqual(contextValues(), [true, false]); + }); + + test('a delete racing a scan is rechecked even while the context is off', async () => { + const folderScan = makeDeferred(); + registerWorkspacePythonContext(disposables); + await flush(); + findFilesStub.onSecondCall().returns(folderScan.promise); + findFilesStub.onThirdCall().resolves([]); + + fireFoldersChanged(); + await settle(); + fireDelete('/other/main.py'); // removed after the running scan already listed it + folderScan.resolve([marker('/other/main.py')]); + await flush(); + await settle(); + + assert.strictEqual(findFilesStub.callCount, 3); + assert.deepStrictEqual(contextValues(), [false]); }); }); @@ -173,14 +577,23 @@ suite('Workspace Python context', () => { '/ws/node_modules/tool/setup.py', '/ws/.git/hooks/pre-commit.py', ].forEach((excluded) => { - test(`ignores create and delete events under ${excluded}`, async () => { + test(`ignores create events under ${excluded}`, async () => { registerWorkspacePythonContext(disposables); await flush(); fireCreate(excluded); - fireDelete(excluded); + await settle(); + assert.strictEqual(findFilesStub.callCount, 1, 'only the initial scan should have run'); + assert.deepStrictEqual(contextValues(), [false]); + }); + + test(`ignores delete events under ${excluded}`, async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); await flush(); + fireDelete(excluded); + await settle(); assert.strictEqual(findFilesStub.callCount, 1, 'only the initial scan should have run'); - assert.strictEqual(executeCommandStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [true]); }); }); @@ -189,43 +602,95 @@ suite('Workspace Python context', () => { await flush(); fireCreate('/ws/my-site-packages-notes/main.py'); await flush(); - assert.strictEqual(findFilesStub.callCount, 2); + assert.deepStrictEqual(contextValues(), [false, true]); }); }); - suite('overlapping refreshes', () => { - test('an older scan that finishes last does not overwrite newer state', async () => { - const older = makeDeferred(); - const newer = makeDeferred(); - findFilesStub.onFirstCall().returns(older.promise); - findFilesStub.onSecondCall().returns(newer.promise); + suite('disposal', () => { + test('cancels a pending rescan', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + fireDelete('/ws/other.py'); + fireDelete('/ws/main.py'); + disposables.forEach((d) => d.dispose()); + assert.strictEqual(clock.countTimers(), 0); + await settle(); + fireDelete('/ws/other.py'); + await settle(); + assert.strictEqual(findFilesStub.callCount, 1); + }); - registerWorkspacePythonContext(disposables); // older scan: started first, still running - fireDelete('/ws/main.py'); // newer scan: started second + test('cancels the running scan and ignores its result', async () => { + const scan = makeDeferred(); + findFilesStub.returns(scan.promise); + registerWorkspacePythonContext(disposables); + disposables.forEach((d) => d.dispose()); + assert.strictEqual(tokenOfScan(0).isCancellationRequested, true); + assert.strictEqual(clock.countTimers(), 0); + scan.resolve([marker('/ws/main.py')]); + await flush(); + assert.strictEqual(executeCommandStub.callCount, 0); + }); - newer.resolve([]); // newer result (false) arrives first... + test('ignores a marker stat that finishes after disposal', async () => { + const pending = makeDeferred(); + statStub.returns(pending.promise); + registerWorkspacePythonContext(disposables); await flush(); - older.resolve([marker('/ws/main.py')]); // ...then the stale result (true) arrives + fireCreate('/ws/main.py'); + disposables.forEach((d) => d.dispose()); + pending.resolve({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); await flush(); - assert.deepStrictEqual(contextValues(), [false]); }); + }); - test('the newest scan wins when scans finish in order', async () => { - const older = makeDeferred(); - const newer = makeDeferred(); - findFilesStub.onFirstCall().returns(older.promise); - findFilesStub.onSecondCall().returns(newer.promise); - + suite('context updates', () => { + test('a slow update is never applied after a newer one', async () => { + const applied: boolean[] = []; + const firstUpdate = makeDeferred(); + let calls = 0; + executeCommandStub.callsFake(async (_command: string, _key: string, value: boolean) => { + if (++calls === 1) { + await firstUpdate.promise; + } + applied.push(value); + }); registerWorkspacePythonContext(disposables); + await flush(); fireCreate('/ws/main.py'); + await flush(); + firstUpdate.resolve(); + await flush(); + assert.deepStrictEqual(applied, [false, true]); + }); - older.resolve([]); + test('only the latest value is sent after a slow update', async () => { + const firstUpdate = makeDeferred(); + executeCommandStub.onFirstCall().returns(firstUpdate.promise); + registerWorkspacePythonContext(disposables); await flush(); - newer.resolve([marker('/ws/main.py')]); + fireCreate('/ws/main.py'); + await flush(); + fireDelete('/ws/main.py'); + await settle(); + firstUpdate.resolve(); await flush(); + assert.deepStrictEqual(contextValues(), [false]); + }); - assert.deepStrictEqual(contextValues(), [true]); + test('no update is sent after disposal', async () => { + const firstUpdate = makeDeferred(); + executeCommandStub.onFirstCall().returns(firstUpdate.promise); + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/main.py'); + await flush(); + disposables.forEach((d) => d.dispose()); + firstUpdate.resolve(); + await flush(); + assert.deepStrictEqual(contextValues(), [false]); }); }); @@ -249,14 +714,64 @@ suite('Workspace Python context', () => { assert.strictEqual(traceErrorStub.firstCall.args[1], error); }); + test('retries setContext after a failed create update', async () => { + executeCommandStub.onSecondCall().rejects(new Error('setContext failed')); + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/main.py'); + await flush(); + fireCreate('/ws/main.py'); + await flush(); + assert.deepStrictEqual(contextValues(), [false, true, true]); + assert.strictEqual(traceErrorStub.callCount, 1); + }); + test('keeps working after a failed refresh', async () => { findFilesStub.onFirstCall().rejects(new Error('boom')); registerWorkspacePythonContext(disposables); await flush(); + fireCreate('/ws/main.py'); + await flush(); + assert.deepStrictEqual(contextValues(), [true]); + }); + + test('a marker that cannot be inspected is found by a rescan', async () => { + statStub.rejects(new Error('stat failed')); + registerWorkspacePythonContext(disposables); + await flush(); findFilesStub.resolves([marker('/ws/main.py')]); fireCreate('/ws/main.py'); await flush(); + await settle(); + assert.strictEqual(traceErrorStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [false, true]); + }); + + test('a marker deleted before it is inspected is ignored', async () => { + statStub.rejects(Object.assign(new Error('gone'), { code: 'FileNotFound' })); + registerWorkspacePythonContext(disposables); + await flush(); + fireCreate('/ws/tmp.py'); + await flush(); + await settle(); + assert.strictEqual(traceErrorStub.callCount, 0); + assert.strictEqual(findFilesStub.callCount, 1); + assert.deepStrictEqual(contextValues(), [false]); + }); + + test('a found marker that cannot be re-checked is logged and searched for', async () => { + findFilesStub.resolves([marker('/ws/main.py')]); + registerWorkspacePythonContext(disposables); + await flush(); + const error = new Error('stat failed'); + statStub.rejects(error); + fireDelete('/ws/other.py'); + await settle(); // re-checks the found marker + await settle(); // rescans + assert.ok(traceErrorStub.calledOnce); + assert.strictEqual(traceErrorStub.firstCall.args[1], error); + assert.strictEqual(findFilesStub.callCount, 2); assert.deepStrictEqual(contextValues(), [true]); }); }); -}); \ No newline at end of file +});