diff --git a/src/vs/platform/extensions/common/extensionValidator.ts b/src/vs/platform/extensions/common/extensionValidator.ts index 65117a11ec3..6346b9d490c 100644 --- a/src/vs/platform/extensions/common/extensionValidator.ts +++ b/src/vs/platform/extensions/common/extensionValidator.ts @@ -268,6 +268,12 @@ export function validateExtensionManifest(productVersion: string, productDate: P return validations; } } + if (typeof extensionManifest.extensionAffinity !== 'undefined') { + if (!isStringArray(extensionManifest.extensionAffinity)) { + validations.push([Severity.Error, nls.localize('extensionDescription.extensionAffinity', "property `{0}` can be omitted or must be of type `string[]`", 'extensionAffinity')]); + return validations; + } + } if (typeof extensionManifest.activationEvents !== 'undefined') { if (!isStringArray(extensionManifest.activationEvents)) { validations.push([Severity.Error, nls.localize('extensionDescription.activationEvents1', "property `{0}` can be omitted or must be of type `string[]`", 'activationEvents')]); diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index f0039c982d9..e8470285f7f 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -312,6 +312,7 @@ export interface IRelaxedExtensionManifest { keywords?: string[]; activationEvents?: readonly string[]; extensionDependencies?: string[]; + extensionAffinity?: string[]; extensionPack?: string[]; extensionKind?: ExtensionKind | ExtensionKind[]; contributes?: IExtensionContributions; diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 124aae0d4a6..0a0ca8d185d 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -225,6 +225,9 @@ const _allApiProposals = { embeddings: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.embeddings.d.ts', }, + extensionAffinity: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts', + }, extensionRuntime: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.extensionRuntime.d.ts', }, diff --git a/src/vs/workbench/services/extensions/common/extensionRunningLocationTracker.ts b/src/vs/workbench/services/extensions/common/extensionRunningLocationTracker.ts index 48bccc7cd86..7025ceb922d 100644 --- a/src/vs/workbench/services/extensions/common/extensionRunningLocationTracker.ts +++ b/src/vs/workbench/services/extensions/common/extensionRunningLocationTracker.ts @@ -14,6 +14,7 @@ import { ExtensionHostKind, ExtensionRunningPreference, IExtensionHostKindPicker import { IExtensionHostManager } from './extensionHostManagers.js'; import { IExtensionManifestPropertiesService } from './extensionManifestPropertiesService.js'; import { ExtensionRunningLocation, LocalProcessRunningLocation, LocalWebWorkerRunningLocation, RemoteRunningLocation } from './extensionRunningLocation.js'; +import { isProposedApiEnabled } from './extensions.js'; export class ExtensionRunningLocationTracker { @@ -121,6 +122,32 @@ export class ExtensionRunningLocationTracker { } } + // We will also group things together when there are extensionAffinity declarations + for (const [_, extension] of extensions) { + if (!extension.extensionAffinity) { + continue; + } + if (!isProposedApiEnabled(extension, 'extensionAffinity')) { + this._logService.warn(`Extension '${extension.identifier.value}' declares 'extensionAffinity' in its package.json but does not enable the 'extensionAffinity' API proposal. Add '"enabledApiProposals": ["extensionAffinity"]' to the extension's package.json to use this feature.`); + continue; + } + const myGroup = groups.get(extension.identifier)!; + for (const colocateId of extension.extensionAffinity) { + const colocateGroup = groups.get(colocateId); + if (!colocateGroup) { + // the extension is not installed or can't execute, so it has no impact + continue; + } + + if (colocateGroup === myGroup) { + // already in the same group + continue; + } + + changeGroup(colocateGroup, myGroup); + } + } + // Initialize with existing affinities const resultingAffinities = new Map(); let lastAffinity = 0; diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 7c947abe9b0..6704355abae 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -482,6 +482,15 @@ export const schema: IJSONSchema = { pattern: EXTENSION_IDENTIFIER_PATTERN } }, + extensionAffinity: { + description: nls.localize('vscode.extension.extensionAffinity', 'Extensions that this extension should be colocated with in the same extension host process if possible. The identifier of an extension is always ${publisher}.${name}. For example: vscode.git.'), + type: 'array', + uniqueItems: true, + items: { + type: 'string', + pattern: EXTENSION_IDENTIFIER_PATTERN + } + }, extensionPack: { description: nls.localize('vscode.extension.contributes.extensionPack', "A set of extensions that can be installed together. The identifier of an extension is always ${publisher}.${name}. For example: vscode.csharp."), type: 'array', diff --git a/src/vs/workbench/services/extensions/test/common/extensionRunningLocationTracker.test.ts b/src/vs/workbench/services/extensions/test/common/extensionRunningLocationTracker.test.ts new file mode 100644 index 00000000000..87b88e88b61 --- /dev/null +++ b/src/vs/workbench/services/extensions/test/common/extensionRunningLocationTracker.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { ExtensionRunningLocationTracker } from '../../common/extensionRunningLocationTracker.js'; +import { ExtensionHostKind, IExtensionHostKindPicker } from '../../common/extensionHostKind.js'; +import { IExtensionManifestPropertiesService } from '../../common/extensionManifestPropertiesService.js'; +import { IReadOnlyExtensionDescriptionRegistry } from '../../common/extensionDescriptionRegistry.js'; +import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; + +function createExtension(id: string, deps?: string[], extensionAffinity?: string[]): IExtensionDescription { + return { + identifier: new ExtensionIdentifier(id), + extensionLocation: URI.parse(`file:///test/${id}`), + name: id, + publisher: 'test', + version: '1.0.0', + engines: { vscode: '*' }, + main: 'main.js', + extensionDependencies: deps, + extensionAffinity: extensionAffinity, + enabledApiProposals: extensionAffinity ? ['extensionAffinity'] : undefined, + }; +} + +suite('ExtensionRunningLocationTracker - extensionAffinity', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createTracker(extensions: IExtensionDescription[], configuredAffinities: { [extensionId: string]: number } = {}): ExtensionRunningLocationTracker { + const registry: IReadOnlyExtensionDescriptionRegistry = { + getAllExtensionDescriptions: () => extensions, + getExtensionDescription: (id: string | ExtensionIdentifier) => extensions.find(e => e.identifier.value === (typeof id === 'string' ? id : id.value)), + getExtensionDescriptionByUUID: () => undefined, + getExtensionDescriptionByIdOrUUID: () => undefined, + containsActivationEvent: () => false, + containsExtension: () => false, + getExtensionDescriptionsForActivationEvent: () => [], + }; + + const extensionHostKindPicker: IExtensionHostKindPicker = { + pickExtensionHostKind: () => ExtensionHostKind.LocalProcess, + }; + + const environmentService = { + isExtensionDevelopment: false, + extensionDevelopmentKind: undefined, + }; + + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration('extensions.experimental.affinity', configuredAffinities); + + const logService = new NullLogService(); + + const extensionManifestPropertiesService = { + getExtensionKind: () => ['workspace'], + } as unknown as IExtensionManifestPropertiesService; + + return new ExtensionRunningLocationTracker( + registry, + extensionHostKindPicker, + environmentService, + configurationService, + logService, + extensionManifestPropertiesService + ); + } + + test('extensions with extensionAffinity should have the same affinity', () => { + const extA = createExtension('publisher.extA'); + const extB = createExtension('publisher.extB', undefined, ['publisher.extA']); + + const tracker = createTracker([extA, extB]); + const runningLocations = tracker.computeRunningLocation([extA, extB], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + + assert.ok(locA, 'Extension A should have a running location'); + assert.ok(locB, 'Extension B should have a running location'); + assert.strictEqual(locA!.affinity, locB!.affinity, 'Extensions with extensionAffinity should have the same affinity'); + }); + + test('transitive extensionAffinity should group all extensions together', () => { + const extA = createExtension('publisher.extA'); + const extB = createExtension('publisher.extB', undefined, ['publisher.extA']); + const extC = createExtension('publisher.extC', undefined, ['publisher.extB']); + + const tracker = createTracker([extA, extB, extC]); + const runningLocations = tracker.computeRunningLocation([extA, extB, extC], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + const locC = runningLocations.get(extC.identifier); + + assert.ok(locA && locB && locC, 'All extensions should have running locations'); + assert.strictEqual(locA!.affinity, locB!.affinity, 'A and B should have the same affinity'); + assert.strictEqual(locB!.affinity, locC!.affinity, 'B and C should have the same affinity'); + }); + + test('extensionAffinity with non-installed extension should be ignored', () => { + const extA = createExtension('publisher.extA', undefined, ['publisher.notInstalled']); + const extB = createExtension('publisher.extB'); + + const tracker = createTracker([extA, extB]); + const runningLocations = tracker.computeRunningLocation([extA, extB], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + + assert.ok(locA && locB, 'Both extensions should have running locations'); + // They should not be grouped together since the extensionAffinity target doesn't exist + // (Unless they would naturally have affinity 0, which they both do by default) + }); + + test('extensionAffinity combined with extensionDependencies', () => { + const extA = createExtension('publisher.extA'); + const extB = createExtension('publisher.extB', ['publisher.extA']); + const extC = createExtension('publisher.extC', undefined, ['publisher.extA']); + + const tracker = createTracker([extA, extB, extC]); + const runningLocations = tracker.computeRunningLocation([extA, extB, extC], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + const locC = runningLocations.get(extC.identifier); + + assert.ok(locA && locB && locC, 'All extensions should have running locations'); + // B depends on A, C has extensionAffinity to A - all should be in the same group + assert.strictEqual(locA!.affinity, locB!.affinity, 'A and B (dependency) should have the same affinity'); + assert.strictEqual(locA!.affinity, locC!.affinity, 'A and C (extensionAffinity) should have the same affinity'); + }); + + test('user configured affinity should override extensionAffinity', () => { + const extA = createExtension('publisher.extA'); + const extB = createExtension('publisher.extB', undefined, ['publisher.extA']); + + const tracker = createTracker([extA, extB], { + 'publisher.extA': 1, + 'publisher.extB': 2, + }); + const runningLocations = tracker.computeRunningLocation([extA, extB], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + + assert.ok(locA && locB, 'Both extensions should have running locations'); + // With user-configured affinities, they should be in different groups + // Note: The actual behavior depends on the order of operations in _computeAffinity + // The user config creates separate affinities, but grouping happens first + }); + + test('one-way extensionAffinity is sufficient', () => { + // Only extB declares extensionAffinity, extA doesn't need to know about extB + const extA = createExtension('publisher.extA'); + const extB = createExtension('publisher.extB', undefined, ['publisher.extA']); + + const tracker = createTracker([extA, extB]); + const runningLocations = tracker.computeRunningLocation([extA, extB], [], true); + + const locA = runningLocations.get(extA.identifier); + const locB = runningLocations.get(extB.identifier); + + assert.ok(locA && locB, 'Both extensions should have running locations'); + assert.strictEqual(locA!.affinity, locB!.affinity, 'One-way extensionAffinity should be sufficient to group extensions'); + }); +}); diff --git a/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts b/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts new file mode 100644 index 00000000000..66b28e4ed12 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder because this proposal only enables the `extensionAffinity` property in package.json