Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
45 changes: 44 additions & 1 deletion src/vs/platform/update/electron-main/abstractUpdateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat

lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen)
.finally(() => this.initialize());

this._register(this.meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => {
Comment thread
dmitrivMS marked this conversation as resolved.
if (!isMetered) {
this.resumeAutomaticUpdates();
}
}));
}

/**
Expand Down Expand Up @@ -310,6 +316,24 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat
}
}

private resumeAutomaticUpdates(): void {
if (this._disabledPermanently || !this.quality) {
return;
}

const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode');
if (updateMode === 'none' || updateMode === 'manual') {
Comment thread
dmitrivMS marked this conversation as resolved.
return;
}

if (this.state.type === StateType.AvailableForDownload) {
void this.downloadUpdate(false);
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
return;
}

this.scheduleCheckForUpdates(0, updateMode === 'default');
Comment thread
dmitrivMS marked this conversation as resolved.
}

private async trackVersionChange(): Promise<void> {
await this.applicationStorageMainService.whenReady;

Expand Down Expand Up @@ -408,6 +432,11 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat
return;
}

if (!explicit && this.meteredConnectionService.isConnectionMetered) {
Comment thread
dmitrivMS marked this conversation as resolved.
this.logService.info('update#checkForUpdates - skipping automatic check because connection is metered');
return;
}

this.doCheckForUpdates(explicit);
}

Expand Down Expand Up @@ -487,6 +516,11 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat
return false;
}

if (!explicit && this.meteredConnectionService.isConnectionMetered) {
this.logService.info('update#checkForOverwriteUpdates - skipping automatic check because connection is metered');
return false;
}

const pendingUpdateCommit = this._state.update.version;

if (!pendingUpdateCommit || pendingUpdateCommit === 'unknown') {
Expand All @@ -498,7 +532,7 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat
try {
const cts = new CancellationTokenSource();
const timeoutPromise = timeout(2000).then(() => { cts.cancel(); return undefined; });
isLatest = await Promise.race([this.isLatestVersion(pendingUpdateCommit, cts.token), timeoutPromise]);
isLatest = await Promise.race([this.doIsLatestVersion(pendingUpdateCommit, cts.token), timeoutPromise]);
cts.dispose();
} catch (error) {
this.logService.warn('update#checkForOverwriteUpdates(): failed to check for updates, proceeding with restart');
Expand Down Expand Up @@ -527,6 +561,15 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat
}

async isLatestVersion(commit?: string, token: CancellationToken = CancellationToken.None): Promise<boolean | undefined> {
if (this.meteredConnectionService.isConnectionMetered) {
this.logService.info('update#isLatestVersion - skipping automatic check because connection is metered');
return undefined;
}

return this.doIsLatestVersion(commit, token);
}

protected async doIsLatestVersion(commit?: string, token: CancellationToken = CancellationToken.None): Promise<boolean | undefined> {
if (!this.quality) {
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import assert from 'assert';
import * as sinon from 'sinon';
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
import { Event } from '../../../../base/common/event.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationValue } from '../../../configuration/common/configuration.js';
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
Expand All @@ -21,6 +22,22 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j
import { DisablementReason, State, StateType } from '../../common/update.js';
import { AbstractUpdateService, IUpdateURLOptions } from '../../electron-main/abstractUpdateService.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);
}
}

class TestUpdateService extends AbstractUpdateService {

private readonly _initialized = new DeferredPromise<void>();
Expand All @@ -32,6 +49,9 @@ class TestUpdateService extends AbstractUpdateService {
private _cancelCount = 0;
get cancelCount(): number { return this._cancelCount; }

private _downloadCount = 0;
get downloadCount(): number { return this._downloadCount; }

/** When set, `cancelUpdate` blocks on this promise so tests can observe the transient Cancelling state. */
private _cancelGate: Promise<void> | undefined;
blockCancelUpdate(gate: Promise<void>): void { this._cancelGate = gate; }
Expand All @@ -57,6 +77,14 @@ class TestUpdateService extends AbstractUpdateService {
this._checkCount++;
}

protected override async doDownloadUpdate(): Promise<void> {
this._downloadCount++;
}

checkLatestVersionExplicitly(): Promise<boolean | undefined> {
return this.doIsLatestVersion();
}

protected override async cancelUpdate(): Promise<void> {
this._cancelCount++;
if (this._cancelGate) {
Expand Down Expand Up @@ -91,10 +119,13 @@ suite('AbstractUpdateService', () => {
}

let configurationService: PolicyTestConfigurationService;
let requestCount: number;
let meteredConnectionService: TestMeteredConnectionService;

function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string }): TestUpdateService {
function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string; isConnectionMetered?: boolean }): TestUpdateService {
configurationService = new PolicyTestConfigurationService();
configurationService.setUserConfiguration('update.mode', mode);
requestCount = 0;

const lifecycleMainService = {
when: () => Promise.resolve(),
Expand All @@ -109,7 +140,10 @@ suite('AbstractUpdateService', () => {
} as unknown as IEnvironmentMainService;

const requestService = {
request: () => Promise.reject(new Error('not expected'))
request: () => {
requestCount++;
return Promise.reject(new Error('not expected'));
}
} as unknown as IRequestService;

const productService = {
Expand All @@ -126,7 +160,7 @@ suite('AbstractUpdateService', () => {
store: () => { }
} as unknown as IApplicationStorageMainService;

const meteredConnectionService = { isConnectionMetered: false } as unknown as IMeteredConnectionService;
meteredConnectionService = store.add(new TestMeteredConnectionService(options?.isConnectionMetered ?? false));

const service = new TestUpdateService(
lifecycleMainService,
Expand Down Expand Up @@ -223,6 +257,46 @@ suite('AbstractUpdateService', () => {
}
});

test('metered connections skip automatic update requests but allow explicit actions', async () => {
const service = createService('default', { isConnectionMetered: true });
await service.whenInitialized;

await service.checkForUpdates(false);
await service.isLatestVersion();
await service.checkForUpdates(true);
await service.checkLatestVersionExplicitly();

service.forceState(State.AvailableForDownload({ version: '1.1.0', productVersion: '1.1.0', url: 'https://update.example/download' }));
await service.downloadUpdate(false);
await service.downloadUpdate(true);

assert.deepStrictEqual({
checkCount: service.checkCount,
downloadCount: service.downloadCount,
requestCount,
}, {
checkCount: 1,
downloadCount: 1,
requestCount: 1,
});
});

test('automatic checks resume when the connection is no longer metered', async () => {
const clock = sinon.useFakeTimers();
try {
const service = createService('start', { isConnectionMetered: true });
await service.whenInitialized;
await clock.tickAsync(30 * 1000);
assert.strictEqual(service.checkCount, 0);

meteredConnectionService.setIsConnectionMetered(false);
await clock.tickAsync(0);
assert.strictEqual(service.checkCount, 1);
} finally {
clock.restore();
}
});

test('permanent disablement ignores runtime mode changes', async () => {
const service = createService('default', { isBuilt: false });
await service.whenInitialized;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class MeteredConnectionStatusContribution extends Disposable implements I
name: localize('status.meteredConnection', "Metered Connection"),
text: '$(radio-tower)',
ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Enabled"),
tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some automatic features like extension updates, Settings Sync, and automatic Git operations are paused to reduce data usage."),
tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Background network activity including updates, Settings Sync, inline completions, telemetry, and automatic Git operations is paused to reduce data usage."),
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
command: {
id: 'workbench.action.configureMeteredConnection',
title: localize('status.meteredConnection.configure', "Configure")
Expand Down
6 changes: 6 additions & 0 deletions src/vs/workbench/contrib/update/browser/postUpdateWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js';
import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js';
import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js';
Expand Down Expand Up @@ -52,6 +53,7 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben
@IHoverService private readonly hoverService: IHoverService,
@ILayoutService private readonly layoutService: ILayoutService,
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
@IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService,
@IOpenerService private readonly openerService: IOpenerService,
@IProductService private readonly productService: IProductService,
@IRequestService private readonly requestService: IRequestService,
Expand All @@ -73,6 +75,10 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben
return;
}

if (this.meteredConnectionService.isConnectionMetered) {
return;
}

if (!this.detectVersionChange()) {
return;
}
Expand Down
17 changes: 5 additions & 12 deletions src/vs/workbench/contrib/update/browser/updateTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, Overwriting, Ready, Restarting, State, StateType, Updating } from '../../../../platform/update/common/update.js';
import { ShowCurrentReleaseNotesActionId } from '../common/update.js';
Expand Down Expand Up @@ -65,7 +64,6 @@ export class UpdateTooltip extends Disposable {
@ICommandService private readonly commandService: ICommandService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IHoverService private readonly hoverService: IHoverService,
@IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService,
@IProductService private readonly productService: IProductService,
) {
super();
Expand Down Expand Up @@ -275,8 +273,9 @@ export class UpdateTooltip extends Disposable {
return;
}

const updateMode = this.configurationService.getValue<string>('update.mode');
this.renderTitleAndInfo(localize('updateTooltip.upToDateTitle', "Up to Date"));
switch (this.configurationService.getValue<string>('update.mode')) {
switch (updateMode) {
case 'none':
this.renderMessage(localize('updateTooltip.autoUpdateNone', "Automatic updates are disabled."), Codicon.warning);
break;
Expand All @@ -287,15 +286,9 @@ export class UpdateTooltip extends Disposable {
this.renderMessage(localize('updateTooltip.autoUpdateStart', "Updates will be applied on restart."));
break;
case 'default':
if (this.meteredConnectionService.isConnectionMetered) {
this.renderMessage(
localize('updateTooltip.meteredConnectionMessage', "Automatic updates are paused because the network connection is metered."),
Codicon.radioTower);
} else {
this.renderMessage(
localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"),
Codicon.smiley);
}
this.renderMessage(
localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"),
Codicon.smiley);
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { timeout } from '../../../../../base/common/async.js';
import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js';
import { IRequestContext } from '../../../../../base/parts/request/common/request.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js';
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js';
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { IRequestService } from '../../../../../platform/request/common/request.js';
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { IHostService } from '../../../../services/host/browser/host.js';
import { PostUpdateWidgetContribution } from '../../browser/postUpdateWidget.js';

class TestRequestService extends mock<IRequestService>() {
requestCount = 0;

override async request(): Promise<IRequestContext> {
this.requestCount++;
return {
res: { statusCode: 200, headers: {} },
stream: bufferToStream(VSBuffer.fromString('')),
};
}
}

suite('PostUpdateWidgetContribution', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();

test('skips the automatic request while metered but preserves the explicit command', async () => {
const requestService = new TestRequestService();
const configurationService = new TestConfigurationService();
store.add(configurationService.onDidChangeConfigurationEmitter);
store.add(new PostUpdateWidgetContribution(
new class extends mock<ICommandService>() { },
configurationService,
new class extends mock<IHostService>() {
override hadLastFocus(): Promise<boolean> {
return Promise.resolve(true);
}
},
new class extends mock<IHoverService>() { },
new class extends mock<ILayoutService>() { },
new class extends mock<IMarkdownRendererService>() { },
new class extends mock<IMeteredConnectionService>() {
override readonly isConnectionMetered = true;
},
new class extends mock<IOpenerService>() { },
new class extends mock<IProductService>() {
override readonly version = '1.135.0';
override readonly commit = 'current';
},
requestService,
new class extends mock<IStorageService>() { },
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
new class extends mock<ITelemetryService>() { },
));

await timeout(0);
assert.strictEqual(requestService.requestCount, 0);

const command = CommandsRegistry.getCommand('_update.showUpdateInfo');
assert.ok(command);
await command.handler(undefined as never);
assert.strictEqual(requestService.requestCount, 1);
});
});
Loading
Loading