From fb5d407f8b5d5ecc52e97bad1ec831939683be3c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 12:09:37 -0700 Subject: [PATCH 1/7] chat: pause plugin auto-updates on metered connections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/pluginAutoUpdate.ts | 45 +++++++-- .../plugins/pluginMarketplaceService.ts | 40 ++++++-- .../browser/plugins/pluginAutoUpdate.test.ts | 92 ++++++++++++++++++- .../plugins/pluginMarketplaceService.test.ts | 82 ++++++++++++++++- 4 files changed, 235 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index 28c761987f1570..58745fb25912b6 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, MutableDisposable, toDisposable } 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'; @@ -31,11 +32,13 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi static readonly ID = 'workbench.contrib.pluginAutoUpdate'; private _updateInFlight = false; + private readonly _updateCancellation = this._register(new MutableDisposable()); constructor( @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IPluginInstallService private readonly _pluginInstallService: IPluginInstallService, @ILogService private readonly _logService: ILogService, + @IMeteredConnectionService private readonly _meteredConnectionService: IMeteredConnectionService, ) { super(); @@ -46,21 +49,51 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } void this._triggerAutoUpdate(marketplaceIds); })); + + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (isMetered) { + this._updateCancellation.clear(); + return; + } + + 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._updateInFlight || this._meteredConnectionService.isConnectionMetered) { return; } this._updateInFlight = true; + const cancellationTokenSource = new CancellationTokenSource(); + const cancellation = toDisposable(() => cancellationTokenSource.dispose(true)); + this._updateCancellation.value = cancellation; try { - await this._pluginInstallService.updateAllPlugins({ silent: true, automatic: true, marketplaceIds }, CancellationToken.None); + await this._pluginInstallService.updateAllPlugins({ silent: true, automatic: true, marketplaceIds }, cancellationTokenSource.token); } catch (err) { - this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); + if (!cancellationTokenSource.token.isCancellationRequested) { + this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); + } } finally { + const wasCancelled = cancellationTokenSource.token.isCancellationRequested; + if (this._updateCancellation.value === cancellation) { + this._updateCancellation.clear(); + } + if (!wasCancelled) { + this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); + } this._updateInFlight = false; - this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); + + if (!this._meteredConnectionService.isConnectionMetered) { + const queuedMarketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); + if (queuedMarketplaceIds.size > 0) { + void this._triggerAutoUpdate(queuedMarketplaceIds); + } + } } } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index bb0c3d7a3bbfed..a1ae4ecf496b8e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -18,6 +18,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'; @@ -335,6 +336,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(); @@ -416,6 +418,14 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke })); })); + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (isMetered) { + this._clearUpdateCheckTimer(); + } else { + this._scheduleUpdateCheck(); + } + })); + // Hydrate plugin metadata for installed entries that are not yet in // the in-memory cache (e.g. after restart when installed.json is read // but the metadata map is empty). Modern entries match by plugin name; @@ -430,10 +440,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } override dispose(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } + this._clearUpdateCheckTimer(); super.dispose(); } @@ -827,12 +834,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke * construction and whenever the auto-update config changes. */ private _scheduleUpdateCheck(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } + this._clearUpdateCheckTimer(); - if (!this._hasAutoUpdateEnabledMarketplace()) { + if (this._meteredConnectionService.isConnectionMetered || !this._hasAutoUpdateEnabledMarketplace()) { return; } @@ -847,8 +851,18 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), delay); } + private _clearUpdateCheckTimer(): void { + if (this._updateCheckTimer !== undefined) { + clearTimeout(this._updateCheckTimer); + this._updateCheckTimer = undefined; + } + } + private async _runUpdateCheck(): Promise { this._updateCheckTimer = undefined; + if (this._meteredConnectionService.isConnectionMetered) { + return; + } try { const installed = this.installedPlugins.get(); @@ -860,6 +874,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const marketplacesWithUpdates = new Set(); for (const entry of installed) { + if (this._meteredConnectionService.isConnectionMetered) { + return; + } const ref = entry.plugin.marketplaceReference; if (seenMarketplaces.has(ref.canonicalId) || !this.isMarketplaceAutoUpdateEnabled(ref) @@ -870,6 +887,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke try { const behind = await this._pluginRepositoryService.fetchRepository(ref); + if (this._meteredConnectionService.isConnectionMetered) { + return; + } if (behind) { marketplacesWithUpdates.add(ref.canonicalId); } @@ -889,7 +909,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this._logService.debug('[PluginMarketplaceService] Periodic update check failed:', err); } finally { // Reschedule for the next check - if (this._hasAutoUpdateEnabledMarketplace()) { + if (!this._meteredConnectionService.isConnectionMetered && 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..0f9766abb607f0 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,69 @@ 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('cancels an in-flight update when the connection becomes metered and resumes it later', async () => { + let resolveUpdate!: () => void; + let observedToken: CancellationToken | undefined; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { state, meteredConnectionService } = createContribution({ + updateAllImpl: token => { + observedToken = token; + return 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()], + wasCancelled: observedToken?.isCancellationRequested, + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 0, + updateStillQueued: ['a'], + wasCancelled: true, + }); + + meteredConnectionService.setIsConnectionMetered(false); + await flushMicrotasks(); + assert.strictEqual(state.updateAllCalls.length, 2); + }); + 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..3f4d66a26a6722 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,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../../base/common/async.js'; +import { 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 { joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -18,6 +19,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati 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 +30,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 +459,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 +497,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 +556,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 +620,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => autoUpdate, } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } @@ -664,7 +696,10 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { }; } - function createService(): PluginMarketplaceService { + function createService(options?: { + meteredConnectionService?: IMeteredConnectionService; + pluginRepositoryService?: Partial; + }): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ @@ -673,7 +708,10 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { })); 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 +726,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 +748,39 @@ 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('removeInstalledPlugin removes plugin from installedPlugins and metadata', () => { const service = createService(); const uri = URI.file('/agent-plugins/github.com/microsoft/plugins/my-plugin'); @@ -908,6 +980,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 +1034,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'on', } as Partial as IExtensionsWorkbenchService); + stubMeteredConnectionService(instantiationService); return store.add(instantiationService.createInstance(PluginMarketplaceService)); } From eb3244127b803dfe5ecdd1e270de6e94fe881e40 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 12:24:51 -0700 Subject: [PATCH 2/7] chat: avoid plugin update lifecycle races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/pluginAutoUpdate.ts | 4 +- .../plugins/pluginMarketplaceService.ts | 38 ++++++++++---- .../browser/plugins/pluginAutoUpdate.test.ts | 33 +++++++++++++ .../plugins/pluginMarketplaceService.test.ts | 49 ++++++++++++++++++- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index 58745fb25912b6..4b70477ab4a9df 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -64,7 +64,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } private async _triggerAutoUpdate(marketplaceIds: ReadonlySet): Promise { - if (this._updateInFlight || this._meteredConnectionService.isConnectionMetered) { + if (this._store.isDisposed || this._updateInFlight || this._meteredConnectionService.isConnectionMetered) { return; } @@ -88,7 +88,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } this._updateInFlight = false; - if (!this._meteredConnectionService.isConnectionMetered) { + if (!this._store.isDisposed && !this._meteredConnectionService.isConnectionMetered) { const queuedMarketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); if (queuedMarketplaceIds.size > 0) { void this._triggerAutoUpdate(queuedMarketplaceIds); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index a1ae4ecf496b8e..01c9ab94e85672 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -317,6 +317,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private readonly _lastFetchedPluginsStore: ObservableMemento; private readonly _marketplacesWithUpdates = observableValue>('marketplacesWithUpdates', new Set()); private _updateCheckTimer: ReturnType | undefined; + private _updateCheckPromise: Promise | undefined; + private _updateCheckRescheduleRequested = false; readonly onDidChangeMarketplaces: Event; @@ -833,10 +835,14 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke * (Re-)schedules the next periodic update check. Called on * construction and whenever the auto-update config changes. */ - private _scheduleUpdateCheck(): void { + private _scheduleUpdateCheck(delayOverride?: number): void { this._clearUpdateCheckTimer(); - if (this._meteredConnectionService.isConnectionMetered || !this._hasAutoUpdateEnabledMarketplace()) { + if (this._store.isDisposed || this._meteredConnectionService.isConnectionMetered || !this._hasAutoUpdateEnabledMarketplace()) { + return; + } + if (this._updateCheckPromise) { + this._updateCheckRescheduleRequested = true; return; } @@ -846,9 +852,9 @@ 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._updateCheckTimer = setTimeout(() => { void this._runUpdateCheck(); }, delay); } private _clearUpdateCheckTimer(): void { @@ -858,8 +864,25 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } } - private async _runUpdateCheck(): Promise { + private _runUpdateCheck(): Promise { this._updateCheckTimer = undefined; + if (this._updateCheckPromise) { + return this._updateCheckPromise; + } + + const promise = this._doRunUpdateCheck().finally(() => { + if (this._updateCheckPromise === promise) { + this._updateCheckPromise = undefined; + const rescheduleImmediately = this._updateCheckRescheduleRequested; + this._updateCheckRescheduleRequested = false; + this._scheduleUpdateCheck(rescheduleImmediately ? 0 : PLUGIN_UPDATE_CHECK_INTERVAL_MS); + } + }); + this._updateCheckPromise = promise; + return promise; + } + + private async _doRunUpdateCheck(): Promise { if (this._meteredConnectionService.isConnectionMetered) { return; } @@ -907,11 +930,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._meteredConnectionService.isConnectionMetered && 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 0f9766abb607f0..05d82bdc4097e6 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 @@ -164,6 +164,39 @@ suite('PluginAutoUpdate', () => { assert.strictEqual(state.updateAllCalls.length, 2); }); + test('disposing during an in-flight update does not restart queued work', async () => { + let resolveUpdate!: () => void; + let observedToken: CancellationToken | undefined; + const pendingUpdate = new Promise(resolve => { + resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); + }); + const { contribution, state } = createContribution({ + updateAllImpl: token => { + observedToken = token; + return 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()], + wasCancelled: observedToken?.isCancellationRequested, + }, { + updateAllCalls: 1, + clearUpdatesAvailableCalls: 0, + updateStillQueued: ['a'], + wasCancelled: true, + }); + }); + 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 3f4d66a26a6722..79e5b7aec6a678 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,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { installFakeRunWhenIdle, timeout } from '../../../../../../base/common/async.js'; +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 { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -781,6 +781,53 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(fetchCount, 1); }); + 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: 2, 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'); From 4d9a809b0281deb56d394b3044b56cd40e61ee5a Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 12:36:21 -0700 Subject: [PATCH 3/7] chat: simplify metered plugin updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/pluginAutoUpdate.ts | 35 ++++++------------- .../plugins/pluginMarketplaceService.ts | 20 +++-------- .../browser/plugins/pluginAutoUpdate.test.ts | 28 +++++---------- .../plugins/pluginMarketplaceService.test.ts | 2 +- 4 files changed, 24 insertions(+), 61 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index 4b70477ab4a9df..ef3bdacae4b746 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +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'; @@ -32,7 +32,6 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi static readonly ID = 'workbench.contrib.pluginAutoUpdate'; private _updateInFlight = false; - private readonly _updateCancellation = this._register(new MutableDisposable()); constructor( @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @@ -51,14 +50,11 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi })); this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { - if (isMetered) { - this._updateCancellation.clear(); - return; - } - - const marketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); - if (marketplaceIds.size > 0) { - void this._triggerAutoUpdate(marketplaceIds); + if (!isMetered) { + const marketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); + if (marketplaceIds.size > 0) { + void this._triggerAutoUpdate(marketplaceIds); + } } })); } @@ -69,23 +65,12 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi } this._updateInFlight = true; - const cancellationTokenSource = new CancellationTokenSource(); - const cancellation = toDisposable(() => cancellationTokenSource.dispose(true)); - this._updateCancellation.value = cancellation; try { - await this._pluginInstallService.updateAllPlugins({ silent: true, automatic: true, marketplaceIds }, cancellationTokenSource.token); + await this._pluginInstallService.updateAllPlugins({ silent: true, automatic: true, marketplaceIds }, CancellationToken.None); } catch (err) { - if (!cancellationTokenSource.token.isCancellationRequested) { - this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); - } + this._logService.error('[PluginAutoUpdate] Failed to auto-update plugins:', err); } finally { - const wasCancelled = cancellationTokenSource.token.isCancellationRequested; - if (this._updateCancellation.value === cancellation) { - this._updateCancellation.clear(); - } - if (!wasCancelled) { - this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); - } + this._pluginMarketplaceService.clearUpdatesAvailable(marketplaceIds); this._updateInFlight = false; if (!this._store.isDisposed && !this._meteredConnectionService.isConnectionMetered) { diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 01c9ab94e85672..fbed139938d2e1 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -318,7 +318,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private readonly _marketplacesWithUpdates = observableValue>('marketplacesWithUpdates', new Set()); private _updateCheckTimer: ReturnType | undefined; private _updateCheckPromise: Promise | undefined; - private _updateCheckRescheduleRequested = false; readonly onDidChangeMarketplaces: Event; @@ -838,11 +837,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private _scheduleUpdateCheck(delayOverride?: number): void { this._clearUpdateCheckTimer(); - if (this._store.isDisposed || this._meteredConnectionService.isConnectionMetered || !this._hasAutoUpdateEnabledMarketplace()) { - return; - } - if (this._updateCheckPromise) { - this._updateCheckRescheduleRequested = true; + if (this._store.isDisposed + || this._updateCheckPromise + || this._meteredConnectionService.isConnectionMetered + || !this._hasAutoUpdateEnabledMarketplace()) { return; } @@ -873,9 +871,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const promise = this._doRunUpdateCheck().finally(() => { if (this._updateCheckPromise === promise) { this._updateCheckPromise = undefined; - const rescheduleImmediately = this._updateCheckRescheduleRequested; - this._updateCheckRescheduleRequested = false; - this._scheduleUpdateCheck(rescheduleImmediately ? 0 : PLUGIN_UPDATE_CHECK_INTERVAL_MS); + this._scheduleUpdateCheck(PLUGIN_UPDATE_CHECK_INTERVAL_MS); } }); this._updateCheckPromise = promise; @@ -897,9 +893,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const marketplacesWithUpdates = new Set(); for (const entry of installed) { - if (this._meteredConnectionService.isConnectionMetered) { - return; - } const ref = entry.plugin.marketplaceReference; if (seenMarketplaces.has(ref.canonicalId) || !this.isMarketplaceAutoUpdateEnabled(ref) @@ -910,9 +903,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke try { const behind = await this._pluginRepositoryService.fetchRepository(ref); - if (this._meteredConnectionService.isConnectionMetered) { - return; - } if (behind) { marketplacesWithUpdates.add(ref.canonicalId); } 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 05d82bdc4097e6..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 @@ -127,17 +127,13 @@ suite('PluginAutoUpdate', () => { }); }); - test('cancels an in-flight update when the connection becomes metered and resumes it later', async () => { + test('allows an in-flight update to finish after the connection becomes metered', async () => { let resolveUpdate!: () => void; - let observedToken: CancellationToken | undefined; const pendingUpdate = new Promise(resolve => { resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); }); const { state, meteredConnectionService } = createContribution({ - updateAllImpl: token => { - observedToken = token; - return pendingUpdate; - }, + updateAllImpl: () => pendingUpdate, }); state.marketplacesWithUpdates.set(new Set(['a']), undefined); @@ -151,30 +147,24 @@ suite('PluginAutoUpdate', () => { updateAllCalls: state.updateAllCalls.length, clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, updateStillQueued: [...state.marketplacesWithUpdates.get()], - wasCancelled: observedToken?.isCancellationRequested, }, { updateAllCalls: 1, - clearUpdatesAvailableCalls: 0, - updateStillQueued: ['a'], - wasCancelled: true, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], }); meteredConnectionService.setIsConnectionMetered(false); await flushMicrotasks(); - assert.strictEqual(state.updateAllCalls.length, 2); + assert.strictEqual(state.updateAllCalls.length, 1); }); test('disposing during an in-flight update does not restart queued work', async () => { let resolveUpdate!: () => void; - let observedToken: CancellationToken | undefined; const pendingUpdate = new Promise(resolve => { resolveUpdate = () => resolve({ updatedNames: [], failedNames: [] }); }); const { contribution, state } = createContribution({ - updateAllImpl: token => { - observedToken = token; - return pendingUpdate; - }, + updateAllImpl: () => pendingUpdate, }); state.marketplacesWithUpdates.set(new Set(['a']), undefined); @@ -188,12 +178,10 @@ suite('PluginAutoUpdate', () => { updateAllCalls: state.updateAllCalls.length, clearUpdatesAvailableCalls: state.clearUpdatesAvailableCalls.length, updateStillQueued: [...state.marketplacesWithUpdates.get()], - wasCancelled: observedToken?.isCancellationRequested, }, { updateAllCalls: 1, - clearUpdatesAvailableCalls: 0, - updateStillQueued: ['a'], - wasCancelled: true, + clearUpdatesAvailableCalls: 1, + updateStillQueued: [], }); }); 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 79e5b7aec6a678..e6aac0bef12ae5 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 @@ -825,7 +825,7 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { await timeout(0); await timeout(0); - assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 2, maxActiveFetches: 1 }); + assert.deepStrictEqual({ fetchCount, maxActiveFetches }, { fetchCount: 1, maxActiveFetches: 1 }); }); test('removeInstalledPlugin removes plugin from installedPlugins and metadata', () => { From 307274dcf3af7ac1dd61efb02fa7a4b40ee01de9 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 17:09:49 -0700 Subject: [PATCH 4/7] Preserve plugin update startup idle gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/pluginAutoUpdate.ts | 17 +++++----- .../plugins/pluginMarketplaceService.ts | 19 +++++------ .../plugins/pluginMarketplaceService.test.ts | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts index ef3bdacae4b746..712cb3c1d84954 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts @@ -51,14 +51,18 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { if (!isMetered) { - const marketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); - if (marketplaceIds.size > 0) { - void this._triggerAutoUpdate(marketplaceIds); - } + 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._store.isDisposed || this._updateInFlight || this._meteredConnectionService.isConnectionMetered) { return; @@ -74,10 +78,7 @@ export class PluginAutoUpdate extends Disposable implements IWorkbenchContributi this._updateInFlight = false; if (!this._store.isDisposed && !this._meteredConnectionService.isConnectionMetered) { - const queuedMarketplaceIds = this._pluginMarketplaceService.marketplacesWithUpdates.get(); - if (queuedMarketplaceIds.size > 0) { - void this._triggerAutoUpdate(queuedMarketplaceIds); - } + 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 fbed139938d2e1..5d3ff777c84e47 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -417,14 +417,13 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this.clearUpdatesAvailable(); this._scheduleUpdateCheck(); })); - })); - - this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { - if (isMetered) { - this._clearUpdateCheckTimer(); - } else { - this._scheduleUpdateCheck(); - } + this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + if (isMetered) { + this._clearUpdateCheckTimer(); + } else { + this._scheduleUpdateCheck(); + } + })); })); // Hydrate plugin metadata for installed entries that are not yet in @@ -831,8 +830,8 @@ 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(delayOverride?: number): void { this._clearUpdateCheckTimer(); 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 e6aac0bef12ae5..75588d36b49a51 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 @@ -781,6 +781,40 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { assert.strictEqual(fetchCount, 1); }); + 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('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) => { From 36f45203027ea7f351bbe19d7946b69f3986845c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 19:07:12 -0700 Subject: [PATCH 5/7] chat: coalesce plugin update checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 54 ++++-------- .../plugins/pluginMarketplaceService.test.ts | 84 +++++++++++++++++-- 2 files changed, 96 insertions(+), 42 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 5d3ff777c84e47..5da33dbe74658c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -3,7 +3,7 @@ * 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 { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; @@ -316,8 +316,8 @@ 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 _updateCheckPromise: Promise | undefined; + private readonly _updateCheckDelayer = this._register(new ThrottledDelayer(PLUGIN_UPDATE_CHECK_INTERVAL_MS)); + private _updateCheckRunning = false; readonly onDidChangeMarketplaces: Event; @@ -415,12 +415,12 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke || e.affectsConfiguration(ChatConfiguration.StrictMarketplaces), )(() => { this.clearUpdatesAvailable(); - this._scheduleUpdateCheck(); + this._scheduleUpdateCheck(0); })); this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { if (isMetered) { - this._clearUpdateCheckTimer(); - } else { + this._updateCheckDelayer.cancel(); + } else if (!this._updateCheckRunning && !this._updateCheckDelayer.isTriggered()) { this._scheduleUpdateCheck(); } })); @@ -439,11 +439,6 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke })); } - override dispose(): void { - this._clearUpdateCheckTimer(); - super.dispose(); - } - clearUpdatesAvailable(marketplaceIds?: ReadonlySet): void { if (!marketplaceIds) { this._marketplacesWithUpdates.set(new Set(), undefined); @@ -834,10 +829,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke * whenever the auto-update config or metered connection state changes. */ private _scheduleUpdateCheck(delayOverride?: number): void { - this._clearUpdateCheckTimer(); - + this._updateCheckDelayer.cancel(); if (this._store.isDisposed - || this._updateCheckPromise || this._meteredConnectionService.isConnectionMetered || !this._hasAutoUpdateEnabledMarketplace()) { return; @@ -851,30 +844,17 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const elapsed = Date.now() - lastCheck; const delay = delayOverride ?? Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); - this._updateCheckTimer = setTimeout(() => { void this._runUpdateCheck(); }, delay); - } - - private _clearUpdateCheckTimer(): void { - if (this._updateCheckTimer !== undefined) { - clearTimeout(this._updateCheckTimer); - this._updateCheckTimer = undefined; - } - } - - private _runUpdateCheck(): Promise { - this._updateCheckTimer = undefined; - if (this._updateCheckPromise) { - return this._updateCheckPromise; - } - - const promise = this._doRunUpdateCheck().finally(() => { - if (this._updateCheckPromise === promise) { - this._updateCheckPromise = undefined; - this._scheduleUpdateCheck(PLUGIN_UPDATE_CHECK_INTERVAL_MS); + void 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); + } } - }); - this._updateCheckPromise = promise; - return promise; + }, delay); } private async _doRunUpdateCheck(): Promise { 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 75588d36b49a51..c1d6394494ae15 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 @@ -14,7 +14,7 @@ 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'; @@ -683,26 +683,27 @@ 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(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, })); @@ -862,6 +863,79 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { 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'); From 59464b2aae0860d645fa5818c32cce60e40d4776 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 13:24:22 -0700 Subject: [PATCH 6/7] Handle cancelled plugin update checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 9 +++-- .../plugins/pluginMarketplaceService.test.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 5da33dbe74658c..8d63fcfb9c4942 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -5,6 +5,7 @@ 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'; @@ -844,7 +845,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const elapsed = Date.now() - lastCheck; const delay = delayOverride ?? Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); - void this._updateCheckDelayer.trigger(async () => { + this._updateCheckDelayer.trigger(async () => { this._updateCheckRunning = true; try { await this._doRunUpdateCheck(); @@ -854,7 +855,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke this._scheduleUpdateCheck(PLUGIN_UPDATE_CHECK_INTERVAL_MS); } } - }, delay); + }, delay).catch(error => { + if (!isCancellationError(error)) { + onUnexpectedError(error); + } + }); } private async _doRunUpdateCheck(): Promise { 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 c1d6394494ae15..d790a0e41f2a47 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 @@ -10,6 +10,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba 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'; @@ -816,6 +817,39 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { 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) => { From f8531171436e95cf088d4e73f39edf3df5f1ffa9 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 20 Aug 2026 13:52:11 -0700 Subject: [PATCH 7/7] Coordinate plugin checks with queued updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 21 +++++--- .../plugins/pluginMarketplaceService.test.ts | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 8d63fcfb9c4942..141051b8be9346 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -318,6 +318,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke private readonly _lastFetchedPluginsStore: ObservableMemento; private readonly _marketplacesWithUpdates = observableValue>('marketplacesWithUpdates', new Set()); private readonly _updateCheckDelayer = this._register(new ThrottledDelayer(PLUGIN_UPDATE_CHECK_INTERVAL_MS)); + private _updateChecksInitialized = false; private _updateCheckRunning = false; readonly onDidChangeMarketplaces: Event; @@ -408,6 +409,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); this._register(runWhenGlobalIdle(() => { + this._updateChecksInitialized = true; this._scheduleUpdateCheck(); this._register(Event.filter( _configurationService.onDidChangeConfiguration, @@ -415,7 +417,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke || e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces) || e.affectsConfiguration(ChatConfiguration.StrictMarketplaces), )(() => { - this.clearUpdatesAvailable(); + this._marketplacesWithUpdates.set(new Set(), undefined); this._scheduleUpdateCheck(0); })); this._register(this._meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { @@ -441,12 +443,17 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } 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 { @@ -831,8 +838,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke */ private _scheduleUpdateCheck(delayOverride?: number): void { this._updateCheckDelayer.cancel(); + if (this._store.isDisposed || this._meteredConnectionService.isConnectionMetered + || this._marketplacesWithUpdates.get().size > 0 || !this._hasAutoUpdateEnabledMarketplace()) { return; } 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 d790a0e41f2a47..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,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +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'; @@ -783,6 +784,53 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { 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) => {