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