1import type { NativeReviewDiffSource } from "./nativeReviewDiffTypes";
2import { diffHighlightTraceId, traceDiffHighlight } from "./nativeReviewDiffTrace";
3
4const MAX_SOURCE_FILES = 4;
5// Bound retained UTF-16 text across files without discarding an already-fetched
6// authoritative source just because it is large. Provider response limits still apply.
7const MAX_CACHED_SOURCE_CHARACTERS = 8_000_000;
8
9function isWeakKey(value: unknown): value is object {
10 return (typeof value === "object" && value !== null) || typeof value === "function";
11}
12
13function permanentSourceFailureReason(error: unknown): string | undefined {
14 for (let depth = 0; depth < 8 && typeof error === "object" && error !== null; depth++) {
15 const reason = "reason" in error ? error.reason : undefined;
16 const name = "name" in error ? error.name : undefined;
17 // Defect serialization preserves error names and causes, but drops custom fields.
18 if (name === "GitCafeDiffSourceUnavailableError") return "gitcafe-source-unavailable";
19 if (name === "GitHubDiffFileContentsUnavailableError") return "github-source-unavailable";
20 for (const permanent of [
21 "provider-unsupported",
22 "unsupported",
23 "oversized",
24 "binary",
25 "not-found",
26 ]) {
27 if (permanent === reason) return permanent;
28 }
29 if ("status" in error && error.status === 413) return "http-413";
30 if ("status" in error && error.status === 501) return "http-501";
31 // Older servers have no structured reason for an unsupported contents RPC.
32 if (
33 "detail" in error &&
34 error.detail === "This host cannot expand unchanged pull request lines."
35 )
36 return "legacy-host-unsupported";
37 error = "cause" in error ? error.cause : undefined;
38 }
39 return undefined;
40}
41
42function waitForSource(
43 promise: Promise<NativeReviewDiffSource | null>,
44 signal?: AbortSignal,
45): Promise<NativeReviewDiffSource | null> {
46 if (!signal) return promise;
47 if (signal.aborted) return Promise.resolve(null);
48 return new Promise((resolve, reject) => {
49 const abort = () => {
50 signal.removeEventListener("abort", abort);
51 resolve(null);
52 };
53 signal.addEventListener("abort", abort, { once: true });
54 void promise.then(
55 (source) => {
56 signal.removeEventListener("abort", abort);
57 resolve(source);
58 },
59 (error: unknown) => {
60 signal.removeEventListener("abort", abort);
61 reject(error);
62 },
63 );
64 });
65}
66
67export function createNativeReviewDiffSourceLoader<FileId>(
68 fetchSource: (fileId: FileId) => Promise<NativeReviewDiffSource>,
69) {
70 const cached = new Map<FileId, NativeReviewDiffSource>();
71 let cachedCharacters = 0;
72 type Failure = { attempts: number; unavailable: boolean };
73 // Request closures can own parsed rows; failed versions must not retain them.
74 const objectFailures = new WeakMap<object, Failure>();
75 const primitiveFailures = new Map<FileId, Failure>();
76 const pending = new Map<
77 FileId,
78 {
79 readonly promise: Promise<NativeReviewDiffSource | null>;
80 readonly subscribers: Set<AbortSignal | undefined>;
81 }
82 >();
83 // RPC commands cannot abort a request already sent. Serialize source fetches
84 // and drop queued requests whose viewports have all been cancelled.
85 let tail = Promise.resolve();
86 const loader = diffHighlightTraceId(cached);
87
88 return async (fileId: FileId, signal?: AbortSignal) => {
89 const file = diffHighlightTraceId(fileId);
90 const failure = (isWeakKey(fileId)
91 ? objectFailures.get(fileId)
92 : primitiveFailures.get(fileId)) ?? { attempts: 0, unavailable: false };
93 if (signal?.aborted || failure.unavailable) {
94 traceDiffHighlight("source-skip", {
95 loader,
96 file,
97 aborted: signal?.aborted ?? false,
98 unavailable: failure.unavailable,
99 });
100 return null;
101 }
102 if (cached.has(fileId)) {
103 traceDiffHighlight("source-cache", { loader, file });
104 const source = cached.get(fileId)!;
105 cached.delete(fileId);
106 cached.set(fileId, source);
107 return source;
108 }
109 let request = pending.get(fileId);
110 if (!request) {
111 traceDiffHighlight("source-queued", { loader, file, pending: pending.size });
112 const subscribers = new Set<AbortSignal | undefined>();
113 const promise = tail
114 .then(async () => {
115 if ([...subscribers].every((subscriber) => subscriber?.aborted)) {
116 // A cancelled request must stop being shareable before its promise settles.
117 pending.delete(fileId);
118 traceDiffHighlight("source-dropped", { loader, file });
119 return null;
120 }
121 const attempt = ++failure.attempts;
122 if (isWeakKey(fileId)) objectFailures.set(fileId, failure);
123 else primitiveFailures.set(fileId, failure);
124 traceDiffHighlight("source-start", { loader, file, attempt });
125 try {
126 const contents = await fetchSource(fileId);
127 traceDiffHighlight("source-result", {
128 loader,
129 file,
130 oldCharacters: contents.oldContents.length,
131 newCharacters: contents.newContents.length,
132 });
133 const characters = contents.oldContents.length + contents.newContents.length;
134 if (isWeakKey(fileId)) objectFailures.delete(fileId);
135 else primitiveFailures.delete(fileId);
136 if (characters <= MAX_CACHED_SOURCE_CHARACTERS) {
137 cached.set(fileId, contents);
138 cachedCharacters += characters;
139 while (
140 cached.size > MAX_SOURCE_FILES ||
141 cachedCharacters > MAX_CACHED_SOURCE_CHARACTERS
142 ) {
143 const oldest = cached.keys().next().value!;
144 const source = cached.get(oldest)!;
145 cachedCharacters -= source.oldContents.length + source.newContents.length;
146 cached.delete(oldest);
147 }
148 }
149 return contents;
150 } catch (error) {
151 const reason = permanentSourceFailureReason(error);
152 const permanent = reason !== undefined;
153 traceDiffHighlight("source-error", {
154 loader,
155 file,
156 attempt,
157 permanent,
158 reason: reason ?? "transient",
159 });
160 if (permanent) {
161 failure.unavailable = true;
162 traceDiffHighlight("source-unavailable", {
163 loader,
164 file,
165 reason: "permanent-error",
166 });
167 return null;
168 }
169 // Only a later viewport request retries; never loop inside this request.
170 throw error;
171 }
172 })
173 .finally(() => {
174 if (pending.get(fileId) === request) pending.delete(fileId);
175 });
176 tail = promise.then(
177 () => {},
178 () => {},
179 );
180 request = { promise, subscribers };
181 pending.set(fileId, request);
182 } else traceDiffHighlight("source-shared", { loader, file });
183 request.subscribers.add(signal);
184 try {
185 return await waitForSource(request.promise, signal);
186 } finally {
187 if (signal?.aborted) traceDiffHighlight("source-wait-aborted", { loader, file });
188 request.subscribers.delete(signal);
189 }
190 };
191}