addons/isl-server/src/WatchForChanges.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {PageVisibility, ValidatedRepoInfo} from 'isl/src/types';
b69ab319import type {PageFocusTracker} from './PageFocusTracker';
b69ab3110import type {Logger} from './logger';
b69ab3111
b69ab3112import fs from 'node:fs/promises';
b69ab3113import path from 'node:path';
b69ab3114import {debounce} from 'shared/debounce';
b69ab3115import {Internal} from './Internal';
b69ab3116import {stagedThrottler} from './StagedThrottler';
b69ab3117import type {SubscriptionCallback} from './__generated__/node-edenfs-notifications-client';
b69ab3118import {EdenFSUtils} from './__generated__/node-edenfs-notifications-client';
b69ab3119import {type ServerSideTracker} from './analytics/serverSideTracker';
b69ab3120import {ONE_MINUTE_MS} from './constants';
b69ab3121import {EdenFSNotifications} from './edenFsNotifications';
b69ab3122import type {RepositoryContext} from './serverTypes';
b69ab3123import {Watchman} from './watchman';
b69ab3124
b69ab3125const DEFAULT_POLL_INTERVAL = 15 * ONE_MINUTE_MS;
b69ab3126// When the page is hidden, aggressively reduce polling.
b69ab3127const HIDDEN_POLL_INTERVAL = 3 * 60 * ONE_MINUTE_MS;
b69ab3128// When visible or focused, poll frequently
b69ab3129const VISIBLE_POLL_INTERVAL = 2 * ONE_MINUTE_MS;
b69ab3130const FOCUSED_POLL_INTERVAL = 0.5 * ONE_MINUTE_MS;
b69ab3131const ON_FOCUS_REFETCH_THROTTLE = 15_000;
b69ab3132const ON_VISIBLE_REFETCH_THROTTLE = 30_000;
b69ab3133
b69ab3134export type KindOfChange = 'uncommitted changes' | 'commits' | 'merge conflicts' | 'everything';
b69ab3135export type PollKind = PageVisibility | 'force';
b69ab3136
b69ab3137/**
b69ab3138 * Handles watching for changes to files on disk which should trigger refetching data,
b69ab3139 * and polling for changes when watching is not reliable.
b69ab3140 */
b69ab3141export class WatchForChanges {
b69ab3142 static WATCHMAN_DEFER = `hg.update`; // TODO: update to sl
b69ab3143 static WATCHMAN_DEFER_TRANSACTION = `hg.transaction`; // TODO: update to sl
b69ab3144 public watchman: Watchman;
b69ab3145 public edenfs: EdenFSNotifications;
b69ab3146
b69ab3147 private dirstateDisposables: Array<() => unknown> = [];
b69ab3148 private watchmanDisposables: Array<() => unknown> = [];
b69ab3149 private edenfsDisposables: Array<() => unknown> = [];
b69ab3150 private logger: Logger;
b69ab3151 private tracker: ServerSideTracker;
b69ab3152 private dirstateSubscriptionPromise: Promise<void>;
b69ab3153
4fe1f3454 public onWatchmanStatusChange: ((status: Watchman['status']) => void) | undefined;
4fe1f3455
b69ab3156 constructor(
b69ab3157 private repoInfo: ValidatedRepoInfo,
b69ab3158 private pageFocusTracker: PageFocusTracker,
b69ab3159 private changeCallback: (kind: KindOfChange, pollKind?: PollKind) => unknown,
b69ab3160 ctx: RepositoryContext,
b69ab3161 watchman?: Watchman | undefined,
b69ab3162 edenfs?: EdenFSNotifications | undefined,
b69ab3163 ) {
b69ab3164 this.logger = ctx.logger;
b69ab3165 this.tracker = ctx.tracker;
b69ab3166 this.watchman = watchman ?? new Watchman(ctx.logger, ctx.tracker);
4fe1f3467 this.watchman.onStatusChange = status => this.onWatchmanStatusChange?.(status);
b69ab3168
b69ab3169 const {repoRoot} = this.repoInfo;
b69ab3170 this.edenfs = edenfs ?? new EdenFSNotifications(ctx.logger, repoRoot);
b69ab3171
b69ab3172 // Watch dirstate right away for commit changes
b69ab3173 this.dirstateSubscriptionPromise = this.setupDirstateSubscriptions(ctx);
b69ab3174 this.setupPolling();
b69ab3175 this.pageFocusTracker.onChange(this.poll.bind(this));
b69ab3176 // poll right away so we get data immediately, without waiting for timeout on startup
b69ab3177 this.poll('force');
b69ab3178 }
b69ab3179
b69ab3180 private timeout: NodeJS.Timeout | undefined;
b69ab3181 private lastFetch = new Date().valueOf();
b69ab3182
b69ab3183 /**
b69ab3184 * Waits for the dirstate subscription to be set up
b69ab3185 * since we can't await in the constructor
b69ab3186 * Resolves when dirstateSubscriptionPromise is fulfilled.
b69ab3187 */
b69ab3188 public async waitForDirstateSubscriptionReady(): Promise<void> {
b69ab3189 await this.dirstateSubscriptionPromise;
b69ab3190 }
b69ab3191
b69ab3192 /**
b69ab3193 * Combine different signals to determine what interval to poll for information
b69ab3194 */
b69ab3195 private setupPolling() {
b69ab3196 this.timeout = setTimeout(this.poll, DEFAULT_POLL_INTERVAL);
b69ab3197 }
b69ab3198
b69ab3199 /**
b69ab31100 * Re-trigger fetching data from the repository,
b69ab31101 * depending on how recently that data was last fetched,
b69ab31102 * and whether any ISL windows are focused or visible.
b69ab31103 *
b69ab31104 * This function calls itself on an interval to check whether we should fetch changes,
b69ab31105 * but it can also be called in response to events like focus being gained.
b69ab31106 */
b69ab31107 public poll = (kind?: PollKind) => {
b69ab31108 // calculate how long we'd like to be waiting from what we know of the windows.
b69ab31109 let desiredNextTickTime = DEFAULT_POLL_INTERVAL;
b69ab31110
b69ab31111 if (this.repoInfo.isEdenFs !== true) {
b69ab31112 if (this.watchman.status !== 'healthy') {
b69ab31113 if (this.pageFocusTracker.hasPageWithFocus()) {
b69ab31114 desiredNextTickTime = FOCUSED_POLL_INTERVAL;
b69ab31115 } else if (this.pageFocusTracker.hasVisiblePage()) {
b69ab31116 desiredNextTickTime = VISIBLE_POLL_INTERVAL;
b69ab31117 }
b69ab31118 } else {
b69ab31119 // if watchman is working normally, and we're not visible, don't poll nearly as often
b69ab31120 if (!this.pageFocusTracker.hasPageWithFocus() && !this.pageFocusTracker.hasVisiblePage()) {
b69ab31121 desiredNextTickTime = HIDDEN_POLL_INTERVAL;
b69ab31122 }
b69ab31123 }
b69ab31124 } else {
b69ab31125 // if using eden and we're not visible, don't poll nearly as often
b69ab31126 if (!this.pageFocusTracker.hasPageWithFocus() && !this.pageFocusTracker.hasVisiblePage()) {
b69ab31127 desiredNextTickTime = HIDDEN_POLL_INTERVAL;
b69ab31128 }
b69ab31129 }
b69ab31130
b69ab31131 const now = Date.now();
b69ab31132 const elapsedTickTime = now - this.lastFetch;
b69ab31133
b69ab31134 if (
b69ab31135 kind === 'force' ||
b69ab31136 // we've been waiting longer than desired
b69ab31137 elapsedTickTime >= desiredNextTickTime ||
b69ab31138 // the moment a window gains focus or visibility, consider polling immediately
b69ab31139 (kind === 'focused' && elapsedTickTime >= ON_FOCUS_REFETCH_THROTTLE) ||
b69ab31140 (kind === 'visible' && elapsedTickTime >= ON_VISIBLE_REFETCH_THROTTLE)
b69ab31141 ) {
b69ab31142 // it's time to fetch
b69ab31143 this.changeCallback('everything', kind);
b69ab31144 this.lastFetch = Date.now();
b69ab31145
b69ab31146 clearTimeout(this.timeout);
b69ab31147 this.timeout = setTimeout(this.poll, desiredNextTickTime);
b69ab31148 } else {
b69ab31149 // we have some time left before we we would expect to need to poll, schedule next poll
b69ab31150 clearTimeout(this.timeout);
b69ab31151 this.timeout = setTimeout(this.poll, desiredNextTickTime - elapsedTickTime);
b69ab31152 }
b69ab31153 };
b69ab31154
b69ab31155 private async setupDirstateSubscriptions(ctx: RepositoryContext) {
b69ab31156 const enabled = await Internal.fetchFeatureFlag?.(ctx, 'isl_use_edenfs_notifications');
b69ab31157 this.logger.info('dirstate edenfs notifications flag state: ', enabled);
b69ab31158 if (enabled) {
b69ab31159 if (this.repoInfo.isEdenFs === true) {
b69ab31160 this.logger.info('Valid eden repo'); // For testing, remove when implemented
b69ab31161 await this.setupEdenDirstateSubscriptions();
b69ab31162 return;
b69ab31163 } else {
b69ab31164 this.logger.info('Non-eden repo');
b69ab31165 await this.setupWatchmanDirstateSubscriptions();
b69ab31166 }
b69ab31167 } else {
b69ab31168 await this.setupWatchmanDirstateSubscriptions();
b69ab31169 }
b69ab31170 }
b69ab31171
b69ab31172 private async setupWatchmanDirstateSubscriptions() {
b69ab31173 const {repoRoot, dotdir} = this.repoInfo;
b69ab31174
b69ab31175 if (repoRoot == null || dotdir == null) {
b69ab31176 this.logger.error(`skipping dirstate subscription since ${repoRoot} is not a repository`);
b69ab31177 return;
b69ab31178 }
b69ab31179
b69ab31180 // Resolve the repo dot dir in case it is a symlink. Watchman doesn't follow symlinks,
b69ab31181 // so we must follow it and watch the target.
b69ab31182 const realDotdir = await fs.realpath(dotdir);
b69ab31183
b69ab31184 if (realDotdir != dotdir) {
b69ab31185 this.logger.info(`resolved dotdir ${dotdir} to ${realDotdir}`);
b69ab31186
b69ab31187 // Write out ".watchmanconfig" so realDotdir passes muster as a watchman "root dir"
b69ab31188 // (otherwise watchman will refuse to watch it).
b69ab31189 await fs.writeFile(path.join(realDotdir, '.watchmanconfig'), '{}');
b69ab31190 }
b69ab31191
b69ab31192 const DIRSTATE_WATCHMAN_SUBSCRIPTION = 'sapling-smartlog-dirstate-change';
b69ab31193 try {
b69ab31194 const handleRepositoryStateChange = debounce(() => {
b69ab31195 // if the repo changes, also recheck files. E.g. if you commit, your uncommitted changes will also change.
b69ab31196 this.changeCallback('everything');
b69ab31197
b69ab31198 // reset timer for polling
b69ab31199 this.lastFetch = new Date().valueOf();
b69ab31200 }, 100); // debounce so that multiple quick changes don't trigger multiple fetches for no reason
b69ab31201
b69ab31202 this.logger.info('setting up dirstate subscription', realDotdir);
b69ab31203
b69ab31204 const dirstateSubscription = await this.watchman.watchDirectoryRecursive(
b69ab31205 realDotdir,
b69ab31206 DIRSTATE_WATCHMAN_SUBSCRIPTION,
b69ab31207 {
b69ab31208 fields: ['name'],
b69ab31209 expression: [
b69ab31210 'name',
b69ab31211 ['bookmarks.current', 'bookmarks', 'dirstate', 'merge'],
b69ab31212 'wholename',
b69ab31213 ],
b69ab31214 defer: [WatchForChanges.WATCHMAN_DEFER],
b69ab31215 empty_on_fresh_instance: true,
b69ab31216 },
b69ab31217 );
b69ab31218 dirstateSubscription.emitter.on('change', changes => {
b69ab31219 if (changes.includes('merge')) {
b69ab31220 this.changeCallback('merge conflicts');
b69ab31221 }
b69ab31222 if (changes.includes('dirstate')) {
b69ab31223 handleRepositoryStateChange();
b69ab31224 }
b69ab31225 });
b69ab31226 dirstateSubscription.emitter.on('fresh-instance', handleRepositoryStateChange);
b69ab31227
b69ab31228 this.dirstateDisposables.push(() => {
b69ab31229 this.logger.info('unsubscribe dirstate watcher');
b69ab31230 this.watchman.unwatch(realDotdir, DIRSTATE_WATCHMAN_SUBSCRIPTION);
b69ab31231 });
b69ab31232 } catch (err) {
b69ab31233 this.logger.error('failed to setup dirstate subscriptions', err);
b69ab31234 this.tracker.error(
b69ab31235 'WatchmanEvent',
b69ab31236 'WatchmanError',
b69ab31237 `failed to setup watchman dirstate subscriptions ${err}`,
b69ab31238 );
b69ab31239 }
b69ab31240 }
b69ab31241
b69ab31242 private async setupEdenDirstateSubscriptions() {
b69ab31243 const {repoRoot, dotdir} = this.repoInfo;
b69ab31244
b69ab31245 if (repoRoot == null || dotdir == null) {
b69ab31246 this.logger.error(`skipping dirstate subscription since ${repoRoot} is not a repository`);
b69ab31247 return;
b69ab31248 }
b69ab31249
b69ab31250 const relativeRoot = path.relative(repoRoot, dotdir);
b69ab31251
b69ab31252 const DIRSTATE_EDENFS_SUBSCRIPTION = 'sapling-smartlog-dirstate-change-edenfs';
b69ab31253 try {
b69ab31254 const handleRepositoryStateChange = debounce(() => {
b69ab31255 // if the repo changes, also recheck files. E.g. if you commit, your uncommitted changes will also change.
b69ab31256 this.changeCallback('everything');
b69ab31257
b69ab31258 // reset timer for polling
b69ab31259 this.lastFetch = new Date().valueOf();
b69ab31260 }, 100); // debounce so that multiple quick changes don't trigger multiple fetches for no reason
b69ab31261
b69ab31262 this.logger.info(
b69ab31263 'setting up dirstate edenfs subscription in root',
b69ab31264 repoRoot,
b69ab31265 'at',
b69ab31266 relativeRoot,
b69ab31267 );
b69ab31268
b69ab31269 const subscriptionCallback: SubscriptionCallback = (error, resp) => {
b69ab31270 if (error) {
b69ab31271 this.logger.error('EdenFS dirstate subscription error:', error.message);
b69ab31272 this.tracker.error('EdenWatcherEvent', 'EdenWatcherError', error);
b69ab31273 return;
b69ab31274 } else if (resp === null) {
b69ab31275 // EdenFS subscription closed
b69ab31276 return;
b69ab31277 } else {
b69ab31278 if (resp.changes && resp.changes.length > 0) {
b69ab31279 resp.changes.forEach(change => {
b69ab31280 if (change.SmallChange) {
b69ab31281 const paths = EdenFSUtils.extractPaths([change]);
b69ab31282 if (paths.includes('merge')) {
b69ab31283 this.changeCallback('merge conflicts');
b69ab31284 return;
b69ab31285 }
b69ab31286 if (paths.includes('dirstate')) {
b69ab31287 handleRepositoryStateChange();
b69ab31288 return;
b69ab31289 }
b69ab31290 } else if (change.LargeChange) {
b69ab31291 handleRepositoryStateChange();
b69ab31292 return;
b69ab31293 }
b69ab31294 });
b69ab31295 }
b69ab31296 return;
b69ab31297 }
b69ab31298 };
b69ab31299
b69ab31300 await this.edenfs.watchDirectoryRecursive(
b69ab31301 repoRoot,
b69ab31302 DIRSTATE_EDENFS_SUBSCRIPTION,
b69ab31303 {
b69ab31304 useCase: 'isl-server-node',
b69ab31305 mountPoint: repoRoot,
b69ab31306 throttle: 100,
b69ab31307 relativeRoot,
b69ab31308 deferredStates: [
b69ab31309 WatchForChanges.WATCHMAN_DEFER,
b69ab31310 WatchForChanges.WATCHMAN_DEFER_TRANSACTION,
b69ab31311 ],
b69ab31312 includeVcsRoots: true,
b69ab31313 },
b69ab31314 subscriptionCallback,
b69ab31315 );
b69ab31316 this.dirstateDisposables.push(() => {
b69ab31317 this.logger.info('unsubscribe dirstate edenfs watcher');
b69ab31318 this.edenfs.unwatch(repoRoot, DIRSTATE_EDENFS_SUBSCRIPTION);
b69ab31319 });
b69ab31320 } catch (err) {
b69ab31321 this.logger.error('failed to setup dirstate edenfs subscriptions', err);
b69ab31322 this.tracker.error(
b69ab31323 'EdenWatcherEvent',
b69ab31324 'EdenWatcherError',
b69ab31325 `failed to setup dirstate edenfs subscriptions ${err}`,
b69ab31326 );
b69ab31327 }
b69ab31328 }
b69ab31329
b69ab31330 public async setupSubscriptions(ctx: RepositoryContext) {
b69ab31331 await this.waitForDirstateSubscriptionReady();
b69ab31332 const enabled = await Internal.fetchFeatureFlag?.(ctx, 'isl_use_edenfs_notifications');
b69ab31333 this.logger.info('subscription edenfs notifications flag state: ', enabled);
b69ab31334 if (enabled) {
b69ab31335 if (this.repoInfo.isEdenFs === true) {
b69ab31336 await this.setupEdenSubscriptions();
b69ab31337 return;
b69ab31338 }
b69ab31339 } else {
b69ab31340 // TODO: move watchman here after implementing eden
b69ab31341 }
b69ab31342 await this.setupWatchmanSubscriptions();
b69ab31343 }
b69ab31344
b69ab31345 /**
b69ab31346 * Some Watchmans subscriptions should only activate when ISL is actually opened.
b69ab31347 * On platforms like vscode, it's possible to create a Repository without actually opening ISL.
b69ab31348 * In those cases, we only want the minimum set of subscriptions to be active.
b69ab31349 * We care about the dirstate watcher, but not the watchman subscriptions in that case.
b69ab31350 */
b69ab31351 public async setupWatchmanSubscriptions() {
b69ab31352 const {repoRoot, dotdir} = this.repoInfo;
b69ab31353
b69ab31354 if (repoRoot == null || dotdir == null) {
b69ab31355 this.logger.error(`skipping watchman subscription since ${repoRoot} is not a repository`);
b69ab31356 return;
b69ab31357 }
b69ab31358 const relativeDotdir = path.relative(repoRoot, dotdir);
b69ab31359 // if working from a git clone, the dotdir lives in .git/sl,
b69ab31360 // but we need to ignore changes in .git in our watchman subscriptions
b69ab31361 const outerDotDir =
b69ab31362 relativeDotdir.indexOf(path.sep) >= 0 ? path.dirname(relativeDotdir) : relativeDotdir;
b69ab31363
b69ab31364 await this.maybeModifyGitignore(repoRoot, outerDotDir);
b69ab31365
b69ab31366 const FILE_CHANGE_WATCHMAN_SUBSCRIPTION = 'sapling-smartlog-file-change';
b69ab31367 try {
b69ab31368 // In some bad cases, a file may not be getting ignored by watchman properly,
b69ab31369 // and ends up constantly triggering the watchman subscription.
b69ab31370 // Incrementally increase the throttling of events to avoid spamming `status`.
b69ab31371 // This does mean "legit" changes will start being missed.
b69ab31372 // TODO: can we scan the list of changes and build a list of files that are overfiring, then send those to the UI as a warning?
b69ab31373 // This would allow a user to know it's happening and possibly fix it for their repo by adding it to a .watchmanconfig.
b69ab31374 const handleUncommittedChanges = stagedThrottler(
b69ab31375 [
b69ab31376 {
b69ab31377 throttleMs: 0,
b69ab31378 numToNextStage: 5,
b69ab31379 resetAfterMs: 5_000,
b69ab31380 onEnter: () => {
b69ab31381 this.logger.info('no longer throttling uncommitted changes');
b69ab31382 },
b69ab31383 },
b69ab31384 {
b69ab31385 throttleMs: 5_000,
b69ab31386 numToNextStage: 10,
b69ab31387 resetAfterMs: 20_000,
b69ab31388 onEnter: () => {
b69ab31389 this.logger.info('slightly throttling uncommitted changes');
b69ab31390 },
b69ab31391 },
b69ab31392 {
b69ab31393 throttleMs: 30_000,
b69ab31394 resetAfterMs: 30_000,
b69ab31395 onEnter: () => {
b69ab31396 this.logger.info('aggressively throttling uncommitted changes');
b69ab31397 },
b69ab31398 },
b69ab31399 ],
b69ab31400 () => {
b69ab31401 this.changeCallback('uncommitted changes');
b69ab31402
b69ab31403 // reset timer for polling
b69ab31404 this.lastFetch = new Date().valueOf();
b69ab31405 },
b69ab31406 );
b69ab31407 const uncommittedChangesSubscription = await this.watchman.watchDirectoryRecursive(
b69ab31408 repoRoot,
b69ab31409 FILE_CHANGE_WATCHMAN_SUBSCRIPTION,
b69ab31410 {
b69ab31411 // We only need to know that a change happened (not the list of files) so that we can trigger `status`
b69ab31412 fields: ['name'],
b69ab31413 expression: [
b69ab31414 'allof',
b69ab31415 // This watchman subscription is used to determine when and which
b69ab31416 // files to fetch new statuses for. There is no reason to include
b69ab31417 // directories in these updates, and in fact they may make us overfetch
b69ab31418 // statuses.
b69ab31419 // This line restricts this subscription to only return files.
b69ab31420 ['type', 'f'],
b69ab31421 ['not', ['dirname', outerDotDir]],
b69ab31422 // Even though we tell it not to match .sl, modifying a file inside .sl
b69ab31423 // will emit an event for the folder itself, which we want to ignore.
b69ab31424 ['not', ['match', outerDotDir, 'basename']],
b69ab31425 // Exclude edenfs notifications directory - EdenFS notifications can modify hidden files
b69ab31426 // in this directory which would otherwise trigger watchman events.
b69ab31427 ['not', ['dirname', '.edenfs-notifications-state']],
b69ab31428 ['not', ['match', '.edenfs-notifications-state', 'basename']],
b69ab31429 ],
b69ab31430 defer: [WatchForChanges.WATCHMAN_DEFER],
b69ab31431 empty_on_fresh_instance: true,
b69ab31432 },
b69ab31433 );
b69ab31434 uncommittedChangesSubscription.emitter.on('change', handleUncommittedChanges);
b69ab31435 uncommittedChangesSubscription.emitter.on('fresh-instance', handleUncommittedChanges);
b69ab31436
b69ab31437 this.watchmanDisposables.push(() => {
b69ab31438 this.logger.info('unsubscribe watchman');
b69ab31439 this.watchman.unwatch(repoRoot, FILE_CHANGE_WATCHMAN_SUBSCRIPTION);
b69ab31440 });
b69ab31441 } catch (err) {
b69ab31442 this.logger.error('failed to setup watchman subscriptions', err);
b69ab31443 this.tracker.error(
b69ab31444 'WatchmanEvent',
b69ab31445 'WatchmanError',
b69ab31446 `failed to setup watchman subscriptions ${err}`,
b69ab31447 );
b69ab31448 }
b69ab31449 }
b69ab31450
b69ab31451 public async setupEdenSubscriptions() {
b69ab31452 const {repoRoot, dotdir} = this.repoInfo;
b69ab31453
b69ab31454 if (repoRoot == null || dotdir == null) {
b69ab31455 this.logger.error(`skipping edenfs subscription since ${repoRoot} is not a repository`);
b69ab31456 return;
b69ab31457 }
b69ab31458 const relativeDotdir = path.relative(repoRoot, dotdir);
b69ab31459 // if working from a git clone, the dotdir lives in .git/sl,
b69ab31460 // but we need to ignore changes in .git in our watchman subscriptions
b69ab31461 const outerDotDir =
b69ab31462 relativeDotdir.indexOf(path.sep) >= 0 ? path.dirname(relativeDotdir) : relativeDotdir;
b69ab31463
b69ab31464 this.logger.info(
b69ab31465 'setting up edenfs subscription in',
b69ab31466 repoRoot,
b69ab31467 'at',
b69ab31468 outerDotDir,
b69ab31469 'relativeDotdir',
b69ab31470 relativeDotdir,
b69ab31471 );
b69ab31472
b69ab31473 const FILE_CHANGE_EDENFS_SUBSCRIPTION = 'sapling-smartlog-file-change-edenfs';
b69ab31474 try {
b69ab31475 // In some bad cases, a file that has a lot of activity can constantly trigger the subscription.
b69ab31476 // Incrementally increase the throttling of events to avoid spamming `status`.
b69ab31477 // This does mean "legit" changes will start being missed.
b69ab31478 // TODO: can we scan the list of changes and build a list of files that are overfiring, then send those to the UI as a warning?
b69ab31479 // This would allow a user to know it's happening and possibly fix it for their repo.
b69ab31480 const handleUncommittedChanges = stagedThrottler(
b69ab31481 [
b69ab31482 {
b69ab31483 throttleMs: 0,
b69ab31484 numToNextStage: 5,
b69ab31485 resetAfterMs: 5_000,
b69ab31486 onEnter: () => {
b69ab31487 this.logger.info('no longer throttling uncommitted changes');
b69ab31488 },
b69ab31489 },
b69ab31490 {
b69ab31491 throttleMs: 5_000,
b69ab31492 numToNextStage: 10,
b69ab31493 resetAfterMs: 20_000,
b69ab31494 onEnter: () => {
b69ab31495 this.logger.info('slightly throttling uncommitted changes');
b69ab31496 },
b69ab31497 },
b69ab31498 {
b69ab31499 throttleMs: 30_000,
b69ab31500 resetAfterMs: 30_000,
b69ab31501 onEnter: () => {
b69ab31502 this.logger.info('aggressively throttling uncommitted changes');
b69ab31503 },
b69ab31504 },
b69ab31505 ],
b69ab31506 () => {
b69ab31507 this.changeCallback('uncommitted changes');
b69ab31508
b69ab31509 // reset timer for polling
b69ab31510 this.lastFetch = new Date().valueOf();
b69ab31511 },
b69ab31512 );
b69ab31513 const subscriptionCallback: SubscriptionCallback = (error, resp) => {
b69ab31514 if (error) {
b69ab31515 this.logger.error('EdenFS subscription error:', error.message);
b69ab31516 this.tracker.error('EdenWatcherEvent', 'EdenWatcherError', error);
b69ab31517 return;
b69ab31518 } else if (resp === null) {
b69ab31519 // EdenFS subscription closed
b69ab31520 return;
b69ab31521 } else {
b69ab31522 if (resp.changes && resp.changes.length > 0) {
b69ab31523 handleUncommittedChanges();
b69ab31524 }
b69ab31525 }
b69ab31526 };
b69ab31527 await this.edenfs.watchDirectoryRecursive(
b69ab31528 repoRoot,
b69ab31529 FILE_CHANGE_EDENFS_SUBSCRIPTION,
b69ab31530 {
b69ab31531 useCase: 'isl-server-node',
b69ab31532 mountPoint: repoRoot,
b69ab31533 throttle: 100,
b69ab31534 deferredStates: [
b69ab31535 WatchForChanges.WATCHMAN_DEFER,
b69ab31536 WatchForChanges.WATCHMAN_DEFER_TRANSACTION,
b69ab31537 ],
b69ab31538 excludedRoots: [outerDotDir, relativeDotdir],
b69ab31539 },
b69ab31540 subscriptionCallback,
b69ab31541 );
b69ab31542
b69ab31543 this.edenfsDisposables.push(() => {
b69ab31544 this.logger.info('unsubscribe edenfs');
b69ab31545 this.edenfs.unwatch(repoRoot, FILE_CHANGE_EDENFS_SUBSCRIPTION);
b69ab31546 });
b69ab31547 } catch (err) {
b69ab31548 this.logger.error('failed to setup edenfs subscriptions', err);
b69ab31549 this.tracker.error(
b69ab31550 'EdenWatcherEvent',
b69ab31551 'EdenWatcherError',
b69ab31552 `failed to setup edenfs subscriptions ${err}`,
b69ab31553 );
b69ab31554 }
b69ab31555 }
b69ab31556
b69ab31557 /**
b69ab31558 * Modify gitignore to ignore watchman cookie files. This is needed when using ISL
b69ab31559 * with git repos. `git status` does not exclude watchman cookie files by default.
b69ab31560 * `sl` does not use watchman in dotgit mode.
b69ab31561 */
b69ab31562 private async maybeModifyGitignore(repoRoot: string, outerDotDir: string) {
b69ab31563 if (outerDotDir !== '.git') {
b69ab31564 return;
b69ab31565 }
b69ab31566 const gitIgnorePath = path.join(repoRoot, outerDotDir, 'info', 'exclude');
b69ab31567 // https://github.com/facebook/watchman/blob/76bd924b1169dae9cb9f5371845ab44ea1f836bf/watchman/Cookie.h#L15
b69ab31568 const rule = '/.watchman-cookie-*';
b69ab31569 try {
b69ab31570 const gitIgnoreContent = await fs.readFile(gitIgnorePath, 'utf8');
b69ab31571 if (!gitIgnoreContent.includes(rule)) {
b69ab31572 await fs.appendFile(gitIgnorePath, `\n${rule}\n`, 'utf8');
b69ab31573 }
b69ab31574 } catch (err) {
b69ab31575 this.logger.error(`failed to read or write ${gitIgnorePath}`, err);
b69ab31576 }
b69ab31577 }
b69ab31578
b69ab31579 public disposeWatchmanSubscriptions() {
b69ab31580 this.watchmanDisposables.forEach(dispose => dispose());
b69ab31581 }
b69ab31582
b69ab31583 public disposeEdenFSSubscriptions() {
b69ab31584 this.edenfsDisposables.forEach(dispose => dispose());
b69ab31585 }
b69ab31586
b69ab31587 public dispose() {
b69ab31588 this.dirstateDisposables.forEach(dispose => dispose());
b69ab31589 this.disposeWatchmanSubscriptions();
b69ab31590 this.disposeEdenFSSubscriptions();
b69ab31591 if (this.timeout) {
b69ab31592 clearTimeout(this.timeout);
b69ab31593 this.timeout = undefined;
b69ab31594 }
b69ab31595 }
b69ab31596}