Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/vs/workbench/contrib/chat/browser/pluginAutoUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand All @@ -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<string>): Promise<void> {
if (this._updateInFlight) {
if (this._store.isDisposed || this._updateInFlight || this._meteredConnectionService.isConnectionMetered) {
return;
}

Expand All @@ -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();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -315,7 +316,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
private readonly _trustedMarketplacesStore: ObservableMemento<readonly string[]>;
private readonly _lastFetchedPluginsStore: ObservableMemento<IStoredLastFetchedPlugins>;
private readonly _marketplacesWithUpdates = observableValue<ReadonlySet<string>>('marketplacesWithUpdates', new Set());
private _updateCheckTimer: ReturnType<typeof setTimeout> | undefined;
private readonly _updateCheckDelayer = this._register(new ThrottledDelayer<void>(PLUGIN_UPDATE_CHECK_INTERVAL_MS));
private _updateCheckRunning = false;

readonly onDidChangeMarketplaces: Event<void>;

Expand All @@ -335,6 +337,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();

Expand Down Expand Up @@ -412,7 +415,14 @@ 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._updateCheckDelayer.cancel();
} else if (!this._updateCheckRunning && !this._updateCheckDelayer.isTriggered()) {
this._scheduleUpdateCheck();
}
}));
}));

Expand All @@ -429,14 +439,6 @@ 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<string>): void {
if (!marketplaceIds) {
this._marketplacesWithUpdates.set(new Set(), undefined);
Expand Down Expand Up @@ -823,16 +825,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.
* (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;
}

if (!this._hasAutoUpdateEnabledMarketplace()) {
private _scheduleUpdateCheck(delayOverride?: number): void {
this._updateCheckDelayer.cancel();
if (this._store.isDisposed
|| this._meteredConnectionService.isConnectionMetered
|| !this._hasAutoUpdateEnabledMarketplace()) {
return;
}

Expand All @@ -842,13 +842,25 @@ 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);
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);
}
}
}, delay);
}

private async _runUpdateCheck(): Promise<void> {
this._updateCheckTimer = undefined;
private async _doRunUpdateCheck(): Promise<void> {
if (this._meteredConnectionService.isConnectionMetered) {
return;
}

try {
const installed = this.installedPlugins.get();
Expand Down Expand Up @@ -887,11 +899,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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>());
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<typeof observableValue<ReadonlySet<string>>>;
updateAllCalls: IUpdateAllPluginsOptions[];
updateAllImpl: () => Promise<IUpdateAllPluginsResult>;
updateAllImpl: (token: CancellationToken) => Promise<IUpdateAllPluginsResult>;
clearUpdatesAvailableCalls: ReadonlySet<string>[];
}

function createContribution(stateOverrides?: Partial<MockState>): { contribution: PluginAutoUpdate; state: MockState } {
function createContribution(stateOverrides?: Partial<MockState>, 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<ReadonlySet<string>>('test.marketplacesWithUpdates', new Set()),
Expand All @@ -46,14 +66,15 @@ suite('PluginAutoUpdate', () => {
instantiationService.stub(IPluginInstallService, {
updateAllPlugins: async (options: IUpdateAllPluginsOptions, _token: CancellationToken): Promise<IUpdateAllPluginsResult> => {
state.updateAllCalls.push(options);
return state.updateAllImpl();
return state.updateAllImpl(_token);
},
} as Partial<IPluginInstallService> 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. */
Expand All @@ -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<IUpdateAllPluginsResult>(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<IUpdateAllPluginsResult>(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<IUpdateAllPluginsResult>(resolve => {
Expand Down
Loading
Loading