authorgravatar for ishaanko.mail@gmail.comIshaan Kothari <ishaanko.mail@gmail.com> 2026-10-05 21:09:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:09:13-07:00
logb69f8b5ff079cccc259bf2a001c2da2bc25d6892
tree9533ca8235d9cc5a24f66bc887149671d3d7c229
parentfec71c5ab4fd860331fb15ce37c15edf6db1c236
signature Signed by PGP key B5690EEEBB952194

fix(server): probe project favicon candidates concurrently (#12543)


2 files changed, 143 insertions(+), 52 deletions(-)

apps/server/src/project/ProjectFaviconResolver.test.ts+69
......@@ -402,6 +402,75 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => {
402402 }),
403403 );
404404
405 it.effect("probes well-known candidates concurrently", () =>
406 Effect.gen(function* () {
407 const fileSystem = yield* FileSystem.FileSystem;
408 const cwd = yield* makeTempDir;
409 let inFlight = 0;
410 let peakInFlight = 0;
411 const resolver = yield* makeResolverWithFileSystem(
412 FileSystem.FileSystem.of({
413 ...fileSystem,
414 stat: (filePath) =>
415 Effect.suspend(() => {
416 inFlight += 1;
417 peakInFlight = Math.max(peakInFlight, inFlight);
418 return Effect.yieldNow.pipe(Effect.andThen(fileSystem.stat(filePath)));
419 }).pipe(Effect.ensuring(Effect.sync(() => (inFlight -= 1)))),
420 }),
421 );
422
423 expect(yield* resolver.resolvePath(cwd)).toBeNull();
424 expect(peakInFlight).toBeGreaterThan(1);
425 }),
426 );
427
428 it.effect("returns a found favicon without waiting for lower-ranked probes", () =>
429 Effect.gen(function* () {
430 const fileSystem = yield* FileSystem.FileSystem;
431 const path = yield* Path.Path;
432 const cwd = yield* makeTempDir;
433 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
434 const stuckPath = path.join(cwd, "favicon.ico");
435 const resolver = yield* makeResolverWithFileSystem(
436 FileSystem.FileSystem.of({
437 ...fileSystem,
438 stat: (filePath) => (filePath === stuckPath ? Effect.never : fileSystem.stat(filePath)),
439 }),
440 );
441
442 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
443 }),
444 );
445
446 it.effect("ignores a failing candidate that ranks below a found favicon", () =>
447 Effect.gen(function* () {
448 const fileSystem = yield* FileSystem.FileSystem;
449 const path = yield* Path.Path;
450 const cwd = yield* makeTempDir;
451 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
452 const failingPath = path.join(cwd, "favicon.ico");
453 const resolver = yield* makeResolverWithFileSystem(
454 FileSystem.FileSystem.of({
455 ...fileSystem,
456 stat: (filePath) =>
457 filePath === failingPath
458 ? Effect.fail(
459 PlatformError.systemError({
460 _tag: "PermissionDenied",
461 module: "FileSystem",
462 method: "stat",
463 pathOrDescriptor: failingPath,
464 }),
465 )
466 : fileSystem.stat(filePath),
467 }),
468 );
469
470 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
471 }),
472 );
473
405474 it.effect("preserves icon source read failures", () =>
406475 Effect.gen(function* () {
407476 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/project/ProjectFaviconResolver.ts+74-52
......@@ -11,6 +11,7 @@ import * as Context from "effect/Context";
1111import * as Duration from "effect/Duration";
1212import * as Effect from "effect/Effect";
1313import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
1415import * as FileSystem from "effect/FileSystem";
1516import * as Layer from "effect/Layer";
1617import * as Option from "effect/Option";
......@@ -21,8 +22,8 @@ import * as Schema from "effect/Schema";
2122import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2223import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
2324
24// Resolution walks up to 12 well-known paths plus 7 source files, so a miss
25// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon
25// Resolution probes 21 well-known paths plus 7 source files, so a miss
26// costs ~30 filesystem probes. AssetAccess resolves on every project-favicon
2627// asset URL, and a project's icon does not move, so the answer is cached.
2728const FAVICON_CACHE_CAPACITY = 512;
2829const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);
......@@ -205,6 +206,69 @@ export const make = Effect.gen(function* () {
205206 return null;
206207 });
207208
209 // Reads one source file and resolves the icon it declares, if any.
210 const findIconFromSource = Effect.fn("ProjectFaviconResolver.findIconFromSource")(function* (
211 projectCwd: string,
212 sourceFile: string,
213 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
214 const sourcePath = yield* workspacePaths
215 .resolveRelativePathWithinRoot({
216 workspaceRoot: projectCwd,
217 relativePath: sourceFile,
218 })
219 .pipe(
220 Effect.mapError(
221 (cause) =>
222 new ProjectFaviconResolutionError({
223 operation: "resolve-path",
224 workspaceRoot: projectCwd,
225 relativePath: sourceFile,
226 cause,
227 }),
228 ),
229 );
230 const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe(
231 Effect.mapError(
232 (cause) =>
233 new ProjectFaviconResolutionError({
234 operation: "read-source",
235 workspaceRoot: projectCwd,
236 relativePath: sourceFile,
237 absolutePath: sourcePath.absolutePath,
238 cause,
239 }),
240 ),
241 );
242 if (Option.isNone(source)) {
243 return null;
244 }
245 const href = extractIconHref(source.value);
246 if (!href) {
247 return null;
248 }
249 return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
250 });
251
252 // Starts every probe at once and returns the first hit in list order. A miss
253 // costs one round of filesystem latency instead of one per probe, which
254 // matters when the disk is slow. Fibers are joined in order, so a hit returns
255 // without waiting for lower-ranked probes (the scope interrupts them), and a
256 // failure surfaces only when no earlier probe found a file.
257 const firstInOrder = <A>(
258 items: ReadonlyArray<A>,
259 probe: (item: A) => Effect.Effect<string | null, ProjectFaviconResolutionError>,
260 ) =>
261 Effect.gen(function* () {
262 const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item)));
263 for (const fiber of fibers) {
264 const found = yield* Fiber.join(fiber);
265 if (found) {
266 return found;
267 }
268 }
269 return null;
270 }).pipe(Effect.scoped);
271
208272 const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* (
209273 cwd: string,
210274 faviconPath?: string,
......@@ -241,58 +305,16 @@ export const make = Effect.gen(function* () {
241305 }
242306 }
243307
244 for (const candidate of FAVICON_CANDIDATES) {
245 const existing = yield* findExistingFile(projectCwd, [candidate], "workspace");
246 if (existing) {
247 return existing;
248 }
249 }
250
251 for (const sourceFile of ICON_SOURCE_FILES) {
252 const sourcePath = yield* workspacePaths
253 .resolveRelativePathWithinRoot({
254 workspaceRoot: projectCwd,
255 relativePath: sourceFile,
256 })
257 .pipe(
258 Effect.mapError(
259 (cause) =>
260 new ProjectFaviconResolutionError({
261 operation: "resolve-path",
262 workspaceRoot: projectCwd,
263 relativePath: sourceFile,
264 cause,
265 }),
266 ),
267 );
268 const source = yield* optionOnNotFound(
269 fileSystem.readFileString(sourcePath.absolutePath),
270 ).pipe(
271 Effect.mapError(
272 (cause) =>
273 new ProjectFaviconResolutionError({
274 operation: "read-source",
275 workspaceRoot: projectCwd,
276 relativePath: sourceFile,
277 absolutePath: sourcePath.absolutePath,
278 cause,
279 }),
280 ),
281 );
282 if (Option.isNone(source)) {
283 continue;
284 }
285 const href = extractIconHref(source.value);
286 if (!href) {
287 continue;
288 }
289 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
290 if (existing) {
291 return existing;
292 }
308 const wellKnown = yield* firstInOrder(FAVICON_CANDIDATES, (candidate) =>
309 findExistingFile(projectCwd, [candidate], "workspace"),
310 );
311 if (wellKnown) {
312 return wellKnown;
293313 }
294314
295 return null;
315 return yield* firstInOrder(ICON_SOURCE_FILES, (sourceFile) =>
316 findIconFromSource(projectCwd, sourceFile),
317 );
296318 });
297319
298320 const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>(