Add the possibility that extensions can declare their runtime affinity towards other extensions (#289684)

Add the possibility that extensions can declare their affinity towards other extensions (they would like to execute together)
This commit is contained in:
Alexandru Dima
2026-01-23 12:41:40 +01:00
committed by GitHub
parent e8fa7c5d46
commit c3992ef338
7 changed files with 226 additions and 0 deletions
@@ -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')]);
@@ -312,6 +312,7 @@ export interface IRelaxedExtensionManifest {
keywords?: string[];
activationEvents?: readonly string[];
extensionDependencies?: string[];
extensionAffinity?: string[];
extensionPack?: string[];
extensionKind?: ExtensionKind | ExtensionKind[];
contributes?: IExtensionContributions;
@@ -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',
},
@@ -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<number, number>();
let lastAffinity = 0;
@@ -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',
@@ -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 <IExtensionDescription>{
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 = <IWorkbenchEnvironmentService>{
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');
});
});
+6
View File
@@ -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