Skip to content
40 changes: 30 additions & 10 deletions src/lsp/Project.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from 'chai';
import { tempDir, rootDir, expectDiagnosticsAsync, expectDiagnostics, once } from '../testHelpers.spec';
import { tempDir, rootDir, expectDiagnosticsAsync, expectDiagnostics } from '../testHelpers.spec';
import * as fsExtra from 'fs-extra';
import { standardizePath as s } from '../util';
import { Deferred } from '../deferred';
Expand Down Expand Up @@ -55,13 +55,8 @@ describe('Project', () => {
enableThreading: false
});

//wait for the first validate to finish
await new Promise<void>((resolve) => {
const off = project.on('validate-end', () => {
off();
resolve();
});
});
//explicitly trigger and wait for the first validation
await project.validate();

let validationCount = 0;
let maxValidationCount = 0;
Expand Down Expand Up @@ -98,6 +93,31 @@ describe('Project', () => {
});
});

describe('dispose', () => {
it('cancels in-flight validation on dispose', async () => {
await project.activate({
projectKey: rootDir,
projectDir: rootDir,
bsconfigPath: undefined,
workspaceFolder: rootDir,
enableThreading: false
});

const cancelSpy = sinon.spy(project, 'cancelValidate');

//start a validation (don't await it)
const validatePromise = project.validate();

//dispose mid-validation
project.dispose();

expect(cancelSpy.called).to.be.true;

//the validate promise should still resolve (not hang)
await validatePromise;
});
});

describe('activate', () => {
it('uses `files` from bsconfig.json', async () => {
fsExtra.outputJsonSync(`${rootDir}/bsconfig.json`, {
Expand All @@ -119,7 +139,7 @@ describe('Project', () => {
bsconfigPath: undefined
});

await once(project, 'diagnostics');
await project.validate();

expectDiagnostics(project, [
DiagnosticMessages.cannotFindName('alpha').message
Expand Down Expand Up @@ -240,7 +260,7 @@ describe('Project', () => {
bsconfigPath: undefined
});

await once(project, 'diagnostics');
await project.validate();

await expectDiagnosticsAsync(project, [
DiagnosticMessages.cannotFindName('varNotThere').message
Expand Down
4 changes: 1 addition & 3 deletions src/lsp/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,6 @@ export class Project implements LspProject {
});
}

//trigger a validation (but don't wait for it. That way we can cancel it sooner if we get new incoming data or requests)
void this.validate();

this.activationDeferred.resolve();

return {
Expand Down Expand Up @@ -556,6 +553,7 @@ export class Project implements LspProject {
public disposables: LspProject['disposables'] = [];

public dispose() {
this.cancelValidate();
for (let disposable of this.disposables ?? []) {
disposable?.dispose?.();
}
Expand Down
235 changes: 235 additions & 0 deletions src/lsp/ProjectManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,241 @@ describe('ProjectManager', () => {
s`${rootDir}/project1/bsconfig.json`
]);
});

describe('concurrency limiting', () => {
it('limits the number of projects activating concurrently', async () => {
//create multiple bsconfig.json files
for (let i = 0; i < 6; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

//track concurrent activation count by stubbing the private activateProject method
let maxConcurrent = 0;
let currentConcurrent = 0;
const activateStub = sinon.stub(manager as any, 'activateProject').callsFake(async () => {
currentConcurrent++;
maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
//simulate async activation work
await util.sleep(10);
currentConcurrent--;
});

//set a low concurrency limit for testing
const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 2;

try {
await manager.syncProjects([workspaceSettings]);
//should have activated all 6 projects
expect(activateStub.callCount).to.eql(6);
//but never more than 2 at a time
expect(maxConcurrent).to.be.at.most(2);
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});

it('activates all projects even with concurrency limit', async () => {
//create 5 bsconfig.json files
for (let i = 0; i < 5; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 2;

try {
await manager.syncProjects([workspaceSettings]);
//all projects should be created
expect(manager.projects.length).to.eql(5);
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});

it('limits the number of projects validating concurrently', async () => {
for (let i = 0; i < 6; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

let maxConcurrent = 0;
let currentConcurrent = 0;
let validateCallCount = 0;

//stub activateProject to be a no-op so projects activate quickly
sinon.stub(manager as any, 'activateProject').callsFake(async () => { });

//stub Project.prototype.validate BEFORE syncProjects so the fire-and-forget phase uses it
sinon.stub(Project.prototype, 'validate').callsFake(async () => {
currentConcurrent++;
validateCallCount++;
maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
await util.sleep(10);
currentConcurrent--;
});

const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 2;

try {
await manager.syncProjects([workspaceSettings]);

//wait for the fire-and-forget validation phase to complete
await util.sleep(200);

expect(validateCallCount).to.eql(6);
expect(maxConcurrent).to.be.at.most(2);
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});

it('validates all projects after activation completes', async () => {
for (let i = 0; i < 3; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

let allActivated = false;
let validationStartedBeforeActivation = false;
let validateCallCount = 0;

sinon.stub(manager as any, 'activateProject').callsFake(async () => { });

sinon.stub(Project.prototype, 'validate').callsFake(() => {
if (!allActivated) {
validationStartedBeforeActivation = true;
}
validateCallCount++;
return Promise.resolve();
});

const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 1;

try {
//hook into the end of the activation phase by spying on firstSync
const origTryResolve = manager['firstSync'].tryResolve.bind(manager['firstSync']);
sinon.stub(manager['firstSync'], 'tryResolve').callsFake(() => {
allActivated = true;
return origTryResolve();
});

await manager.syncProjects([workspaceSettings]);

//wait for fire-and-forget validation phase
await util.sleep(100);

expect(validateCallCount).to.eql(3);
expect(validationStartedBeforeActivation).to.be.false;
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});

it('stops activating projects when a new sync starts', async () => {
for (let i = 0; i < 4; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

let activateCount = 0;
const activateDeferred = new Deferred();

sinon.stub(manager as any, 'activateProject').callsFake(async () => {
activateCount++;
//block the first activation so we can trigger a re-sync
if (activateCount === 1) {
await activateDeferred.promise;
}
});

const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 1;

try {
//start first sync (will block on first activation)
const firstSync = manager.syncProjects([workspaceSettings]);

//wait for the first activation to start
await util.sleep(50);

//start a second sync which bumps the generation counter
const secondSync = manager.syncProjects([workspaceSettings], true);

//unblock the first activation
activateDeferred.resolve();

await firstSync;
await secondSync;

//the first sync should have stopped activating after generation changed.
//the exact count depends on timing, but we should NOT see all 4 from the first sync
//plus all 4 from the second sync (8 total). The first sync's remaining items should be skipped.
expect(activateCount).to.be.lessThan(8);
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});

it('skips validation when a new sync starts before validation phase', async () => {
for (let i = 0; i < 3; i++) {
fsExtra.outputFileSync(`${rootDir}/project${i}/bsconfig.json`, '');
}

let validateCallCount = 0;
const activateDeferred = new Deferred();

sinon.stub(manager as any, 'activateProject').callsFake(async () => {
//block on the deferred only when it's pending (we'll make it pending for the second sync)
if (!activateDeferred.isCompleted) {
await activateDeferred.promise;
}
});

sinon.stub(Project.prototype, 'validate').callsFake(() => {
validateCallCount++;
return Promise.resolve();
});

const originalLimit = ProjectManager.projectConcurrencyLimit;
ProjectManager.projectConcurrencyLimit = 1;

try {
//first sync — activates and validates normally (deferred starts resolved)
activateDeferred.resolve();
await manager.syncProjects([workspaceSettings]);
await util.sleep(50);
const firstSyncValidateCount = validateCallCount;

//reset for the next syncs: create a new deferred that blocks
const blockDeferred = new Deferred();
(manager as any).activateProject.callsFake(async () => {
await blockDeferred.promise;
});

//start second sync (blocks on activation)
const secondSync = manager.syncProjects([workspaceSettings], true);
await util.sleep(10);

//immediately start a third sync which bumps the generation
const thirdSync = manager.syncProjects([workspaceSettings], true);

//unblock activation
blockDeferred.resolve();

await secondSync;
await thirdSync;

//wait for any fire-and-forget validation
await util.sleep(100);

//the second sync's validation should have been skipped because the third sync bumped the generation.
//we should see at most the first sync's validations + the third sync's validations
expect(validateCallCount - firstSyncValidateCount).to.be.at.most(3);
} finally {
ProjectManager.projectConcurrencyLimit = originalLimit;
}
});
});
});

describe('maxDepth configuration', () => {
Expand Down
Loading