Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 16 additions & 4 deletions src/vs/base/parts/ipc/node/ipc.net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 43 additions & 1 deletion src/vs/base/parts/ipc/test/node/ipc.net.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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<void> {
return new Promise<void>((resolve, reject) => {
const pipeName = createRandomIPCHandle();
Expand Down
172 changes: 166 additions & 6 deletions src/vs/code/electron-main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 => {
Expand Down Expand Up @@ -479,6 +505,140 @@ class CodeMain {
return mainProcessNodeIpcServer;
}

private async claimSessionData(logService: ILogService, environmentMainService: IEnvironmentMainService, lifecycleMainService: ILifecycleMainService, productService: IProductService): Promise<void> {
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<boolean /* keepWaiting */> {

// 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 }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface IEnvironmentMainService extends INativeEnvironmentService {
// --- IPC
readonly mainIPCHandle: string;
readonly mainLockfile: string;
readonly sessionDataLockHandle: string;

// --- config
readonly disableUpdates: boolean;
Expand All @@ -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']; }

Expand Down