Path: blob/main/src/vs/platform/files/common/watcher.ts
3294 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Event } from '../../../base/common/event.js';6import { GLOBSTAR, IRelativePattern, parse, ParsedPattern } from '../../../base/common/glob.js';7import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';8import { isAbsolute } from '../../../base/common/path.js';9import { isLinux } from '../../../base/common/platform.js';10import { URI } from '../../../base/common/uri.js';11import { FileChangeFilter, FileChangeType, IFileChange, isParent } from './files.js';1213interface IWatchRequest {1415/**16* The path to watch.17*/18readonly path: string;1920/**21* Whether to watch recursively or not.22*/23readonly recursive: boolean;2425/**26* A set of glob patterns or paths to exclude from watching.27*/28readonly excludes: string[];2930/**31* An optional set of glob patterns or paths to include for32* watching. If not provided, all paths are considered for33* events.34*/35readonly includes?: Array<string | IRelativePattern>;3637/**38* If provided, file change events from the watcher that39* are a result of this watch request will carry the same40* id.41*/42readonly correlationId?: number;4344/**45* If provided, allows to filter the events that the watcher should consider46* for emitting. If not provided, all events are emitted.47*48* For example, to emit added and updated events, set to:49* `FileChangeFilter.ADDED | FileChangeFilter.UPDATED`.50*/51readonly filter?: FileChangeFilter;52}5354export interface IWatchRequestWithCorrelation extends IWatchRequest {55readonly correlationId: number;56}5758export function isWatchRequestWithCorrelation(request: IWatchRequest): request is IWatchRequestWithCorrelation {59return typeof request.correlationId === 'number';60}6162export interface INonRecursiveWatchRequest extends IWatchRequest {6364/**65* The watcher will be non-recursive.66*/67readonly recursive: false;68}6970export interface IRecursiveWatchRequest extends IWatchRequest {7172/**73* The watcher will be recursive.74*/75readonly recursive: true;7677/**78* @deprecated this only exists for WSL1 support and should never79* be used in any other case.80*/81pollingInterval?: number;82}8384export function isRecursiveWatchRequest(request: IWatchRequest): request is IRecursiveWatchRequest {85return request.recursive === true;86}8788export type IUniversalWatchRequest = IRecursiveWatchRequest | INonRecursiveWatchRequest;8990export interface IWatcherErrorEvent {91readonly error: string;92readonly request?: IUniversalWatchRequest;93}9495export interface IWatcher {9697/**98* A normalized file change event from the raw events99* the watcher emits.100*/101readonly onDidChangeFile: Event<IFileChange[]>;102103/**104* An event to indicate a message that should get logged.105*/106readonly onDidLogMessage: Event<ILogMessage>;107108/**109* An event to indicate an error occurred from the watcher110* that is unrecoverable. Listeners should restart the111* watcher if possible.112*/113readonly onDidError: Event<IWatcherErrorEvent>;114115/**116* Configures the watcher to watch according to the117* requests. Any existing watched path that is not118* in the array, will be removed from watching and119* any new path will be added to watching.120*/121watch(requests: IWatchRequest[]): Promise<void>;122123/**124* Enable verbose logging in the watcher.125*/126setVerboseLogging(enabled: boolean): Promise<void>;127128/**129* Stop all watchers.130*/131stop(): Promise<void>;132}133134export interface IRecursiveWatcher extends IWatcher {135watch(requests: IRecursiveWatchRequest[]): Promise<void>;136}137138export interface IRecursiveWatcherWithSubscribe extends IRecursiveWatcher {139140/**141* Subscribe to file events for the given path. The callback is called142* whenever a file event occurs for the path. If the watcher failed,143* the error parameter is set to `true`.144*145* @returns an `IDisposable` to stop listening to events or `undefined`146* if no events can be watched for the path given the current set of147* recursive watch requests.148*/149subscribe(path: string, callback: (error: true | null, change?: IFileChange) => void): IDisposable | undefined;150}151152export interface IRecursiveWatcherOptions {153154/**155* If `true`, will enable polling for all watchers, otherwise156* will enable it for paths included in the string array.157*158* @deprecated this only exists for WSL1 support and should never159* be used in any other case.160*/161readonly usePolling: boolean | string[];162163/**164* If polling is enabled (via `usePolling`), defines the duration165* in which the watcher will poll for changes.166*167* @deprecated this only exists for WSL1 support and should never168* be used in any other case.169*/170readonly pollingInterval?: number;171}172173export interface INonRecursiveWatcher extends IWatcher {174watch(requests: INonRecursiveWatchRequest[]): Promise<void>;175}176177export interface IUniversalWatcher extends IWatcher {178watch(requests: IUniversalWatchRequest[]): Promise<void>;179}180181export abstract class AbstractWatcherClient extends Disposable {182183private static readonly MAX_RESTARTS = 5;184185private watcher: IWatcher | undefined;186private readonly watcherDisposables = this._register(new MutableDisposable());187188private requests: IWatchRequest[] | undefined = undefined;189190private restartCounter = 0;191192constructor(193private readonly onFileChanges: (changes: IFileChange[]) => void,194private readonly onLogMessage: (msg: ILogMessage) => void,195private verboseLogging: boolean,196private options: {197readonly type: string;198readonly restartOnError: boolean;199}200) {201super();202}203204protected abstract createWatcher(disposables: DisposableStore): IWatcher;205206protected init(): void {207208// Associate disposables to the watcher209const disposables = new DisposableStore();210this.watcherDisposables.value = disposables;211212// Ask implementors to create the watcher213this.watcher = this.createWatcher(disposables);214this.watcher.setVerboseLogging(this.verboseLogging);215216// Wire in event handlers217disposables.add(this.watcher.onDidChangeFile(changes => this.onFileChanges(changes)));218disposables.add(this.watcher.onDidLogMessage(msg => this.onLogMessage(msg)));219disposables.add(this.watcher.onDidError(e => this.onError(e.error, e.request)));220}221222protected onError(error: string, failedRequest?: IUniversalWatchRequest): void {223224// Restart on error (up to N times, if possible)225if (this.canRestart(error, failedRequest)) {226if (this.restartCounter < AbstractWatcherClient.MAX_RESTARTS && this.requests) {227this.error(`restarting watcher after unexpected error: ${error}`);228this.restart(this.requests);229} else {230this.error(`gave up attempting to restart watcher after unexpected error: ${error}`);231}232}233234// Do not attempt to restart otherwise, report the error235else {236this.error(error);237}238}239240private canRestart(error: string, failedRequest?: IUniversalWatchRequest): boolean {241if (!this.options.restartOnError) {242return false; // disabled by options243}244245if (failedRequest) {246// do not treat a failing request as a reason to restart the entire247// watcher. it is possible that from a large amount of watch requests248// some fail and we would constantly restart all requests only because249// of that. rather, continue the watcher and leave the failed request250return false;251}252253if (254error.indexOf('No space left on device') !== -1 ||255error.indexOf('EMFILE') !== -1256) {257// do not restart when the error indicates that the system is running258// out of handles for file watching. this is not recoverable anyway259// and needs changes to the system before continuing260return false;261}262263return true;264}265266private restart(requests: IUniversalWatchRequest[]): void {267this.restartCounter++;268269this.init();270this.watch(requests);271}272273async watch(requests: IUniversalWatchRequest[]): Promise<void> {274this.requests = requests;275276await this.watcher?.watch(requests);277}278279async setVerboseLogging(verboseLogging: boolean): Promise<void> {280this.verboseLogging = verboseLogging;281282await this.watcher?.setVerboseLogging(verboseLogging);283}284285private error(message: string) {286this.onLogMessage({ type: 'error', message: `[File Watcher (${this.options.type})] ${message}` });287}288289protected trace(message: string) {290this.onLogMessage({ type: 'trace', message: `[File Watcher (${this.options.type})] ${message}` });291}292293override dispose(): void {294295// Render the watcher invalid from here296this.watcher = undefined;297298return super.dispose();299}300}301302export abstract class AbstractNonRecursiveWatcherClient extends AbstractWatcherClient {303304constructor(305onFileChanges: (changes: IFileChange[]) => void,306onLogMessage: (msg: ILogMessage) => void,307verboseLogging: boolean308) {309super(onFileChanges, onLogMessage, verboseLogging, { type: 'node.js', restartOnError: false });310}311312protected abstract override createWatcher(disposables: DisposableStore): INonRecursiveWatcher;313}314315export abstract class AbstractUniversalWatcherClient extends AbstractWatcherClient {316317constructor(318onFileChanges: (changes: IFileChange[]) => void,319onLogMessage: (msg: ILogMessage) => void,320verboseLogging: boolean321) {322super(onFileChanges, onLogMessage, verboseLogging, { type: 'universal', restartOnError: true });323}324325protected abstract override createWatcher(disposables: DisposableStore): IUniversalWatcher;326}327328export interface ILogMessage {329readonly type: 'trace' | 'warn' | 'error' | 'info' | 'debug';330readonly message: string;331}332333export function reviveFileChanges(changes: IFileChange[]): IFileChange[] {334return changes.map(change => ({335type: change.type,336resource: URI.revive(change.resource),337cId: change.cId338}));339}340341export function coalesceEvents(changes: IFileChange[]): IFileChange[] {342343// Build deltas344const coalescer = new EventCoalescer();345for (const event of changes) {346coalescer.processEvent(event);347}348349return coalescer.coalesce();350}351352export function normalizeWatcherPattern(path: string, pattern: string | IRelativePattern): string | IRelativePattern {353354// Patterns are always matched on the full absolute path355// of the event. As such, if the pattern is not absolute356// and is a string and does not start with a leading357// `**`, we have to convert it to a relative pattern with358// the given `base`359360if (typeof pattern === 'string' && !pattern.startsWith(GLOBSTAR) && !isAbsolute(pattern)) {361return { base: path, pattern };362}363364return pattern;365}366367export function parseWatcherPatterns(path: string, patterns: Array<string | IRelativePattern>): ParsedPattern[] {368const parsedPatterns: ParsedPattern[] = [];369370for (const pattern of patterns) {371parsedPatterns.push(parse(normalizeWatcherPattern(path, pattern)));372}373374return parsedPatterns;375}376377class EventCoalescer {378379private readonly coalesced = new Set<IFileChange>();380private readonly mapPathToChange = new Map<string, IFileChange>();381382private toKey(event: IFileChange): string {383if (isLinux) {384return event.resource.fsPath;385}386387return event.resource.fsPath.toLowerCase(); // normalise to file system case sensitivity388}389390processEvent(event: IFileChange): void {391const existingEvent = this.mapPathToChange.get(this.toKey(event));392393let keepEvent = false;394395// Event path already exists396if (existingEvent) {397const currentChangeType = existingEvent.type;398const newChangeType = event.type;399400// macOS/Windows: track renames to different case401// by keeping both CREATE and DELETE events402if (existingEvent.resource.fsPath !== event.resource.fsPath && (event.type === FileChangeType.DELETED || event.type === FileChangeType.ADDED)) {403keepEvent = true;404}405406// Ignore CREATE followed by DELETE in one go407else if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.DELETED) {408this.mapPathToChange.delete(this.toKey(event));409this.coalesced.delete(existingEvent);410}411412// Flatten DELETE followed by CREATE into CHANGE413else if (currentChangeType === FileChangeType.DELETED && newChangeType === FileChangeType.ADDED) {414existingEvent.type = FileChangeType.UPDATED;415}416417// Do nothing. Keep the created event418else if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.UPDATED) { }419420// Otherwise apply change type421else {422existingEvent.type = newChangeType;423}424}425426// Otherwise keep427else {428keepEvent = true;429}430431if (keepEvent) {432this.coalesced.add(event);433this.mapPathToChange.set(this.toKey(event), event);434}435}436437coalesce(): IFileChange[] {438const addOrChangeEvents: IFileChange[] = [];439const deletedPaths: string[] = [];440441// This algorithm will remove all DELETE events up to the root folder442// that got deleted if any. This ensures that we are not producing443// DELETE events for each file inside a folder that gets deleted.444//445// 1.) split ADD/CHANGE and DELETED events446// 2.) sort short deleted paths to the top447// 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case448return Array.from(this.coalesced).filter(e => {449if (e.type !== FileChangeType.DELETED) {450addOrChangeEvents.push(e);451452return false; // remove ADD / CHANGE453}454455return true; // keep DELETE456}).sort((e1, e2) => {457return e1.resource.fsPath.length - e2.resource.fsPath.length; // shortest path first458}).filter(e => {459if (deletedPaths.some(deletedPath => isParent(e.resource.fsPath, deletedPath, !isLinux /* ignorecase */))) {460return false; // DELETE is ignored if parent is deleted already461}462463// otherwise mark as deleted464deletedPaths.push(e.resource.fsPath);465466return true;467}).concat(addOrChangeEvents);468}469}470471export function isFiltered(event: IFileChange, filter: FileChangeFilter | undefined): boolean {472if (typeof filter === 'number') {473switch (event.type) {474case FileChangeType.ADDED:475return (filter & FileChangeFilter.ADDED) === 0;476case FileChangeType.DELETED:477return (filter & FileChangeFilter.DELETED) === 0;478case FileChangeType.UPDATED:479return (filter & FileChangeFilter.UPDATED) === 0;480}481}482483return false;484}485486export function requestFilterToString(filter: FileChangeFilter | undefined): string {487if (typeof filter === 'number') {488const filters = [];489if (filter & FileChangeFilter.ADDED) {490filters.push('Added');491}492if (filter & FileChangeFilter.DELETED) {493filters.push('Deleted');494}495if (filter & FileChangeFilter.UPDATED) {496filters.push('Updated');497}498499if (filters.length === 0) {500return '<all>';501}502503return `[${filters.join(', ')}]`;504}505506return '<none>';507}508509510