-
Notifications
You must be signed in to change notification settings - Fork 39.3k
fix: resolve #305051 - fix listener leak on LanguageService.onDidChange #305186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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); | ||
| } | ||
| }); | ||
|
|
||
| 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
|
||
| 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(); | ||
| }); | ||
|
|
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| return handleStringLocation(location); | ||
| } | ||
|
|
||
|
|
||
| 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 }, | ||||||
|
||||||
| { location: '' as any }, | |
| { location: '' }, |
There was a problem hiding this comment.
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-ListenerLeakErrorerrors to the original handler (and still record leak errors) so unexpected errors are not swallowed.