diff --git a/src/main/ports/workspace-port-page-probe.test.ts b/src/main/ports/workspace-port-page-probe.test.ts index 2e635e32f63..cd7e61771e8 100644 --- a/src/main/ports/workspace-port-page-probe.test.ts +++ b/src/main/ports/workspace-port-page-probe.test.ts @@ -59,6 +59,22 @@ describe('probeWorkspacePortPage', () => { ).resolves.toMatchObject({ kind: 'page', title: 'Sign in', faviconDataUrl: null }) }) + it.each([ + ['unclosed links', `ok${''.repeat(27_000), null] + ])('parses a page of %s without stalling the event loop', async (_name, page, title) => { + const port = await serve((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html' }).end(page) + }) + + const startedAt = performance.now() + await expect( + probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' }) + ).resolves.toEqual({ kind: 'page', title, faviconDataUrl: null }) + // Rescanning the body from each unclosed opener took seconds on this ~190 KB page. + expect(performance.now() - startedAt).toBeLessThan(500) + }) + it('leaves out APIs and error pages', async () => { const api = await serve((_request, response) => { response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}') diff --git a/src/main/ports/workspace-port-page-probe.ts b/src/main/ports/workspace-port-page-probe.ts index 0540f54acaa..1c260ced832 100644 --- a/src/main/ports/workspace-port-page-probe.ts +++ b/src/main/ports/workspace-port-page-probe.ts @@ -110,9 +110,26 @@ function fetchLoopback(url: URL, maxBytes: number): Promise]*>([\s\S]*?)<\/title>/i.exec(html) - const title = match ? decodeHtmlEntities(match[1]).replace(/\s+/g, ' ').trim() : '' + const open = /', open.lastIndex) + if (tagEnd === -1) { + return null + } + const close = /<\/title>/gi + close.lastIndex = tagEnd + 1 + const end = close.exec(html) + if (!end) { + return null + } + const title = decodeHtmlEntities(html.slice(tagEnd + 1, end.index)) + .replace(/\s+/g, ' ') + .trim() return title ? title.slice(0, 120) : null } @@ -133,7 +150,14 @@ function decodeHtmlEntities(text: string): string { } function findIconHref(html: string): string | null { - for (const tag of html.match(/]*>/gi) ?? []) { + const open = /', open.lastIndex) + if (end === -1) { + return null + } + const tag = html.slice(open.lastIndex, end) + open.lastIndex = end + 1 const attributes = new Map() for (const [, name, double, single, bare] of tag.matchAll( /\b(rel|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi