From e60eb8c3b11db7698733256ba4cce3d096d3ddd8 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 10 Feb 2026 04:32:55 -0800 Subject: [PATCH] Fix double update issue on Windows (#292746) --- build/win32/code.iss | 16 ++- .../electron-main/abstractUpdateService.ts | 10 +- .../electron-main/updateService.win32.ts | 133 +++++++++++++++--- 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/build/win32/code.iss b/build/win32/code.iss index d889ca8c4d3..0d47e15103f 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1453,6 +1453,12 @@ begin Result := not (IsBackgroundUpdate() and FileExists(Path)); end; +// Check if VS Code created a cancel file to signal that the update should be aborted +function CancelFileExists(): Boolean; +begin + Result := FileExists(ExpandConstant('{param:cancel}')) +end; + function ShouldRunAfterUpdate(): Boolean; begin if IsBackgroundUpdate() then @@ -1639,11 +1645,17 @@ begin Log('Checking whether application is still running...'); while (CheckForMutexes('{#AppMutex}')) do begin + if CancelFileExists() then + begin + Log('Cancel file detected, aborting background update.'); + DeleteFile(ExpandConstant('{app}\updating_version')); + Abort; + end; Sleep(1000) end; Log('Application appears not to be running.'); - if not SessionEndFileExists() then begin + if not SessionEndFileExists() and not CancelFileExists() then begin StopTunnelServiceIfNeeded(); Log('Invoking inno_updater for background update'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); @@ -1657,7 +1669,7 @@ begin end; #endif end else begin - Log('Skipping inno_updater.exe call because OS session is ending'); + Log('Skipping inno_updater.exe call because OS session is ending or cancel was requested'); end; end else begin if IsVersionedUpdate() then begin diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0c396b41681..ed54d90f383 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -253,7 +253,15 @@ export abstract class AbstractUpdateService implements IUpdateService { if (isLatest === false && this._state.type === StateType.Ready) { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); - await this.cancelPendingUpdate(); + + try { + await this.cancelPendingUpdate(); + } catch (error) { + this.logService.error('update#checkForOverwriteUpdates(): failed to cancel pending update, aborting overwrite'); + this.logService.error(error); + return false; + } + this._overwrite = true; this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index c0248864079..7778a01ffa3 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; @@ -18,6 +18,7 @@ import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; +import { killTree } from '../../../base/node/processes.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { IFileService } from '../../files/common/files.js'; @@ -40,6 +41,10 @@ async function pollUntil(fn: () => boolean, millis = 1000): Promise { interface IAvailableUpdate { packagePath: string; updateFilePath?: string; + /** File path used to signal the Inno Setup installer to cancel */ + cancelFilePath?: string; + /** The Inno Setup process that is applying the update in the background */ + updateProcess?: ChildProcess; } let _updateType: UpdateType | undefined = undefined; @@ -75,7 +80,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @IProductService productService: IProductService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -168,14 +173,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return createUpdateURL(this.productService.updateUrl!, platform, quality, commit, options); } - protected doCheckForUpdates(explicit: boolean): void { + protected doCheckForUpdates(explicit: boolean, pendingCommit?: string): void { if (!this.quality) { return; } const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); - this.setState(State.CheckingForUpdates(explicit)); + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + + // Only set CheckingForUpdates if we're not already in Overwriting state + if (this.state.type !== StateType.Overwriting) { + this.setState(State.CheckingForUpdates(explicit)); + } this.requestService.request({ url }, CancellationToken.None) .then(asJson) @@ -183,7 +192,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const updateType = getUpdateType(); if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(updateType)); + // If we were checking for an overwrite update and found nothing newer, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(updateType)); + } return Promise.resolve(null); } @@ -249,10 +265,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.Downloaded(update, explicit, this._overwrite)); const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, explicit, this._overwrite)); } @@ -265,7 +279,15 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; - this.setState(State.Idle(getUpdateType(), message)); + + // If we were checking for an overwrite update and it failed, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), message)); + } }); } @@ -313,15 +335,36 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); + const cancelFilePath = path.join(cachePath, `cancel.flag`); + try { + await unlink(cancelFilePath); + } catch { + // ignore + } this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); + this.availableUpdate.cancelFilePath = cancelFilePath; await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, ['/verysilent', '/log', `/update="${this.availableUpdate.updateFilePath}"`, `/sessionend="${sessionEndFlagPath}"`, '/nocloseapplications', '/mergetasks=runcode,!desktopicon,!quicklaunchicon'], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true - }); + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true + } + ); + + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; child.once('exit', () => { this.availableUpdate = undefined; @@ -336,6 +379,58 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => this.setState(State.Ready(update, explicit, this._overwrite))); } + protected override async cancelPendingUpdate(): Promise { + if (!this.availableUpdate) { + return; + } + + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + if (updateProcess && updateProcess.exitCode === null) { + // Remove all listeners to prevent the exit handler from changing state + updateProcess.removeAllListeners(); + const exitPromise = new Promise(resolve => updateProcess.once('exit', () => resolve(true))); + + // Write the cancel file to signal Inno Setup to exit gracefully + if (cancelFilePath) { + try { + await pfs.Promises.writeFile(cancelFilePath, 'cancel'); + } catch (err) { + this.logService.warn('update#cancelPendingUpdate: failed to write cancel file', err); + } + } + + // Wait for the process to exit gracefully, then force-kill if needed + const pid = updateProcess.pid; + const exited = await Promise.race([exitPromise, timeout(30 * 1000).then(() => false)]); + if (pid && !exited) { + this.logService.trace('update#cancelPendingUpdate: process did not exit gracefully, killing process tree'); + await killTree(pid, true); + } + } + + // Clean up the flag file + if (updateFilePath) { + try { + await unlink(updateFilePath); + } catch (err) { + // ignore + } + } + + // Clean up the cancel file + if (cancelFilePath) { + try { + await unlink(cancelFilePath); + } catch (err) { + // ignore + } + } + + this.availableUpdate = undefined; + } + protected override doQuitAndInstall(): void { if (this.state.type !== StateType.Ready || !this.availableUpdate) { return; @@ -393,10 +488,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, true, false)); }