From 2dd3abcf33aa44f9a45f63898b3bbeca002239fd Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 26 May 2026 18:44:19 -0700 Subject: [PATCH 1/6] fix(update): skip Inno spawn when another instance is already installing Two VS Code instances (e.g. a normal one plus one started with --transient) sharing the same install can both reach doApplyUpdate for the same downloaded package and each spawn Inno Setup. The second spawn races into Inno's 'Setup is already running' modal. Check the existing '-updating' mutex before spawning. If another instance is already running setup, skip the spawn and rely on the existing '-ready' mutex polling loop to transition this instance to Ready when the install finishes. --- .../electron-main/updateService.win32.ts | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index bbbc3bde21da4..e830025192082 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -353,36 +353,47 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate.cancelFilePath = cancelFilePath; await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, - [ - '/verysilent', - '/log', - `/update="${this.availableUpdate.updateFilePath}"`, - `/progress="${progressFilePath}"`, - `/sessionend="${sessionEndFlagPath}"`, - `/cancel="${cancelFilePath}"`, - '/nocloseapplications', - '/mergetasks=runcode,!desktopicon,!quicklaunchicon' - ], - { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true, - env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } - } - ); - - // Track the process so we can cancel it if needed - this.availableUpdate.updateProcess = child; - - child.once('exit', () => { - this.availableUpdate = undefined; - this.setState(State.Idle(getUpdateType())); - }); const readyMutexName = `${this.productService.win32MutexName}-ready`; + const updatingMutexName = `${this.productService.win32MutexName}-updating`; const mutex = await import('@vscode/windows-mutex'); + // Another VS Code instance may have already launched the Inno Setup installer + // for the same update. Spawning a second one races into Inno's "Setup is + // already running" modal. Skip the spawn and rely on the `-ready` mutex + // polling below to advance our own state machine when the install finishes. + const alreadyInstalling = mutex.isActive(updatingMutexName); + if (alreadyInstalling) { + this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); + } else { + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/progress="${progressFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true, + env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } + } + ); + + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; + + child.once('exit', () => { + this.availableUpdate = undefined; + this.setState(State.Idle(getUpdateType())); + }); + } + this.updateCancellationTokenSource?.dispose(true); const cts = this.updateCancellationTokenSource = new CancellationTokenSource(); const token = cts.token; From 4b0f351026e77ecc2e3fa5a7dc2dd08c60074114 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 26 May 2026 19:07:44 -0700 Subject: [PATCH 2/6] PR feedback, more fixes --- .../electron-main/updateService.win32.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index e830025192082..e7c1143709f17 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -344,28 +344,24 @@ 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`); - await this.unlink(cancelFilePath); - const progressFilePath = path.join(cachePath, `update-progress`); - await this.unlink(progressFilePath); - 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 readyMutexName = `${this.productService.win32MutexName}-ready`; const updatingMutexName = `${this.productService.win32MutexName}-updating`; const mutex = await import('@vscode/windows-mutex'); - // Another VS Code instance may have already launched the Inno Setup installer - // for the same update. Spawning a second one races into Inno's "Setup is - // already running" modal. Skip the spawn and rely on the `-ready` mutex - // polling below to advance our own state machine when the install finishes. - const alreadyInstalling = mutex.isActive(updatingMutexName); - if (alreadyInstalling) { + // Skip the spawn if another instance is already running Inno Setup for this + // update; otherwise Inno's "Setup is already running" modal pops up. The + // `-ready` mutex poll below still advances our state when it finishes. + if (mutex.isActive(updatingMutexName)) { this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); } else { + await this.unlink(cancelFilePath); + await this.unlink(progressFilePath); + await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); + const child = spawn(this.availableUpdate.packagePath, [ '/verysilent', @@ -399,12 +395,24 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const token = cts.token; const poll = async () => { + let seenUpdatingMutex = false; while (this.state.type === StateType.Updating && !token.isCancellationRequested) { if (mutex.isActive(readyMutexName)) { this.setState(State.Ready(update, explicit, this._overwrite)); return; } + // Inno gone without `-ready` => install cancelled/failed; drop to Idle. + if (mutex.isActive(updatingMutexName)) { + seenUpdatingMutex = true; + } else if (seenUpdatingMutex) { + if (!this.availableUpdate?.updateProcess) { + this.availableUpdate = undefined; + this.setState(State.Idle(getUpdateType())); + } + return; + } + try { const progressContent = await readFile(progressFilePath, 'utf8'); if (!token.isCancellationRequested) { @@ -453,7 +461,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; - if (updateProcess && updateProcess.exitCode === null) { + // If we didn't spawn the installer ourselves (another instance is running setup), + // don't touch its in-use files or try to kill its process. + if (!updateProcess) { + this.availableUpdate = undefined; + return; + } + + if (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))); From 9b0631e4c97eca85e63180639f071936445a86d8 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 26 May 2026 22:50:19 -0700 Subject: [PATCH 3/6] Updates --- .../update/electron-main/updateService.win32.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index e7c1143709f17..04b2aa7eef960 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -350,12 +350,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const readyMutexName = `${this.productService.win32MutexName}-ready`; const updatingMutexName = `${this.productService.win32MutexName}-updating`; + const setupMutexName = `${this.productService.win32MutexName}setup`; const mutex = await import('@vscode/windows-mutex'); + const isInstallerActive = () => mutex.isActive(updatingMutexName) || mutex.isActive(setupMutexName); - // Skip the spawn if another instance is already running Inno Setup for this - // update; otherwise Inno's "Setup is already running" modal pops up. The - // `-ready` mutex poll below still advances our state when it finishes. - if (mutex.isActive(updatingMutexName)) { + // Skip the spawn if another Inno Setup is already running for this product (background update or a manual installer); + // otherwise Inno's "Setup is already running" modal pops up. The `-ready` mutex poll below still advances our state when it finishes. + if (isInstallerActive()) { this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); } else { await this.unlink(cancelFilePath); @@ -395,7 +396,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const token = cts.token; const poll = async () => { - let seenUpdatingMutex = false; + let seenRunning = false; while (this.state.type === StateType.Updating && !token.isCancellationRequested) { if (mutex.isActive(readyMutexName)) { this.setState(State.Ready(update, explicit, this._overwrite)); @@ -403,9 +404,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } // Inno gone without `-ready` => install cancelled/failed; drop to Idle. - if (mutex.isActive(updatingMutexName)) { - seenUpdatingMutex = true; - } else if (seenUpdatingMutex) { + if (isInstallerActive()) { + seenRunning = true; + } else if (seenRunning) { if (!this.availableUpdate?.updateProcess) { this.availableUpdate = undefined; this.setState(State.Idle(getUpdateType())); From 7ef829ba92b8c4529e8efb5fee509a410e719313 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 27 May 2026 10:41:28 -0700 Subject: [PATCH 4/6] PR feedback Co-authored-by: Copilot --- .../electron-main/updateService.win32.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 04b2aa7eef960..7199eb3b899b9 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -60,6 +60,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun private availableUpdate: IAvailableUpdate | undefined; private updateCancellationTokenSource: CancellationTokenSource | undefined; + private readonly readyMutexName: string; + private readonly updatingMutexName: string; + private readonly setupMutexName: string; + @memoize get cachePath(): Promise { const result = path.join(tmpdir(), `vscode-${this.productService.quality}-${this.productService.target}-${process.arch}`); @@ -81,6 +85,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun ) { super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, telemetryService, applicationStorageMainService, meteredConnectionService, true); + this.readyMutexName = `${productService.win32MutexName}-ready`; + this.updatingMutexName = `${productService.win32MutexName}-updating`; + this.setupMutexName = `${productService.win32MutexName}setup`; + lifecycleMainService.setRelaunchHandler(this); } @@ -348,15 +356,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); this.availableUpdate.cancelFilePath = cancelFilePath; - const readyMutexName = `${this.productService.win32MutexName}-ready`; - const updatingMutexName = `${this.productService.win32MutexName}-updating`; - const setupMutexName = `${this.productService.win32MutexName}setup`; const mutex = await import('@vscode/windows-mutex'); - const isInstallerActive = () => mutex.isActive(updatingMutexName) || mutex.isActive(setupMutexName); // Skip the spawn if another Inno Setup is already running for this product (background update or a manual installer); // otherwise Inno's "Setup is already running" modal pops up. The `-ready` mutex poll below still advances our state when it finishes. - if (isInstallerActive()) { + if (await this.isInstallerActive()) { this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); } else { await this.unlink(cancelFilePath); @@ -398,13 +402,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const poll = async () => { let seenRunning = false; while (this.state.type === StateType.Updating && !token.isCancellationRequested) { - if (mutex.isActive(readyMutexName)) { + if (mutex.isActive(this.readyMutexName)) { this.setState(State.Ready(update, explicit, this._overwrite)); return; } // Inno gone without `-ready` => install cancelled/failed; drop to Idle. - if (isInstallerActive()) { + if (await this.isInstallerActive()) { seenRunning = true; } else if (seenRunning) { if (!this.availableUpdate?.updateProcess) { @@ -455,20 +459,25 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + // Another instance owns the installer: abort if it's still running so we don't start a new + // update cycle on top of it; keep `availableUpdate` so quit-and-install can still complete. + if (!updateProcess && await this.isInstallerActive()) { + throw new Error('Cannot cancel pending update: another instance is still running setup'); + } + // Cancel the polling loop this.updateCancellationTokenSource?.dispose(true); this.updateCancellationTokenSource = undefined; - this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); - const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; - - // If we didn't spawn the installer ourselves (another instance is running setup), - // don't touch its in-use files or try to kill its process. if (!updateProcess) { this.availableUpdate = undefined; return; } + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + if (updateProcess.exitCode === null) { // Remove all listeners to prevent the exit handler from changing state updateProcess.removeAllListeners(); @@ -570,6 +579,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private async isInstallerActive(): Promise { + const mutex = await import('@vscode/windows-mutex'); + return mutex.isActive(this.updatingMutexName) || mutex.isActive(this.setupMutexName); + } + private async unlink(path: string | undefined): Promise { if (path) { try { From 6a28c9e014f36e107b0dbbdfafa626268dc6f7c9 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 27 May 2026 11:08:17 -0700 Subject: [PATCH 5/6] PR feedback Co-authored-by: Copilot --- .../electron-main/updateService.win32.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 7199eb3b899b9..28779b633d3ca 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -70,6 +70,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return mkdir(result, { recursive: true }).then(() => result); } + @memoize + private get mutex(): Promise { + return import('@vscode/windows-mutex'); + } + constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IConfigurationService configurationService: IConfigurationService, @@ -356,11 +361,12 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); this.availableUpdate.cancelFilePath = cancelFilePath; - const mutex = await import('@vscode/windows-mutex'); + const mutex = await this.mutex; + const skippedSpawn = this.isInstallerActive(mutex); // Skip the spawn if another Inno Setup is already running for this product (background update or a manual installer); // otherwise Inno's "Setup is already running" modal pops up. The `-ready` mutex poll below still advances our state when it finishes. - if (await this.isInstallerActive()) { + if (skippedSpawn) { this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); } else { await this.unlink(cancelFilePath); @@ -400,7 +406,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const token = cts.token; const poll = async () => { - let seenRunning = false; + // If we skipped the spawn, the foreign installer was active when we started; treat that as having seen it run + // so a quick exit (cancel/fail) before the first poll iteration still drops us to Idle. + let seenRunning = skippedSpawn; while (this.state.type === StateType.Updating && !token.isCancellationRequested) { if (mutex.isActive(this.readyMutexName)) { this.setState(State.Ready(update, explicit, this._overwrite)); @@ -408,7 +416,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } // Inno gone without `-ready` => install cancelled/failed; drop to Idle. - if (await this.isInstallerActive()) { + if (this.isInstallerActive(mutex)) { seenRunning = true; } else if (seenRunning) { if (!this.availableUpdate?.updateProcess) { @@ -463,7 +471,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // Another instance owns the installer: abort if it's still running so we don't start a new // update cycle on top of it; keep `availableUpdate` so quit-and-install can still complete. - if (!updateProcess && await this.isInstallerActive()) { + if (!updateProcess && this.isInstallerActive(await this.mutex)) { throw new Error('Cannot cancel pending update: another instance is still running setup'); } @@ -579,8 +587,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } - private async isInstallerActive(): Promise { - const mutex = await import('@vscode/windows-mutex'); + private isInstallerActive(mutex: typeof import('@vscode/windows-mutex')): boolean { return mutex.isActive(this.updatingMutexName) || mutex.isActive(this.setupMutexName); } From 25599b44c5104a6455b2786f4250acfa86d44ef5 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 27 May 2026 12:20:39 -0700 Subject: [PATCH 6/6] PR feedback --- .../platform/update/electron-main/updateService.win32.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 28779b633d3ca..1911ac0a6bcd1 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -479,14 +479,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.updateCancellationTokenSource?.dispose(true); this.updateCancellationTokenSource = undefined; - if (!updateProcess) { - this.availableUpdate = undefined; - return; - } - - this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + if (updateProcess && updateProcess.exitCode === null) { + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); - if (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)));