diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index 28c761987f1570..712cb3c1d84954 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -36,6 +37,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, @ILogService private readonly _logService: ILogService, + @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { super(); @@ -46,10 +48,23 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } void this._triggerAutoUpdate(marketplaceIds); })); + + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (!isMetered) { + this._triggerQueuedAutoUpdate(); + } + })); + } + + private _triggerQueuedAutoUpdate(): void { + const marketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); + if (marketplaceIds.size > 0) { + void this._triggerAutoUpdate(marketplaceIds); + } } private async _triggerAutoUpdate(marketplaceIds: ReadonlySet): Promise { - if (this._updateInFlight) { + if (this._store.isDisposed || this._updateInFlight || this._meteredConnectionService.isConnectionMetered) { return; } @@ -59,8 +74,12 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } catch (err) { this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); } finally { - this._updateInFlight = false; this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); + this._updateInFlight = false; + + if (!this._store.isDisposed && !this._meteredConnectionService.isConnectionMetered) { + this._triggerQueuedAutoUpdate(); + } } } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index bb0c3d7a3bbfed..141051b8be9346 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { runWhenGlobalIdle } from '../../../../../base/common/async.js'; +import { runWhenGlobalIdle, ThrottledDelayer } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; @@ -18,6 +19,7 @@ import { IEnvironmentService } from '../../../../../platform/environment/common/ import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { ObservableMemento, observableMemento } from '../../../../../platform/observable/common/observableMemento.js'; import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -315,7 +317,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private readonly _trustedMarketplacesStore: ObservableMemento; private readonly _lastFetchedPluginsStore: ObservableMemento; private readonly _marketplacesWithUpdates = observableValue>('marketplacesWithUpdates', new Set()); - private _updateCheckTimer: ReturnType | undefined; + private readonly _updateCheckDelayer = this._register(new ThrottledDelayer(PLUGIN_UPDATE_CHECK_INTERVAL_MS)); + private _updateChecksInitialized = false; + private _updateCheckRunning = false; readonly onDidChangeMarketplaces: Event; @@ -335,6 +339,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke @IWorkspacePluginSettingsService private readonly _workspacePluginSettingsService: IWorkspacePluginSettingsService, @IWorkspaceTrustManagementService private readonly _workspaceTrustService: IWorkspaceTrustManagementService, @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, + @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { super(); @@ -404,6 +409,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); this._register(runWhenGlobalIdle(() => { + this._updateChecksInitialized = true; this._scheduleUpdateCheck(); this._register(Event.filter( _configurationService.onDidChangeConfiguration, @@ -411,8 +417,15 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke || e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces) || e.affectsConfiguration(ChatConfiguration.StrictMarketplaces), )(() => { - this.clearUpdatesAvailable(); - this._scheduleUpdateCheck(); + this._marketplacesWithUpdates.set(new Set(), undefined); + this._scheduleUpdateCheck(0); + })); + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (isMetered) { + this._updateCheckDelayer.cancel(); + } else if (!this._updateCheckRunning && !this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(); + } })); })); @@ -429,21 +442,18 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke })); } - override dispose(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } - super.dispose(); - } - clearUpdatesAvailable(marketplaceIds?: ReadonlySet): void { - if (!marketplaceIds) { - this._marketplacesWithUpdates.set(new Set(), undefined); - return; - } - const remaining = new Set([...this._marketplacesWithUpdates.get()].filter(id => !marketplaceIds.has(id))); + const remaining = marketplaceIds + ? new Set([...this._marketplacesWithUpdates.get()].filter(id => !marketplaceIds.has(id))) + : new Set(); this._marketplacesWithUpdates.set(remaining, undefined); + + if (remaining.size === 0 + && this._updateChecksInitialized + && !this._updateCheckRunning + && !this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(); + } } async fetchMarketplacePlugins(token: CancellationToken, marketplaceIds?: ReadonlySet, options?: IFetchMarketplacePluginsOptions): Promise { @@ -823,16 +833,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } /** - * (Re-)schedules the next periodic update check. Called on - * construction and whenever the auto-update config changes. + * (Re-)schedules the next periodic update check after startup idle and + * whenever the auto-update config or metered connection state changes. */ - private _scheduleUpdateCheck(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } + private _scheduleUpdateCheck(delayOverride?: number): void { + this._updateCheckDelayer.cancel(); - if (!this._hasAutoUpdateEnabledMarketplace()) { + if (this._store.isDisposed + || this._meteredConnectionService.isConnectionMetered + || this._marketplacesWithUpdates.get().size > 0 + || !this._hasAutoUpdateEnabledMarketplace()) { return; } @@ -842,13 +852,29 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke 0, ); const elapsed = Date.now() - lastCheck; - const delay = Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); + const delay = delayOverride ?? Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); - this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), delay); + this._updateCheckDelayer.trigger(async () => { + this._updateCheckRunning = true; + try { + await this._doRunUpdateCheck(); + } finally { + this._updateCheckRunning = false; + if (!this._updateCheckDelayer.isTriggered()) { + this._scheduleUpdateCheck(PLUGIN_UPDATE_CHECK_INTERVAL_MS); + } + } + }, delay).catch(error => { + if (!isCancellationError(error)) { + onUnexpectedError(error); + } + }); } - private async _runUpdateCheck(): Promise { - this._updateCheckTimer = undefined; + private async _doRunUpdateCheck(): Promise { + if (this._meteredConnectionService.isConnectionMetered) { + return; + } try { const installed = this.installedPlugins.get(); @@ -887,11 +913,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); } catch (err) { this._logService.debug('[PluginMarketplaceService] Periodic update check failed:', err); - } finally { - // Reschedule for the next check - if (this._hasAutoUpdateEnabledMarketplace()) { - this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), PLUGIN_UPDATE_CHECK_INTERVAL_MS); - } } } diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts index c88cff13d668cd..a43f4e286ffce5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginAutoUpdate.test.ts @@ -5,26 +5,46 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../../../platform/meteredConnection/common/meteredConnection.js'; import { PluginAutoUpdate } from '../../../browser/pluginAutoUpdate.js'; import { IPluginInstallService, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../../../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService } from '../../../common/plugins/pluginMarketplaceService.js'; +class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor(public isConnectionMetered: boolean) { + super(); + } + + setIsConnectionMetered(isConnectionMetered: boolean): void { + this.isConnectionMetered = isConnectionMetered; + this._onDidChangeIsConnectionMetered.fire(isConnectionMetered); + } +} + suite('PluginAutoUpdate', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); interface MockState { marketplacesWithUpdates: ReturnType>>; updateAllCalls: IUpdateAllPluginsOptions[]; - updateAllImpl: () => Promise; + updateAllImpl: (token: CancellationToken) => Promise; clearUpdatesAvailableCalls: ReadonlySet[]; } - function createContribution(stateOverrides?: Partial): { contribution: PluginAutoUpdate; state: MockState } { + function createContribution(stateOverrides?: Partial, isConnectionMetered = false): { contribution: PluginAutoUpdate; state: MockState; meteredConnectionService: TestMeteredConnectionService } { const instantiationService = store.add(new TestInstantiationService()); + const meteredConnectionService = store.add(new TestMeteredConnectionService(isConnectionMetered)); const state: MockState = { marketplacesWithUpdates: observableValue>('test.marketplacesWithUpdates', new Set()), @@ -46,14 +66,15 @@ suite('PluginAutoUpdate', () => { instantiationService.stub(IPluginInstallService, { updateAllPlugins: async (options: IUpdateAllPluginsOptions, _token: CancellationToken): Promise => { state.updateAllCalls.push(options); - return state.updateAllImpl(); + return state.updateAllImpl(_token); }, } as Partial as IPluginInstallService); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IMeteredConnectionService, meteredConnectionService); const contribution = store.add(instantiationService.createInstance(PluginAutoUpdate)); - return { contribution, state }; + return { contribution, state, meteredConnectionService }; } /** Waits for an in-flight microtask-driven update to settle. */ @@ -80,6 +101,90 @@ suite('PluginAutoUpdate', () => { })), [{ silent: true, automatic: true, marketplaceIds: ['github:microsoft/plugins'] }]); }); + test('retains queued updates while metered and runs them when unmetered', async () => { + const { state, meteredConnectionService } = createContribution(undefined, true); + + state.marketplacesWithUpdates.set(new Set(['github:microsoft/plugins']), undefined); + await flushMicrotasks(); + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls, + }, { + updateAllCalls: [], + clearUpdatesAvailableCalls: [], + }); + + meteredConnectionService.setIsConnectionMetered(false); + await flushMicrotasks(); + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.map(call => [...call.marketplaceIds ?? []]), + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.map(ids => [...ids]), + }, { + updateAllCalls: [['github:microsoft/plugins']], + clearUpdatesAvailableCalls: [['github:microsoft/plugins']], + }); + }); + + test('allows an in-flight update to finish after the connection becomes metered', async () => { + let resolveUpdate!: () => void; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { state, meteredConnectionService } = createContribution({ + updateAllImpl: () => pendingUpdate, + }); + + state.marketplacesWithUpdates.set(new Set(['a']), undefined); + await flushMicrotasks(); + meteredConnectionService.setIsConnectionMetered(true); + resolveUpdate(); + await pendingUpdate; + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.length, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, + updateStillQueued: [...state.marketplacesWithUpdates.get()], + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], + }); + + meteredConnectionService.setIsConnectionMetered(false); + await flushMicrotasks(); + assert.strictEqual(state.updateAllCalls.length, 1); + }); + + test('disposing during an in-flight update does not restart queued work', async () => { + let resolveUpdate!: () => void; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { contribution, state } = createContribution({ + updateAllImpl: () => pendingUpdate, + }); + + state.marketplacesWithUpdates.set(new Set(['a']), undefined); + await flushMicrotasks(); + contribution.dispose(); + resolveUpdate(); + await pendingUpdate; + await flushMicrotasks(); + + assert.deepStrictEqual({ + updateAllCalls: state.updateAllCalls.length, + clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, + updateStillQueued: [...state.marketplacesWithUpdates.get()], + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], + }); + }); + test('queues a marketplace reported while another update is in flight', async () => { let resolveUpdate!: () => void; const pendingUpdate = new Promise(resolve => { diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 9471a8d57e00c3..d2b5cd4e7a8c83 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -4,20 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../../base/common/async.js'; +import * as sinon from 'sinon'; +import { DeferredPromise, installFakeRunWhenIdle, timeout } from '../../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; import { joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AGENT_PLUGIN_SCHEMA } from '../../../../../../platform/agentPlugins/common/agentPluginParser.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IFileService, IFileSystemWatcher } from '../../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMeteredConnectionService } from '../../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IRequestService } from '../../../../../../platform/request/common/request.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -28,6 +32,32 @@ import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPlug import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, extraKnownMarketplacesToConfigDict, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource, readConfiguredMarketplaces } from '../../../common/plugins/pluginMarketplaceService.js'; import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; +class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor(public isConnectionMetered: boolean) { + super(); + } + + setIsConnectionMetered(isConnectionMetered: boolean): void { + this.isConnectionMetered = isConnectionMetered; + this._onDidChangeIsConnectionMetered.fire(isConnectionMetered); + } +} + +const unmeteredConnectionService: IMeteredConnectionService = { + _serviceBrand: undefined, + isConnectionMetered: false, + onDidChangeIsConnectionMetered: Event.None, +}; + +function stubMeteredConnectionService(instantiationService: TestInstantiationService, service: IMeteredConnectionService = unmeteredConnectionService): void { + instantiationService.stub(IMeteredConnectionService, service); +} + suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -431,6 +461,7 @@ suite('PluginMarketplaceService - GitHub marketplace refs', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); await service.fetchMarketplacePlugins(CancellationToken.None); @@ -468,6 +499,7 @@ suite('PluginMarketplaceService - GitHub marketplace refs', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); const seeded = service.lastFetchedPlugins.get(); @@ -526,6 +558,7 @@ suite('PluginMarketplaceService - Agent Plugin direct install probes', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -589,6 +622,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => autoUpdate, } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -651,29 +685,36 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function makePlugin(name: string, source: string): IMarketplacePlugin { + function makePlugin(name: string, source: string, reference = marketplaceRef): IMarketplacePlugin { return { name, description: `${name} description`, version: '1.0.0', source, sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source } as const, - marketplace: marketplaceRef.displayLabel, - marketplaceReference: marketplaceRef, + marketplace: reference.displayLabel, + marketplaceReference: reference, marketplaceType: MarketplaceType.Copilot, }; } - function createService(): PluginMarketplaceService { + function createService(options?: { + configurationService?: TestConfigurationService; + meteredConnectionService?: IMeteredConnectionService; + pluginRepositoryService?: Partial; + }): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ + instantiationService.stub(IConfigurationService, options?.configurationService ?? new TestConfigurationService({ [ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'], [ChatConfiguration.PluginsEnabled]: true, })); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); instantiationService.stub(IFileService, {} as unknown as IFileService); - instantiationService.stub(IAgentPluginRepositoryService, { agentPluginsHome: URI.file('/agent-plugins') } as unknown as IAgentPluginRepositoryService); + instantiationService.stub(IAgentPluginRepositoryService, { + agentPluginsHome: URI.file('/agent-plugins'), + ...options?.pluginRepositoryService, + } as IAgentPluginRepositoryService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); @@ -688,6 +729,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService, options?.meteredConnectionService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -709,6 +751,273 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(installed[0].plugin.name, 'my-plugin'); }); + test('periodic update checking pauses while metered and resumes when unmetered', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(true)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + return false; + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + assert.strictEqual(fetchCount, 0); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 1); + }); + + test('defers an overdue check until queued updates are acknowledged', async () => { + const updateCheckInterval = 24 * 60 * 60 * 1000; + const clock = sinon.useFakeTimers({ now: updateCheckInterval + 1 }); + try { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => ++fetchCount === 1, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await clock.tickAsync(0); + assert.deepStrictEqual({ + fetchCount, + marketplacesWithUpdates: [...service.marketplacesWithUpdates.get()], + }, { + fetchCount: 1, + marketplacesWithUpdates: [marketplaceRef.canonicalId], + }); + + meteredConnectionService.setIsConnectionMetered(true); + await clock.tickAsync(updateCheckInterval); + meteredConnectionService.setIsConnectionMetered(false); + await clock.tickAsync(0); + assert.strictEqual(fetchCount, 1); + + service.clearUpdatesAvailable(new Set([marketplaceRef.canonicalId])); + await clock.tickAsync(0); + assert.strictEqual(fetchCount, 2); + } finally { + clock.restore(); + } + }); + + test('unmetering before startup idle does not start an update check', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(true)); + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + return false; + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 0); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + await timeout(0); + assert.strictEqual(fetchCount, 1); + }); + + test('cancelling a scheduled update check does not cause an unhandled rejection', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + createService({ meteredConnectionService }); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason); + const onBrowserUnhandledRejection = (event: PromiseRejectionEvent) => onUnhandledRejection(event.reason); + if (isWeb) { + globalThis.addEventListener('unhandledrejection', onBrowserUnhandledRejection); + } else { + process.on('unhandledRejection', onUnhandledRejection); + } + + try { + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + meteredConnectionService.setIsConnectionMetered(true); + await timeout(0); + + assert.deepStrictEqual(unhandledRejections, []); + } finally { + if (isWeb) { + globalThis.removeEventListener('unhandledrejection', onBrowserUnhandledRejection); + } else { + process.off('unhandledRejection', onUnhandledRejection); + } + } + }); + + test('unmetering while a check is in flight does not start a concurrent check', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const meteredConnectionService = store.add(new TestMeteredConnectionService(false)); + const firstFetch = new DeferredPromise(); + let activeFetches = 0; + let maxActiveFetches = 0; + let fetchCount = 0; + const service = createService({ + meteredConnectionService, + pluginRepositoryService: { + fetchRepository: async () => { + fetchCount++; + activeFetches++; + maxActiveFetches = Math.max(maxActiveFetches, activeFetches); + try { + return fetchCount === 1 ? await firstFetch.p : false; + } finally { + activeFetches--; + } + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'), + makePlugin('my-plugin', 'my-plugin'), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + meteredConnectionService.setIsConnectionMetered(true); + meteredConnectionService.setIsConnectionMetered(false); + await timeout(0); + + assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 1, maxActiveFetches: 1 }); + + firstFetch.complete(false); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 1, maxActiveFetches: 1 }); + }); + + test('configuration changes during a check queue one rerun without overlapping fetches', async () => { + let runIdle: ((idle: IdleDeadline) => void) | undefined; + store.add(installFakeRunWhenIdle((_target, runner) => { + runIdle = runner; + return Disposable.None; + })); + const skippedRef = parseMarketplaceReference('microsoft/skipped')!; + const deferredRef = parseMarketplaceReference('microsoft/deferred')!; + const configurationService = new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [skippedRef.canonicalId, deferredRef.canonicalId], + [ChatConfiguration.PluginsEnabled]: true, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: 'microsoft/deferred' }], + }); + const firstFetch = new DeferredPromise(); + const fetched: string[] = []; + let activeFetches = 0; + let maxActiveFetches = 0; + const service = createService({ + configurationService, + pluginRepositoryService: { + fetchRepository: async reference => { + fetched.push(reference.canonicalId); + activeFetches++; + maxActiveFetches = Math.max(maxActiveFetches, activeFetches); + try { + return fetched.length === 1 ? await firstFetch.p : false; + } finally { + activeFetches--; + } + }, + }, + }); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/skipped/plugin'), + makePlugin('skipped', 'plugin', skippedRef), + ); + service.addInstalledPlugin( + URI.file('/agent-plugins/github.com/microsoft/deferred/plugin'), + makePlugin('deferred', 'plugin', deferredRef), + ); + + assert.ok(runIdle); + runIdle({ didTimeout: false, timeRemaining: () => 50 }); + await timeout(0); + assert.deepStrictEqual(fetched, [deferredRef.canonicalId]); + + await configurationService.setUserConfiguration(ChatConfiguration.StrictMarketplaces, [ + { source: 'github', repo: 'microsoft/skipped' }, + { source: 'github', repo: 'microsoft/deferred' }, + ]); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([ChatConfiguration.StrictMarketplaces]), + change: { keys: [ChatConfiguration.StrictMarketplaces], overrides: [] }, + affectsConfiguration: key => key === ChatConfiguration.StrictMarketplaces, + } satisfies IConfigurationChangeEvent); + await timeout(0); + assert.deepStrictEqual({ fetched, maxActiveFetches }, { fetched: [deferredRef.canonicalId], maxActiveFetches: 1 }); + + firstFetch.complete(false); + for (let i = 0; i < 5 && fetched.length < 3; i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + fetched, + maxActiveFetches, + }, { + fetched: [deferredRef.canonicalId, skippedRef.canonicalId, deferredRef.canonicalId], + maxActiveFetches: 1, + }); + }); + test('removeInstalledPlugin removes plugin from installedPlugins and metadata', () => { const service = createService(); const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'); @@ -908,6 +1217,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); @@ -961,6 +1271,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); }