Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/LanguageServer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ describe('LanguageServer', () => {
workspaceFolders = [workspacePath];
LanguageServer.enableThreadingDefault = false;

//disable debounce by default for tests so existing tests run without delay
server.fileChangeDebounceDelay = 0;

//mock the connection stuff
sinon.stub(server as any, 'establishConnection').callsFake(() => {
return connection;
Expand Down Expand Up @@ -1138,6 +1141,169 @@ describe('LanguageServer', () => {
stub.getCalls()[0].args[0]
).to.eql([]);
});

describe('debouncing', () => {
let clock: sinon.SinonFakeTimers;

beforeEach(() => {
(server as any)['connection'] = connection;
//enable a non-zero debounce for these tests
server.fileChangeDebounceDelay = 150;
clock = sinon.useFakeTimers();
});

afterEach(() => {
clock.restore();
});

it('batches rapid successive file change events into a single handleFileChanges call', async () => {
const stub = sinon.stub(server['projectManager'], 'handleFileChanges').callsFake(() => Promise.resolve());

//fire 3 rapid events without awaiting
const promise1 = server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file1.brs`)
}]
} as DidChangeWatchedFilesParams);

const promise2 = server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file2.brs`)
}]
} as DidChangeWatchedFilesParams);

const promise3 = server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file3.brs`)
}]
} as DidChangeWatchedFilesParams);

//handleFileChanges should not have been called yet (still within debounce window)
expect(stub.callCount).to.eql(0);

//advance past the debounce window and flush microtasks
await clock.tickAsync(200);

//all promises should resolve to the same batch
await Promise.all([promise1, promise2, promise3]);

//handleFileChanges should have been called exactly once with all 3 changes
expect(stub.callCount).to.eql(1);
expect(stub.getCalls()[0].args[0]).to.have.lengthOf(3);
});

it('resets the debounce timer on each new event', async () => {
const stub = sinon.stub(server['projectManager'], 'handleFileChanges').callsFake(() => Promise.resolve());

//fire first event
void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file1.brs`)
}]
} as DidChangeWatchedFilesParams);

//advance 100ms (less than the 150ms debounce)
await clock.tickAsync(100);

//fire another event -- this should reset the timer
void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file2.brs`)
}]
} as DidChangeWatchedFilesParams);

//advance another 100ms (200ms total, but only 100ms since last event)
await clock.tickAsync(100);

//should NOT have flushed yet because the timer was reset
expect(stub.callCount).to.eql(0);

//advance past the debounce window from the second event
await clock.tickAsync(100);

//now it should have flushed with both changes
expect(stub.callCount).to.eql(1);
expect(stub.getCalls()[0].args[0]).to.have.lengthOf(2);
});

it('calls rebuildPathFilterer at most once per batch when bsconfig changes', async () => {
sinon.stub(server['projectManager'], 'handleFileChanges').callsFake(() => Promise.resolve());
const pathFiltererStub = sinon.stub(server as any, 'rebuildPathFilterer').callsFake(() => Promise.resolve());

//fire two bsconfig change events
void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/bsconfig.json`)
}]
} as DidChangeWatchedFilesParams);

void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/sub/bsconfig.json`)
}]
} as DidChangeWatchedFilesParams);

await clock.tickAsync(200);

//rebuildPathFilterer should have been called exactly once for the entire batch
expect(pathFiltererStub.callCount).to.eql(1);
});

it('deduplicates multiple events for the same file within a batch', async () => {
const stub = sinon.stub(server['projectManager'], 'handleFileChanges').callsFake(() => Promise.resolve());

//fire 3 Changed events for the same file
for (let i = 0; i < 3; i++) {
void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Changed,
uri: util.pathToUri(s`${rootDir}/source/file1.brs`)
}]
} as DidChangeWatchedFilesParams);
}

await clock.tickAsync(200);

//should have been called once with only 1 unique change (not 3)
expect(stub.callCount).to.eql(1);
expect(stub.getCalls()[0].args[0]).to.have.lengthOf(1);
});

it('keeps the last event type when deduplicating (last event wins)', async () => {
const stub = sinon.stub(server['projectManager'], 'handleFileChanges').callsFake(() => Promise.resolve());
const fileUri = util.pathToUri(s`${rootDir}/source/file1.brs`);

//fire Created then Deleted for the same file
void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Created,
uri: fileUri
}]
} as DidChangeWatchedFilesParams);

void server['onDidChangeWatchedFiles']({
changes: [{
type: FileChangeType.Deleted,
uri: fileUri
}]
} as DidChangeWatchedFilesParams);

await clock.tickAsync(200);

expect(stub.callCount).to.eql(1);
const changes = stub.getCalls()[0].args[0];
expect(changes).to.have.lengthOf(1);
//the Deleted event should win because it came last
expect(changes[0].type).to.eql(FileChangeType.Deleted);
});
});
});

describe('onDocumentClose', () => {
Expand Down
64 changes: 62 additions & 2 deletions src/LanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ import { PathFilterer } from './lsp/PathFilterer';
import type { WorkspaceConfig } from './lsp/ProjectManager';
import { ProjectManager } from './lsp/ProjectManager';
import * as fsExtra from 'fs-extra';
import type { MaybePromise } from './interfaces';
import type { FileChange, MaybePromise } from './interfaces';
import { Deferred } from './deferred';
import { workerPool } from './lsp/worker/WorkerThreadProject';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import isEqual = require('lodash.isequal');
Expand Down Expand Up @@ -353,17 +354,36 @@ export class LanguageServer {
}]);
}

/**
* Pending file changes waiting to be flushed after the debounce period
*/
private pendingFileChanges: FileChange[] = [];

/**
* Timer handle for the file change debounce
*/
private fileChangeDebounceTimer: ReturnType<typeof setTimeout> | undefined;

/**
* How long to wait (in ms) after the last file change event before processing the batch.
* This prevents excessive revalidation during bulk operations like `git checkout` or package installs.
*/
public fileChangeDebounceDelay = 300;

/**
* Called when watched files changed (add/change/delete).
* The CLIENT is in charge of what files to watch, so all client
* implementations should ensure that all valid project
* file types are watched (.brs,.bs,.xml,manifest, and any json/text/image files)
*
* File changes are debounced to batch rapid successive events (e.g. during builds or VCS operations)
* into a single processing pass, reducing redundant work across projects.
*/
@AddStackToErrorMessage
public async onDidChangeWatchedFiles(params: DidChangeWatchedFilesParams) {
const workspacePaths = (await this.connection.workspace.getWorkspaceFolders()).map(x => util.uriToPath(x.uri));

let changes = params.changes
const changes = params.changes
.map(x => ({
srcPath: util.uriToPath(x.uri),
type: x.type,
Expand All @@ -375,6 +395,45 @@ export class LanguageServer {

this.logger.debug('onDidChangeWatchedFiles', changes);

//accumulate changes into the pending buffer
this.pendingFileChanges.push(...changes);

//reset the debounce timer so we batch rapid successive events
clearTimeout(this.fileChangeDebounceTimer);

//use a deferred so callers can await the completion of the flush
if (!this.pendingFileChangesDeferred) {
this.pendingFileChangesDeferred = new Deferred();
}
const deferred = this.pendingFileChangesDeferred;

this.fileChangeDebounceTimer = setTimeout(() => {
void this.flushFileChanges().then(
() => deferred.resolve(),
(err) => deferred.reject(err)
);
}, this.fileChangeDebounceDelay);

return deferred.promise;
}

/**
* Deferred for the current pending file changes batch
*/
private pendingFileChangesDeferred: Deferred | undefined;

/**
* Flush all pending file changes accumulated during the debounce window
*/
private async flushFileChanges() {
//grab all pending changes and clear the buffer, deduping by srcPath (last event wins)
const deduped = new Map<string, FileChange>();
for (const change of this.pendingFileChanges.splice(0, this.pendingFileChanges.length)) {
deduped.set(change.srcPath, change);
}
const changes = [...deduped.values()];
this.pendingFileChangesDeferred = undefined;

//if the client changed any files containing include/exclude patterns, rebuild the path filterer before processing these changes
if (
micromatch.some(changes.map(x => x.srcPath), [
Expand Down Expand Up @@ -772,6 +831,7 @@ export class LanguageServer {
private diagnosticCollection = new DiagnosticCollection();

protected dispose() {
clearTimeout(this.fileChangeDebounceTimer);
this.loggerSubscription?.();
this.projectManager?.dispose?.();
}
Expand Down