From 6ba92f5fe17982724a19a05407363897a7da454d Mon Sep 17 00:00:00 2001 From: Matt Urenovich Date: Thu, 6 Aug 2026 11:10:21 -0400 Subject: [PATCH] fix(server): favicon resolution no longer pins the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LINK_ICON_OBJ_RE` was unanchored, so it restarted at every offset in an icon source file and rescanned forward from each one. A project with no icon file and a large `index.html` that has no `` made favicon resolution spin for minutes on the server's only thread, so every connection stopped being answered and the desktop client dropped into a permanent reconnect loop. Measured on a 1.6 MB generated `index.html`: 200 KB already cost ~4s, and the full file never completed. That pattern accepted any position from which both `rel` and `href` were visible before the next `}`, which is exactly "both live in the same brace-free run". Walking those runs directly gives the same answers in linear time, and keeps working where a single anchored pattern would not: runs holding `rel` but no `href` fall through to the next candidate, and metadata sitting beside a nested object still resolves. The same 1.6 MB file now finishes in 2 ms. The object branch had no test coverage, so this adds both key orders, a nested-object case, a no-href-then-valid case, and a large brace-sparse source that hangs without the fix. Model: Claude Opus 5 ยท Harness: Claude Code Co-Authored-By: Claude Opus 5 (1M context) --- .../project/ProjectFaviconResolver.test.ts | 103 ++++++++++++++++++ .../src/project/ProjectFaviconResolver.ts | 16 ++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..8642a294905 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -133,6 +133,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("resolves icon hrefs from object-literal route metadata", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/routes/__root.tsx", + `export const Route = createRootRoute({ + head: () => ({ + links: [ + { rel: "stylesheet", href: "/app.css" }, + { rel: "icon", href: "/brand/logo.svg" }, + ], + }), +});`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata when href precedes rel", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata alongside nested objects", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("skips icon metadata without an href and keeps scanning", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + // A large icon source with no icon metadata used to pin the server's event loop for + // minutes: the object pattern was unanchored, so it restarted at every offset and + // rescanned forward from each one. Anchoring keeps this proportional to file size. + it.effect("scans large icon sources without an icon in reasonable time", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata. + const filler = `

${"pokopia companion guide ".repeat(24)}

\n`; + yield* writeTextFile( + cwd, + "index.html", + `guide\n${filler.repeat(1200)}`, + ); + + const startedAt = performance.now(); + const resolved = yield* resolver.resolvePath(cwd); + const elapsedMs = performance.now() - startedAt; + + expect(resolved).toBeNull(); + expect(elapsedMs).toBeLessThan(5_000); + }), + ); + it.effect("returns null when no icon is present", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630..d107267ef66 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -55,10 +55,13 @@ const ICON_SOURCE_FILES = [ ] as const; // Matches tags or object-like icon metadata where rel/href can appear in any order. +// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; +const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( "ProjectFaviconResolutionError", @@ -98,8 +101,13 @@ export class ProjectFaviconResolver extends Context.Service< function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; + // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel` + // but no href falls through to the next one rather than ending the search. + for (const run of source.split("}")) { + if (!ICON_REL_RE.test(run)) continue; + const hrefMatch = run.match(ICON_HREF_RE); + if (hrefMatch?.[1]) return hrefMatch[1]; + } return null; }