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..b3b4be6726f5e 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,140 @@ 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) + // Keep the lock until process exit, after Chromium has finished using session data. + process.once('exit', () => lockServer.dispose()); + + 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; + } + + // 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 + if (!isWindows && error.code === 'ECONNREFUSED') { + try { + unlinkSync(handle); + } catch (unlinkError) { + + // 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; + } + + 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']; }