/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as glob from 'vs/base/common/glob'; import * as extpath from 'vs/base/common/extpath'; import * as path from 'vs/base/common/path'; import * as platform from 'vs/base/common/platform'; import { IDiskFileChange, normalizeFileChanges, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; import * as nsfw from 'nsfw'; import { IWatcherService, IWatcherRequest, IWatcherOptions } from 'vs/platform/files/node/watcher/nsfw/watcher'; import { ThrottledDelayer } from 'vs/base/common/async'; import { FileChangeType } from 'vs/platform/files/common/files'; import { normalizeNFC } from 'vs/base/common/normalization'; import { Event, Emitter } from 'vs/base/common/event'; import { realcaseSync, realpathSync } from 'vs/base/node/extpath'; const nsfwActionToRawChangeType: { [key: number]: number } = []; nsfwActionToRawChangeType[nsfw.actions.CREATED] = FileChangeType.ADDED; nsfwActionToRawChangeType[nsfw.actions.MODIFIED] = FileChangeType.UPDATED; nsfwActionToRawChangeType[nsfw.actions.DELETED] = FileChangeType.DELETED; interface IWatcherObjet { start(): any; stop(): any; } interface IPathWatcher { ready: Promise; watcher?: IWatcherObjet; ignored: glob.ParsedPattern[]; } export class NsfwWatcherService implements IWatcherService { private static readonly FS_EVENT_DELAY = 50; // aggregate and only emit events when changes have stopped for this duration (in ms) private _pathWatchers: { [watchPath: string]: IPathWatcher } = {}; private _verboseLogging: boolean; private enospcErrorLogged: boolean; private _onWatchEvent = new Emitter(); readonly onWatchEvent = this._onWatchEvent.event; private _onLogMessage = new Emitter(); readonly onLogMessage: Event = this._onLogMessage.event; watch(options: IWatcherOptions): Event { return this.onWatchEvent; } private _watch(request: IWatcherRequest): void { let undeliveredFileEvents: IDiskFileChange[] = []; const fileEventDelayer = new ThrottledDelayer(NsfwWatcherService.FS_EVENT_DELAY); let readyPromiseResolve: (watcher: IWatcherObjet) => void; this._pathWatchers[request.path] = { ready: new Promise(resolve => readyPromiseResolve = resolve), ignored: Array.isArray(request.excludes) ? request.excludes.map(ignored => glob.parse(ignored)) : [] }; process.on('uncaughtException', (e: Error | string) => { // Specially handle ENOSPC errors that can happen when // the watcher consumes so many file descriptors that // we are running into a limit. We only want to warn // once in this case to avoid log spam. // See https://github.com/Microsoft/vscode/issues/7950 if (e === 'Inotify limit reached' && !this.enospcErrorLogged) { this.enospcErrorLogged = true; this.error('Inotify limit reached (ENOSPC)'); } }); // NSFW does not report file changes in the path provided on macOS if // - the path uses wrong casing // - the path is a symbolic link // We have to detect this case and massage the events to correct this. let realBasePathDiffers = false; let realBasePathLength = request.path.length; if (platform.isMacintosh) { try { // First check for symbolic link let realBasePath = realpathSync(request.path); // Second check for casing difference if (request.path === realBasePath) { realBasePath = (realcaseSync(request.path) || request.path); } if (request.path !== realBasePath) { realBasePathLength = realBasePath.length; realBasePathDiffers = true; this.warn(`Watcher basePath does not match version on disk and will be corrected (original: ${request.path}, real: ${realBasePath})`); } } catch (error) { // ignore } } nsfw(request.path, events => { for (const e of events) { // Logging if (this._verboseLogging) { const logPath = e.action === nsfw.actions.RENAMED ? path.join(e.directory, e.oldFile || '') + ' -> ' + e.newFile : path.join(e.directory, e.file || ''); this.log(`${e.action === nsfw.actions.CREATED ? '[CREATED]' : e.action === nsfw.actions.DELETED ? '[DELETED]' : e.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); } // Convert nsfw event to IRawFileChange and add to queue let absolutePath: string; if (e.action === nsfw.actions.RENAMED) { // Rename fires when a file's name changes within a single directory absolutePath = path.join(e.directory, e.oldFile || ''); if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: FileChangeType.DELETED, path: absolutePath }); } else if (this._verboseLogging) { this.log(` >> ignored ${absolutePath}`); } absolutePath = path.join(e.newDirectory || e.directory, e.newFile || ''); if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: FileChangeType.ADDED, path: absolutePath }); } else if (this._verboseLogging) { this.log(` >> ignored ${absolutePath}`); } } else { absolutePath = path.join(e.directory, e.file || ''); if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: nsfwActionToRawChangeType[e.action], path: absolutePath }); } else if (this._verboseLogging) { this.log(` >> ignored ${absolutePath}`); } } } // Delay and send buffer fileEventDelayer.trigger(() => { const events = undeliveredFileEvents; undeliveredFileEvents = []; if (platform.isMacintosh) { events.forEach(e => { // Mac uses NFD unicode form on disk, but we want NFC e.path = normalizeNFC(e.path); // Convert paths back to original form in case it differs if (realBasePathDiffers) { e.path = request.path + e.path.substr(realBasePathLength); } }); } // Broadcast to clients normalized const res = normalizeFileChanges(events); this._onWatchEvent.fire(res); // Logging if (this._verboseLogging) { res.forEach(r => { this.log(` >> normalized ${r.type === FileChangeType.ADDED ? '[ADDED]' : r.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${r.path}`); }); } return Promise.resolve(undefined); }); }).then(watcher => { this._pathWatchers[request.path].watcher = watcher; const startPromise = watcher.start(); startPromise.then(() => readyPromiseResolve(watcher)); return startPromise; }); } public setRoots(roots: IWatcherRequest[]): Promise { const promises: Promise[] = []; const normalizedRoots = this._normalizeRoots(roots); // Gather roots that are not currently being watched const rootsToStartWatching = normalizedRoots.filter(r => { return !(r.path in this._pathWatchers); }); // Gather current roots that don't exist in the new roots array const rootsToStopWatching = Object.keys(this._pathWatchers).filter(r => { return normalizedRoots.every(normalizedRoot => normalizedRoot.path !== r); }); // Logging if (this._verboseLogging) { this.log(`Start watching: [${rootsToStartWatching.map(r => r.path).join(',')}]\nStop watching: [${rootsToStopWatching.join(',')}]`); } // Stop watching some roots rootsToStopWatching.forEach(root => { this._pathWatchers[root].ready.then(watcher => watcher.stop()); delete this._pathWatchers[root]; }); // Start watching some roots rootsToStartWatching.forEach(root => this._watch(root)); // Refresh ignored arrays in case they changed roots.forEach(root => { if (root.path in this._pathWatchers) { this._pathWatchers[root.path].ignored = Array.isArray(root.excludes) ? root.excludes.map(ignored => glob.parse(ignored)) : []; } }); return Promise.all(promises).then(() => undefined); } public setVerboseLogging(enabled: boolean): Promise { this._verboseLogging = enabled; return Promise.resolve(undefined); } public stop(): Promise { for (let path in this._pathWatchers) { let watcher = this._pathWatchers[path]; watcher.ready.then(watcher => watcher.stop()); delete this._pathWatchers[path]; } this._pathWatchers = Object.create(null); return Promise.resolve(); } /** * Normalizes a set of root paths by removing any root paths that are * sub-paths of other roots. */ protected _normalizeRoots(roots: IWatcherRequest[]): IWatcherRequest[] { return roots.filter(r => roots.every(other => { return !(r.path.length > other.path.length && extpath.isEqualOrParent(r.path, other.path)); })); } private _isPathIgnored(absolutePath: string, ignored: glob.ParsedPattern[]): boolean { return ignored && ignored.some(i => i(absolutePath)); } private log(message: string) { this._onLogMessage.fire({ type: 'trace', message: `[File Watcher (nswf)] ` + message }); } private warn(message: string) { this._onLogMessage.fire({ type: 'warn', message: `[File Watcher (nswf)] ` + message }); } private error(message: string) { this._onLogMessage.fire({ type: 'error', message: `[File Watcher (nswf)] ` + message }); } }