From cbb091aa5c7926ac7da2d79c0657d65f673c2f80 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Wed, 9 Sep 2026 10:41:12 +0300 Subject: [PATCH 1/3] Acquire a process lock on the session data directory The main IPC handle is scoped to the product version, so right after an update the new process fails to detect the old one via claimInstance and both processes end up using the same user data directory at the same time. Chromium does not support sharing session data between multiple running browser processes and webview service worker registration then fails with an InvalidStateError. Add a version independent process lock for the session data: - createStaticIPCHandle now allows omitting the version so that the same handle is produced by all versions for a given user data directory (existing callers are unchanged) - the main process serves that handle (environmentMainService. sessionDataLockHandle) for its whole lifetime before any storage in the user data directory is opened (claimSessionData runs right after claimInstance and before initServices) - if a live lock holder exists, the process waits for it to exit (polling every second, dialog after 10s with Keep Waiting/Exit that re-appears every 60s), because concurrent use of the session data directory is unsupported and can corrupt Chromium state storage - stale sockets of dead processes are recovered by probing the handle and unlinking it, mirroring claimInstance; the lock is released on shutdown Signed-off-by: Danila Fominykh --- src/vs/base/parts/ipc/node/ipc.net.ts | 20 ++- .../base/parts/ipc/test/node/ipc.net.test.ts | 44 ++++- src/vs/code/electron-main/main.ts | 161 +++++++++++++++++- .../electron-main/environmentMainService.ts | 9 + 4 files changed, 223 insertions(+), 11 deletions(-) diff --git a/src/vs/base/parts/ipc/node/ipc.net.ts b/src/vs/base/parts/ipc/node/ipc.net.ts index d97410ac0850c..3d4fc2871afe6 100644 --- a/src/vs/base/parts/ipc/node/ipc.net.ts +++ b/src/vs/base/parts/ipc/node/ipc.net.ts @@ -770,13 +770,24 @@ export function createRandomIPCHandle(): string { return join(basePath, `vscode-ipc-${suffix}.sock`); } -export function createStaticIPCHandle(directoryPath: string, type: string, version: string): string { +/** + * Creates a static IPC handle (a named pipe on Windows, a socket file on + * macOS and Linux) that is deterministically derived from the given + * directory path. + * + * When a version is given, it becomes part of the handle name, resulting + * in different handles per version (e.g. for the main process IPC server). + * When omitted, the handle is identical for all versions, which is useful + * for resources that are shared between versions and must only be owned + * by one process at a time, such as the session data directory. + */ +export function createStaticIPCHandle(directoryPath: string, type: string, version = ''): string { const scope = createHash('sha256').update(directoryPath).digest('hex'); const scopeForSocket = scope.substr(0, 8); // Windows: use named pipe if (process.platform === 'win32') { - return `\\\\.\\pipe\\${scopeForSocket}-${version}-${type}-sock`; + return `\\\\.\\pipe\\${scopeForSocket}${version ? `-${version}` : ''}-${type}-sock`; } // Mac & Unix: Use socket file @@ -786,12 +797,13 @@ export function createStaticIPCHandle(directoryPath: string, type: string, versi const versionForSocket = version.substr(0, 4); const typeForSocket = type.substr(0, 6); + const versionAndTypeForSocket = versionForSocket ? `${versionForSocket}-${typeForSocket}` : typeForSocket; let result: string; if (process.platform !== 'darwin' && XDG_RUNTIME_DIR && !process.env['VSCODE_PORTABLE']) { - result = join(XDG_RUNTIME_DIR, `vscode-${scopeForSocket}-${versionForSocket}-${typeForSocket}.sock`); + result = join(XDG_RUNTIME_DIR, `vscode-${scopeForSocket}-${versionAndTypeForSocket}.sock`); } else { - result = join(directoryPath, `${versionForSocket}-${typeForSocket}.sock`); + result = join(directoryPath, `${versionAndTypeForSocket}.sock`); } // Validate length. Unlike `createRandomIPCHandle`, the path here must be derived diff --git a/src/vs/base/parts/ipc/test/node/ipc.net.test.ts b/src/vs/base/parts/ipc/test/node/ipc.net.test.ts index e0621d0900b01..57f5e930a447d 100644 --- a/src/vs/base/parts/ipc/test/node/ipc.net.test.ts +++ b/src/vs/base/parts/ipc/test/node/ipc.net.test.ts @@ -6,17 +6,20 @@ import assert from 'assert'; import sinon from 'sinon'; import { EventEmitter } from 'events'; +import { promises } from 'fs'; import { AddressInfo, connect, createServer, Server, Socket } from 'net'; import { tmpdir } from 'os'; import { Barrier, timeout } from '../../../../common/async.js'; import { VSBuffer } from '../../../../common/buffer.js'; import { Emitter, Event } from '../../../../common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../common/lifecycle.js'; +import { join } from '../../../../common/path.js'; import { ILoadEstimator, PersistentProtocol, Protocol, ProtocolConstants, SocketCloseEvent, SocketDiagnosticsEventType, SocketTimeoutReason } from '../../common/ipc.net.js'; -import { createRandomIPCHandle, createStaticIPCHandle, NodeSocket, WebSocketNodeSocket } from '../../node/ipc.net.js'; +import { createRandomIPCHandle, createStaticIPCHandle, NodeSocket, serve, WebSocketNodeSocket } from '../../node/ipc.net.js'; import { flakySuite } from '../../../../test/common/testUtils.js'; import { runWithFakedTimers } from '../../../../test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../test/common/utils.js'; +import { generateUuid } from '../../../../common/uuid.js'; class MessageStream extends Disposable { @@ -643,6 +646,45 @@ flakySuite('IPC, create handle', () => { return testIPCHandle(createStaticIPCHandle(tmpdir(), 'test', '1.64.0')); }); + test('createStaticIPCHandle without version', async () => { + return testIPCHandle(createStaticIPCHandle(tmpdir(), 'test')); + }); + + test('createStaticIPCHandle is deterministic and independent of an omitted version', () => { + const handleA = createStaticIPCHandle('/a', 'lock'); + const handleB = createStaticIPCHandle('/a', 'lock'); + const handleC = createStaticIPCHandle('/b', 'lock'); + const handleD = createStaticIPCHandle('/a', 'lock', '1.64.0'); + + assert.strictEqual(handleA, handleB); + assert.notStrictEqual(handleA, handleC); + assert.notStrictEqual(handleA, handleD); + }); + + test('serving the same static IPC handle twice fails', async () => { + const uniqueDir = join(tmpdir(), `vscode-ipc-test-${generateUuid()}`); + await promises.mkdir(uniqueDir, { recursive: true }); + + const handle = createStaticIPCHandle(uniqueDir, 'lock'); + const disposables = new DisposableStore(); + + try { + disposables.add(await serve(handle)); + + // A second server cannot bind the same handle, which is what + // makes static handles usable as process locks + await assert.rejects(async () => { + disposables.add(await serve(handle)); + }, (error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE' || error.code === 'EACCES'); + } finally { + disposables.dispose(); + + // The socket file may live outside of `uniqueDir` (XDG_RUNTIME_DIR) + await promises.unlink(handle).catch(() => { /* ignored */ }); + await promises.rm(uniqueDir, { recursive: true, force: true }).catch(() => { /* ignored */ }); + } + }); + function testIPCHandle(handle: string): Promise { return new Promise((resolve, reject) => { const pipeName = createRandomIPCHandle(); diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index df40f72071631..c83c6d43646c7 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -9,7 +9,7 @@ import { app, dialog } from 'electron'; import { unlinkSync, promises } from 'fs'; import { URI } from '../../base/common/uri.js'; import { coalesce, distinct } from '../../base/common/arrays.js'; -import { Promises, retry } from '../../base/common/async.js'; +import { Promises, retry, timeout } from '../../base/common/async.js'; import { toErrorMessage } from '../../base/common/errorMessage.js'; import { ExpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; import { IPathWithLineAndColumn, isValidBasename, parseLineAndColumnAware, sanitizeFilePath } from '../../base/common/extpath.js'; @@ -110,6 +110,37 @@ class CodeMain { try { + // Claim ownership of the instance and of the session data directory + // before any storage inside the user data directory is opened: + // sharing session data between multiple running browser processes + // is not supported and corrupts Chromium state storage (observed + // as webview service worker registrations failing with an + // InvalidStateError) + const mainProcessNodeIpcServer = await instantiationService.invokeFunction(async accessor => { + const logService = accessor.get(ILogService); + const lifecycleMainService = accessor.get(ILifecycleMainService); + + // Ensure the user data directory exists because IPC handles + // may be created inside of it (e.g. unix domain sockets) + if (!isWindows) { + await promises.mkdir(environmentMainService.userDataPath, { recursive: true }).catch(() => { /* ignored, handled by initServices */ }); + } + + // Create the main IPC server by trying to be the server + // If this throws an error it means we are not the first + // instance of VS Code running and so we would quit. + const mainProcessNodeIpcServer = await this.claimInstance(logService, environmentMainService, lifecycleMainService, instantiationService, productService, true); + + // Acquire the version independent lock on the session data: + // this guarantees that only one VS Code process, of any + // version, is using the user data directory at the same time + // (which can happen across updates because the handle used + // by `claimInstance` above is version scoped) + await this.claimSessionData(logService, environmentMainService, lifecycleMainService, productService); + + return mainProcessNodeIpcServer; + }); + // Init services try { await this.initServices(environmentMainService, userDataProfilesMainService, configurationService, stateMainService, productService); @@ -128,11 +159,6 @@ class CodeMain { const fileService = accessor.get(IFileService); const loggerService = accessor.get(ILoggerService); - // Create the main IPC server by trying to be the server - // If this throws an error it means we are not the first - // instance of VS Code running and so we would quit. - const mainProcessNodeIpcServer = await this.claimInstance(logService, environmentMainService, lifecycleMainService, instantiationService, productService, true); - // Write a lockfile to indicate an instance is running // (https://github.com/microsoft/vscode/issues/127861#issuecomment-877417451) FSPromises.writeFile(environmentMainService.mainLockfile, String(process.pid)).catch(err => { @@ -479,6 +505,129 @@ class CodeMain { return mainProcessNodeIpcServer; } + private async claimSessionData(logService: ILogService, environmentMainService: IEnvironmentMainService, lifecycleMainService: ILifecycleMainService, productService: IProductService): Promise { + const handle = environmentMainService.sessionDataLockHandle; + const pollIntervalMs = 1000; // interval to check if the lock was released + const dialogDelayMs = 10000; // time to wait before showing a dialog + const dialogRepeatMs = 60000; // interval to re-show the dialog while waiting + + const start = Date.now(); + let lastDialogShownAt = 0; + let waitingLogged = false; + + while (true) { + + // Try to become the owner of the session data lock. If this + // succeeds, no other VS Code process (of any version) is using + // the session data directory of our user data directory. + try { + const lockServer = await nodeIPCServe(handle); + + if (waitingLogged) { + logService.info(`Acquired session data lock after waiting ${Date.now() - start}ms (${handle})`); + } else { + logService.trace(`Acquired session data lock (${handle})`); + } + + // Hold the lock for the lifetime of the process and release + // it on shutdown: named pipes on Windows are cleaned up by + // the OS automatically, socket files on macOS and Linux are + // removed explicitly (stale files are also handled below) + Event.once(lifecycleMainService.onWillShutdown)(evt => { + lockServer.dispose(); + evt.join('sessionDataLock', promises.unlink(handle).catch(() => { /* ignored */ })); + }); + + return; + } catch (error) { + if (error.code !== 'EADDRINUSE') { + this.handleStartupDataDirError(environmentMainService, productService, error); + + throw error; + } + } + + // The lock is already taken: check whether the process owning + // it is still alive by connecting to the handle + try { + const client = await nodeIPCConnect(handle, 'session-data-lock'); + client.dispose(); + } catch (error) { + + // Windows surfaces EPERM when connecting to a handle that is + // owned by an elevated process: waiting cannot help in this + // case, so surface the error to the user + if (error.code === 'EPERM' || error.code === 'EACCES') { + this.showStartupWarningDialog( + localize('sessionDataLockElevated', "Another instance of {0} is running as administrator.", productService.nameShort), + localize('sessionDataLockElevatedDetail', "Please close the other instance of {0} and try again.", productService.nameShort), + productService + ); + + throw error; + } + + // On macOS and Linux the socket file can be left behind when + // a process dies: since we cannot connect to it, remove it + // and try to become the lock owner again + if (!isWindows && error.code === 'ECONNREFUSED') { + try { + unlinkSync(handle); + } catch (unlinkError) { + logService.warn(`Error removing stale session data lock: ${unlinkError.toString()}`); + } + + continue; + } + + throw error; + } + + // The lock owner is alive and may even be a different version of + // VS Code (for example right after an update): we cannot hand + // off to it like `claimInstance` does and we must not use the + // session data directory at the same time, so we wait for the + // other process to release the lock + if (!waitingLogged) { + waitingLogged = true; + logService.warn(`Session data directory is locked by another running process, waiting for it to be released (${handle})`); + } + + if (Date.now() - start >= dialogDelayMs && Date.now() - lastDialogShownAt >= dialogRepeatMs) { + lastDialogShownAt = Date.now(); + + const keepWaiting = await this.showSessionDataLockDialog(environmentMainService, productService); + if (!keepWaiting) { + throw new ExpectedError('Terminating...'); + } + } + + await timeout(pollIntervalMs); + } + } + + private async showSessionDataLockDialog(environmentMainService: IEnvironmentMainService, productService: IProductService): Promise { + + // Make sure the app is ready because dialogs can only be shown after + await app.whenReady(); + + const massaged = massageMessageBoxOptions({ + type: 'warning', + buttons: [ + localize({ key: 'sessionDataLockKeepWaiting', comment: ['&& denotes a mnemonic'] }, "&&Keep Waiting"), + localize({ key: 'sessionDataLockExit', comment: ['&& denotes a mnemonic'] }, "E&&xit") + ], + message: localize('sessionDataLocked', "Another instance of {0} is using the session data directory", productService.nameShort), + detail: localize('sessionDataLockedDetail', "{0} cannot start while another program is using:\n\n{1}\n\nThis can happen when another version of {0} is still running, for example right after an update. Close the other instance to continue.\n\nSharing the session data directory between multiple running programs is not supported and can result in data loss.", productService.nameShort, environmentMainService.userDataPath), + defaultId: 0, + cancelId: 0 + }, productService); + + const { response } = await dialog.showMessageBox(massaged.options); + + return massaged.buttonIndeces[response] === 0; // keep waiting + } + private handleStartupDataDirError(environmentMainService: IEnvironmentMainService, productService: IProductService, error: NodeJS.ErrnoException): void { if (error.code === 'EACCES' || error.code === 'EPERM') { const directories = coalesce([environmentMainService.userDataPath, environmentMainService.extensionsPath, XDG_RUNTIME_DIR]).map(folder => getPathLabel(URI.file(folder), { os: OS, tildify: environmentMainService })); diff --git a/src/vs/platform/environment/electron-main/environmentMainService.ts b/src/vs/platform/environment/electron-main/environmentMainService.ts index f8486e8ab742a..c79586b91a39a 100644 --- a/src/vs/platform/environment/electron-main/environmentMainService.ts +++ b/src/vs/platform/environment/electron-main/environmentMainService.ts @@ -29,6 +29,7 @@ export interface IEnvironmentMainService extends INativeEnvironmentService { // --- IPC readonly mainIPCHandle: string; readonly mainLockfile: string; + readonly sessionDataLockHandle: string; // --- config readonly disableUpdates: boolean; @@ -54,6 +55,14 @@ export class EnvironmentMainService extends NativeEnvironmentService implements @memoize get mainLockfile(): string { return join(this.userDataPath, 'code.lock'); } + // Unlike `mainIPCHandle` this handle is intentionally not scoped to the + // current version: it guards the user data directory (and thus the + // session data stored inside of it) from being used by multiple VS Code + // processes at the same time, which is possible when different versions + // run side by side, for example right after an update. + @memoize + get sessionDataLockHandle(): string { return createStaticIPCHandle(this.userDataPath, 'lock'); } + @memoize get disableUpdates(): boolean { return !!this.args['disable-updates']; } From 5003b107df7f42cf58ff7f9c06765406a344f835 Mon Sep 17 00:00:00 2001 From: D Fnx Date: Wed, 9 Sep 2026 11:21:27 +0300 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/code/electron-main/main.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index c83c6d43646c7..87765e2634886 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -533,10 +533,8 @@ class CodeMain { // it on shutdown: named pipes on Windows are cleaned up by // the OS automatically, socket files on macOS and Linux are // removed explicitly (stale files are also handled below) - Event.once(lifecycleMainService.onWillShutdown)(evt => { - lockServer.dispose(); - evt.join('sessionDataLock', promises.unlink(handle).catch(() => { /* ignored */ })); - }); + // Keep the lock until process exit, after Chromium has finished using session data. + process.once('exit', () => lockServer.dispose()); return; } catch (error) { From 0fe28bf6d300267a094f86efa94fee4efc84455b Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Wed, 9 Sep 2026 11:30:29 +0300 Subject: [PATCH 3/3] Treat ENOENT when probing the session data lock as a released lock A normal lock release can remove the Unix socket in between the failed serve() attempt and the connect() probe (and on Windows the named pipe disappears when its owning process exits). The probe then failed with ENOENT, which escaped and aborted startup instead of continuing the polling loop. Treat ENOENT as a released-lock race and retry acquisition. The same race applies to the unlink() of a stale socket, which another process may have removed in the meantime: ignore ENOENT there quietly instead of logging a warning. Signed-off-by: Danila Fominykh --- src/vs/code/electron-main/main.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index 87765e2634886..b3b4be6726f5e 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -565,6 +565,14 @@ class CodeMain { throw error; } + // The lock can be released in between the failed serve attempt + // and the connect probe above (on macOS and Linux the socket + // file is removed, on Windows the named pipe disappears): + // retry acquiring the lock in that case + if (error.code === 'ENOENT') { + continue; + } + // On macOS and Linux the socket file can be left behind when // a process dies: since we cannot connect to it, remove it // and try to become the lock owner again @@ -572,7 +580,12 @@ class CodeMain { try { unlinkSync(handle); } catch (unlinkError) { - logService.warn(`Error removing stale session data lock: ${unlinkError.toString()}`); + + // The socket file can also be removed by another + // process in the meantime: nothing to clean up + if (unlinkError.code !== 'ENOENT') { + logService.warn(`Error removing stale session data lock: ${unlinkError.toString()}`); + } } continue;