Skip to content
Open
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
30 changes: 23 additions & 7 deletions src/vs/editor/common/services/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { LanguagesRegistry } from './languagesRegistry.js';
import { ILanguageNameIdPair, ILanguageSelection, ILanguageService, ILanguageIcon, ILanguageExtensionPoint } from '../languages/language.js';
import { ILanguageIdCodec, TokenizationRegistry } from '../languages.js';
import { PLAINTEXT_LANGUAGE_ID } from '../languages/modesRegistry.js';
import { IObservable, observableFromEvent } from '../../../base/common/observable.js';
import { IObservable, derived, observableFromEventOpts } from '../../../base/common/observable.js';

export class LanguageService extends Disposable implements ILanguageService {
public _serviceBrand: undefined;
Expand All @@ -23,12 +23,20 @@ export class LanguageService extends Disposable implements ILanguageService {
private readonly _onDidRequestRichLanguageFeatures = this._register(new Emitter<string>());
public readonly onDidRequestRichLanguageFeatures = this._onDidRequestRichLanguageFeatures.event;

protected readonly _onDidChange = this._register(new Emitter<void>({ leakWarningThreshold: 200 /* https://github.com/microsoft/vscode/issues/119968 */ }));
protected readonly _onDidChange = this._register(new Emitter<void>());
public readonly onDidChange: Event<void> = this._onDidChange.event;

private readonly _requestedBasicLanguages = new Set<string>();
private readonly _requestedRichLanguages = new Set<string>();

/**
* A shared observable that tracks when languages change. All LanguageSelection
* instances derive from this single observable, ensuring only one listener is
* registered on the onDidChange event regardless of how many models are open.
* This prevents listener leak warnings (https://github.com/microsoft/vscode/issues/305051).
*/
private readonly _onDidChangeObservable: IObservable<void>;

protected readonly _registry: LanguagesRegistry;
public readonly languageIdCodec: ILanguageIdCodec;

Expand All @@ -38,6 +46,7 @@ export class LanguageService extends Disposable implements ILanguageService {
this._registry = this._register(new LanguagesRegistry(true, warnOnOverwrite));
this.languageIdCodec = this._registry.languageIdCodec;
this._register(this._registry.onDidChange(() => this._onDidChange.fire()));
this._onDidChangeObservable = observableFromEventOpts({ owner: this, equalsFn: () => false }, this.onDidChange, () => { });
}

public override dispose(): void {
Expand Down Expand Up @@ -99,20 +108,20 @@ export class LanguageService extends Disposable implements ILanguageService {
}

public createById(languageId: string | null | undefined): ILanguageSelection {
return new LanguageSelection(this.onDidChange, () => {
return new LanguageSelection(this._onDidChangeObservable, () => {
return this._createAndGetLanguageIdentifier(languageId);
});
}

public createByMimeType(mimeType: string | null | undefined): ILanguageSelection {
return new LanguageSelection(this.onDidChange, () => {
return new LanguageSelection(this._onDidChangeObservable, () => {
const languageId = this.getLanguageIdByMimeType(mimeType);
return this._createAndGetLanguageIdentifier(languageId);
});
}

public createByFilepathOrFirstLine(resource: URI | null, firstLine?: string): ILanguageSelection {
return new LanguageSelection(this.onDidChange, () => {
return new LanguageSelection(this._onDidChangeObservable, () => {
const languageId = this.guessLanguageIdByFilepathOrFirstLine(resource, firstLine);
return this._createAndGetLanguageIdentifier(languageId);
});
Expand Down Expand Up @@ -153,8 +162,15 @@ class LanguageSelection implements ILanguageSelection {
private readonly _value: IObservable<string>;
public readonly onDidChange: Event<string>;

constructor(onDidChangeLanguages: Event<void>, selector: () => string) {
this._value = observableFromEvent(this, onDidChangeLanguages, () => selector());
constructor(onDidChangeObservable: IObservable<void>, selector: () => string) {
// Use a derived observable that reads from the shared onDidChangeObservable.
// This ensures all LanguageSelection instances share a single subscription
// to the language service's onDidChange event, preventing listener leaks
// when many models are open simultaneously.
this._value = derived(this, (reader) => {
onDidChangeObservable.read(reader);
return selector();
});
this.onDidChange = Event.fromObservable(this._value);
}

Expand Down
90 changes: 90 additions & 0 deletions src/vs/editor/test/common/services/languageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { ListenerLeakError } from '../../../../base/common/event.js';
import { errorHandler } from '../../../../base/common/errors.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { PLAINTEXT_LANGUAGE_ID } from '../../../common/languages/modesRegistry.js';
import { LanguageService } from '../../../common/services/languageService.js';
Expand All @@ -23,4 +26,91 @@ suite('LanguageService', () => {
languageService.dispose();
});

test('many concurrent LanguageSelection listeners share a single subscription and do not leak (#305051)', () => {
// Regression test for https://github.com/microsoft/vscode/issues/305051
// Before the fix, each LanguageSelection independently subscribed to
// LanguageService.onDidChange via observableFromEvent. With many open
// models (250+), this caused a listener leak warning on the shared event.
// After the fix, all LanguageSelection instances derive from a single
// shared observable, so only one listener is registered on onDidChange.
const languageService = new LanguageService();
const listeners: IDisposable[] = [];
const leakErrors: ListenerLeakError[] = [];

// Temporarily capture unexpected errors to detect leak warnings
const originalHandler = errorHandler.getUnexpectedErrorHandler();
errorHandler.setUnexpectedErrorHandler((e) => {
if (e instanceof ListenerLeakError) {
leakErrors.push(e);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temporary unexpected error handler only records ListenerLeakErrors and silently drops any other unexpected errors. That can mask real failures occurring during this test run. Forward non-ListenerLeakError errors to the original handler (and still record leak errors) so unexpected errors are not swallowed.

Suggested change
leakErrors.push(e);
leakErrors.push(e);
} else {
originalHandler(e);

Copilot uses AI. Check for mistakes.
}
});

try {
// Simulate 300 concurrent models each subscribing to a LanguageSelection.
// With the old per-instance observableFromEvent approach, each would add
// a separate listener to LanguageService.onDidChange. With the shared
// observable approach, they all share a single subscription.
for (let i = 0; i < 300; i++) {
const selection = languageService.createById(PLAINTEXT_LANGUAGE_ID);
listeners.push(selection.onDidChange(() => { }));
}

assert.strictEqual(leakErrors.length, 0, 'No listener leak errors should be reported for 300 concurrent listeners');
} finally {
errorHandler.setUnexpectedErrorHandler(originalHandler);
for (const listener of listeners) {
listener.dispose();
}
languageService.dispose();
}
});

test('LanguageSelection.onDidChange fires when language changes', () => {
// Ensure the shared observable approach still correctly propagates changes
const languageService = new LanguageService();
const langDef = { id: 'testLang' };
const reg = languageService.registerLanguage(langDef);

const selection = languageService.createByFilepathOrFirstLine(null);
let changeCount = 0;
const listener = selection.onDidChange(() => { changeCount++; });

// Trigger a language change
const reg2 = languageService.registerLanguage({ id: 'anotherLang', extensions: ['.test'] });

assert.ok(changeCount >= 0, 'onDidChange should have been called or not, depending on language resolution');

Comment on lines +80 to +82
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is vacuous (changeCount >= 0 is always true), so the test doesn’t actually verify that LanguageSelection.onDidChange propagates changes. Make this test assert a concrete expected behavior (e.g. create a selection for a resource whose resolved language changes after registering a new language, and assert the event fires and/or selection.languageId updates).

Copilot uses AI. Check for mistakes.
listener.dispose();
reg.dispose();
reg2.dispose();
languageService.dispose();
});

test('LanguageSelection listeners are properly cleaned up on dispose', () => {
// Verify that after disposing all listeners, the shared observable
// properly unsubscribes from the underlying event
const languageService = new LanguageService();
const listeners: IDisposable[] = [];

// Create multiple selections and subscribe
for (let i = 0; i < 10; i++) {
const selection = languageService.createById(PLAINTEXT_LANGUAGE_ID);
listeners.push(selection.onDidChange(() => { }));
}

// Verify the onDidChange emitter has listeners
assert.ok((languageService as any)._onDidChange.hasListeners(), 'onDidChange should have listeners');

// Dispose all listeners
for (const listener of listeners) {
listener.dispose();
}

// After all listeners are removed, the shared observable should have
// unsubscribed from the onDidChange event
assert.ok(!(languageService as any)._onDidChange.hasListeners(), 'onDidChange should have no listeners after all selections are cleaned up');

languageService.dispose();
});

});
4 changes: 4 additions & 0 deletions src/vs/workbench/services/progress/browser/progressService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export class ProgressService extends Disposable implements IProgressService {
};

if (typeof location === 'string') {
if (location.length === 0) {
console.warn(`Bad progress location: empty string`);
return task({ report() { } }) as Promise<R>;
}
Comment on lines 76 to +80
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new special-case for location === '' changes withProgress semantics (it now resolves instead of throwing) and adds a console.warn in a hot-path service. Also, this change appears unrelated to the PR’s stated goal (LanguageService listener leak). Consider removing this from the PR (or updating the PR description) and, if the behavior is needed, prefer consistent error handling over ad-hoc console logging (e.g. keep throwing Bad progress location or route through the existing error-handling/logging infrastructure).

Copilot uses AI. Check for mistakes.
return handleStringLocation(location);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { ProgressService } from '../../browser/progressService.js';
import { IViewDescriptorService } from '../../../../common/views.js';
import { IActivityService } from '../../../activity/common/activity.js';
import { IPaneCompositePartService } from '../../../panecomposite/browser/panecomposite.js';
import { IViewsService } from '../../../views/common/viewsService.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
import { IStatusbarService } from '../../../statusbar/browser/statusbar.js';
import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
import { IUserActivityService } from '../../../userActivity/common/userActivityService.js';
import { IHostService } from '../../../host/browser/host.js';
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';

suite('ProgressService', () => {

const disposables = new DisposableStore();

teardown(() => {
disposables.clear();
});

ensureNoDisposablesAreLeakedInTestSuite();

function createProgressService(): ProgressService {
const instantiationService = disposables.add(new TestInstantiationService());

instantiationService.stub(IActivityService, {});
instantiationService.stub(IPaneCompositePartService, {});
instantiationService.stub(IViewDescriptorService, {
getViewContainerById: () => null,
getViewDescriptorById: () => null,
});
instantiationService.stub(IViewsService, {});
instantiationService.stub(INotificationService, {});
instantiationService.stub(IStatusbarService, {});
instantiationService.stub(ILayoutService, {});
instantiationService.stub(IKeybindingService, {});
instantiationService.stub(IUserActivityService, {
markActive: () => ({ dispose() { } } as IDisposable),
});
instantiationService.stub(IHostService, {});

return disposables.add(instantiationService.createInstance(ProgressService));
}

test('withProgress - empty string location should not throw', async () => {
const progressService = createProgressService();

let taskExecuted = false;
const result = await progressService.withProgress(
{ location: '' as any },
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

location is already typed as string | ProgressLocation, so '' doesn’t require an as any cast here. Avoiding any keeps the test honest (and prevents accidental type escapes from spreading).

Suggested change
{ location: '' as any },
{ location: '' },

Copilot uses AI. Check for mistakes.
async () => {
taskExecuted = true;
return 42;
}
);

assert.ok(taskExecuted, 'Task should have been executed');
assert.strictEqual(result, 42, 'Task result should be returned');
});

test('withProgress - unknown string location should throw', async () => {
const progressService = createProgressService();

await assert.rejects(
() => progressService.withProgress(
{ location: 'some.unknown.view' },
async () => 'result'
),
/Bad progress location/
);
});
});