8.5 KB242 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {AbsolutePath, RepositoryError, ValidatedRepoInfo} from 'isl/src/types';
9import type {RepositoryContext} from './serverTypes';
10
11import {TypedEventEmitter} from 'shared/TypedEventEmitter';
12import {ensureTrailingPathSep} from 'shared/pathUtils';
13import {Repository} from './Repository';
14
15/**
16 * Reference-counting access to a {@link Repository}, via a Promise.
17 * Be sure to `unref()` when no longer needed.
18 */
19export interface RepositoryReference {
20 promise: Promise<Repository | RepositoryError>;
21 unref: () => unknown;
22}
23
24/**
25 * We return `RepositoryReference`s synchronously before we have the Repository,
26 * but reference counts should be associated with the actual async constructed Repository,
27 * which is why we can't return RefCounted<Repository> directly.
28 */
29class RepositoryReferenceImpl implements RepositoryReference {
30 constructor(
31 public promise: Promise<Repository | RepositoryError>,
32 private disposeFunc: () => void,
33 ) {}
34 public unref() {
35 if (!this.disposed) {
36 this.disposed = true;
37 this.disposeFunc();
38 }
39 }
40
41 internalReference: RefCounted<Repository> | undefined;
42 disposed = false;
43}
44
45class RefCounted<T extends {dispose: () => void}> {
46 constructor(public value: T) {}
47 private references = 0;
48
49 public isDisposed = false;
50 public ref() {
51 this.references++;
52 }
53 public getNumberOfReferences() {
54 return this.references;
55 }
56 public dispose() {
57 this.references--;
58 if (!this.isDisposed && this.references === 0) {
59 this.isDisposed = true;
60 this.value.dispose();
61 }
62 }
63}
64
65class RepoMap {
66 /**
67 * Previously distributed RepositoryReferences, keyed by repository root path
68 * Note that Repositories do not define their own `cwd`, and can be reused across cwds.
69 */
70 private reposByRoot = new Map<AbsolutePath, RefCounted<Repository>>();
71
72 /**
73 * Ordered by length from longest to shortest, for quick longest prefix match.
74 * Used with reposByRoot to achieve O(n) prefix lookup.
75 */
76 private orderedRoots: string[] = [];
77
78 private static cmpRoots = (a: string, b: string) => b.length - a.length;
79
80 public get(repoRoot: AbsolutePath): RefCounted<Repository> | undefined {
81 return this.reposByRoot.get(repoRoot);
82 }
83
84 public getLongestPrefixMatch(path: AbsolutePath): RefCounted<Repository> | undefined {
85 for (const repoRoot of this.orderedRoots) {
86 if (path === repoRoot || path.startsWith(ensureTrailingPathSep(repoRoot))) {
87 return this.reposByRoot.get(repoRoot);
88 }
89 }
90 return undefined;
91 }
92
93 public set(repoRoot: AbsolutePath, repoRef: RefCounted<Repository>) {
94 this.reposByRoot.set(repoRoot, repoRef);
95 this.orderedRoots.push(repoRoot);
96 this.orderedRoots.sort(RepoMap.cmpRoots);
97 }
98
99 public values(): IterableIterator<RefCounted<Repository>> {
100 return this.reposByRoot.values();
101 }
102
103 public forEach(callback: (value: RefCounted<Repository>) => void) {
104 this.reposByRoot.forEach(callback);
105 }
106}
107
108/**
109 * Allow reusing Repository instances by storing instances by path,
110 * and controlling how Repositories are created.
111 *
112 * Some async work is needed to construct repositories (finding repo root dir),
113 * so it's possible to duplicate some work if multiple repos are constructed at similar times.
114 * We still enable Repository reuse in this case by double checking for pre-existing Repos at the last second.
115 */
116class RepositoryCache {
117 // allow mocking Repository in tests
118 constructor(private RepositoryType = Repository) {}
119
120 private repoMap = new RepoMap();
121 private activeReposEmitter = new TypedEventEmitter<'change', undefined>();
122
123 private lookup(dirGuess: AbsolutePath): RefCounted<Repository> | undefined {
124 const found = this.repoMap.get(dirGuess);
125 return found && !found.isDisposed ? found : undefined;
126 }
127
128 // Longest match is necessary because repos can be nested (as submodules).
129 private lookupByPrefix(dirGuess: AbsolutePath): RefCounted<Repository> | undefined {
130 const found = this.repoMap.getLongestPrefixMatch(dirGuess);
131 return found && !found.isDisposed ? found : undefined;
132 }
133
134 /**
135 * Create a new Repository, or reuse if one already exists.
136 * Repositories are reference-counted to ensure they can be disposed when no longer needed.
137 */
138 getOrCreate(ctx: RepositoryContext): RepositoryReference {
139 // Fast path: if this cwd is already a known repo root, we can use it directly.
140 // This only works if the cwd happens to be the repo root.
141 const found = this.lookup(ctx.cwd);
142 if (found) {
143 found.ref();
144 return new RepositoryReferenceImpl(Promise.resolve(found.value), () => found.dispose());
145 }
146
147 // More typically, we can reuse a Repository among different cwds:
148
149 // eslint-disable-next-line prefer-const
150 let ref: RepositoryReferenceImpl;
151 const lookupRepoInfoAndReuseIfPossible = async (): Promise<Repository | RepositoryError> => {
152 // TODO: we should rate limit how many getRepoInfos we run at a time, and make other callers just wait.
153 // this would guard against querying lots of redundant paths within the same repo.
154 // This is probably not necessary right now, but would be useful for a VS Code extension where we need to query
155 // individual file paths to add diff gutters.
156 const repoInfo = await this.RepositoryType.getRepoInfo(ctx);
157 // important: there should be no `await` points after here, to ensure there is no race when reusing Repositories.
158 if (repoInfo.type !== 'success') {
159 // No repository found at this root, or some other error prevents the repo from being created
160 return repoInfo;
161 }
162
163 if (ref.disposed) {
164 // If the reference is disposed, the caller gave up while waiting for the repo to be created.
165 // make sure we don't create a dangling Repository.
166 return {type: 'unknownError', error: new Error('Repository already disposed')};
167 }
168
169 // Now that we've spent some async time to determine this repo's actual root,
170 // we can check if we already have a reference to it saved.
171 // This way, we can still reuse a Repository, and only risk duplicating `getRepoInfo` work.
172 const newlyFound = this.lookup(repoInfo.repoRoot);
173 if (newlyFound) {
174 // if it is found now, it means either the cwd differs from the repo root (lookup fails), or
175 // another instance was created at the same time and finished faster than this one (lookup failed before, but succeeds now).
176
177 newlyFound.ref();
178 ref.internalReference = newlyFound;
179 return newlyFound.value;
180 }
181
182 // This is where we actually start new subscriptions and trigger work, so we should only do this
183 // once we're sure we don't have a repository to reuse.
184 const repo = new this.RepositoryType(
185 repoInfo as ValidatedRepoInfo, // repoInfo is now guaranteed to have these root/dotdir set
186 ctx,
187 );
188
189 const internalRef = new RefCounted(repo);
190 internalRef.ref();
191 ref.internalReference = internalRef;
192 this.repoMap.set(repoInfo.repoRoot, internalRef);
193 this.activeReposEmitter.emit('change');
194 return repo;
195 };
196 ref = new RepositoryReferenceImpl(lookupRepoInfoAndReuseIfPossible(), () => {
197 if (ref.internalReference) {
198 ref.internalReference.dispose();
199 }
200 ref.unref();
201 });
202 return ref;
203 }
204
205 /**
206 * Lookup a cached repository without creating a new one if it doesn't exist
207 */
208 public cachedRepositoryForPath(path: AbsolutePath): Repository | undefined {
209 const ref = this.lookupByPrefix(path);
210 return ref?.value;
211 }
212
213 public onChangeActiveRepos(cb: (repos: Array<Repository>) => unknown): () => unknown {
214 const onChange = () => {
215 cb([...this.repoMap.values()].map(ref => ref.value));
216 };
217 this.activeReposEmitter.on('change', onChange);
218 // start with initial repos set
219 onChange();
220 return () => this.activeReposEmitter.off('change', onChange);
221 }
222
223 /** Clean up all known repos. Mostly useful for testing. */
224 clearCache() {
225 this.repoMap.forEach(repo => repo.dispose());
226 this.repoMap = new RepoMap();
227 this.activeReposEmitter.removeAllListeners();
228 }
229
230 public numberOfActiveServers(): number {
231 let numActive = 0;
232 for (const repo of this.repoMap.values()) {
233 numActive += repo.getNumberOfReferences();
234 }
235 return numActive;
236 }
237}
238
239export const __TEST__ = {RepositoryCache, RepoMap, RefCounted};
240
241export const repositoryCache = new RepositoryCache();
242