Describe the bug
In the HyperFrames desktop app, a project whose index.html has a CSS url("assets/image.png") inside a style attribute makes the main process spin at 100% CPU from launch, climb past 1 GB and crash with EXC_BREAKPOINT (V8 out of memory) about 90 seconds later. Home builds that project's preview at launch, so the app crash-loops on every relaunch until the file is edited by hand.
Cause, confirmed with the inspector attached to the main process: by the time recordPreviewReferences (packages/studio-server/src/helpers/previewReads.ts) scans the preview HTML, the image has been inlined, so the unquoted url( branch of REFERENCE captures "data:image/png;base64,iVBOR...". The leading " defeats the scheme check (/^(?:[a-z][a-z0-9+.-]*:|[/#])/i), so the base64 is treated as a relative file path. linksOnTheWay then walks it one "folder" per / (base64 is full of them) and recordPreviewRead adds every ancestor of that path to the reads set. The cost is quadratic in the image size: for an 877 KB PNG the "path" was 1,169,082 characters with 20,198 slashes.
Where the " comes from: the desktop app's bundled takeover-explainer format (guides/formats/takeover-explainer/skills/takeover-explainer/scripts/build.py) writes background-image:url("...") in five places, so any takeover-style film with a large enough image hits this. That script is not in this repo, so I'm flagging it here.
Minimal reproduction
A verbatim copy of linksOnTheWay and recordPreviewReferences from previewReads.ts, run on a one-element page in its post-inlining form, against the same page quoted with '. Node built-ins only.
// repro.mjs
import { readlinkSync, mkdtempSync } from "node:fs";
import { dirname, join, parse, resolve } from "node:path";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
const REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
const readLink = (p) => { try { return readlinkSync(p); } catch { return null; } };
function linksOnTheWay(path) {
const passed = []; let at = parse(path).root; let rest = path.slice(at.length).split(/[\\/]+/);
for (let hops = 0; rest.length > 0; ) {
const part = rest.shift(); if (!part || part === ".") continue;
if (part === "..") { at = dirname(at); continue; }
const next = join(at, part); const target = hops < 40 ? readLink(next) : null;
if (target === null) { at = next; continue; }
hops++; passed.push(next); const root = parse(target).root; if (root) at = root;
rest = [...target.slice(root.length).split(/[\\/]+/), ...rest];
}
passed.push(at); return passed;
}
function recordPreviewReferences(projectDir, html, reads) {
const named = new Set();
for (const m of html.matchAll(REFERENCE)) {
const url = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? "").trim();
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
named.add(url.split(/[?#]/)[0]);
}
for (const p of named) {
const read = join(projectDir, p);
for (const found of [read, ...linksOnTheWay(read)])
for (let q = resolve(found).toLowerCase(); !reads.has(q); q = dirname(q)) reads.add(q);
}
return named;
}
const dir = mkdtempSync(join(tmpdir(), "hf-repro-"));
for (const kb of [16, 32, 64, 128]) {
const b64 = randomBytes(kb * 1024).toString("base64");
for (const [label, attr] of [["" (bug)", `url("data:image/png;base64,${b64}")`], ["' (control)", `url('data:image/png;base64,${b64}')`]]) {
const reads = new Set(); const t = performance.now();
const named = recordPreviewReferences(dir, `<div style="background-image:${attr}"></div>`, reads);
const chars = [...reads].reduce((n, s) => n + s.length, 0);
console.log(`${kb}KB ${label} refs=${named.size} time=${(performance.now() - t).toFixed(0)}ms reads=${reads.size} storedChars=${(chars / 1e6).toFixed(1)}M`);
}
}
Steps to reproduce
node repro.mjs
- In the desktop app (how I hit it): build a takeover-explainer film that uses a screenshot of about 1 MB, or put
style="background-image:url("assets/big.png")" on an element with a PNG of about that size in assets/, then quit and reopen the app. (I hit this through a takeover-explainer build and have not re-run a hand-made page in the app.)
Expected behavior
A data URI is skipped whatever quoting the attribute uses, a captured reference far too long to be a path is ignored, the preview builds and the app opens.
Actual behavior
Script output (cost grows with the square of the image size; the ' control costs nothing):
16KB " (bug) refs=1 time=17ms reads=742 storedChars=7.8M
16KB ' (control) refs=0 time=0ms reads=0 storedChars=0.0M
32KB " (bug) refs=1 time=46ms reads=1316 storedChars=28.3M
64KB " (bug) refs=1 time=191ms reads=2784 storedChars=121.4M
128KB " (bug) refs=1 time=718ms reads=5430 storedChars=476.2M
128KB ' (control) refs=0 time=0ms reads=0 storedChars=0.0M
In the desktop app: main process at 100% CPU from launch, around 1 GB RSS, then EXC_BREAKPOINT on CrBrowserMain about 90 seconds in (Datadog logs "Application crashed"). Four consecutive launches died the same way. The inspector stack was identical at every pause:
linksOnTheWay node_modules/@hyperframes/studio-server/dist/index.js:3536
recordPreviewRead node_modules/@hyperframes/studio-server/dist/index.js:3556
recordPreviewReferences node_modules/@hyperframes/studio-server/dist/index.js:3573
buildPreview node_modules/@hyperframes/studio-server/dist/index.js:3885
At the pause: path.length 1,169,082, rest.length still 14,007, preview HTML 15.6 MB (the same image inlined 12 times, deduplicated to one reference).
Environment
HyperFrames desktop 0.1.0 (build b268, prod channel)
Electron 44.2.0
@hyperframes/studio-server 0.8.133, @hyperframes/core 0.8.133
macOS 27.0.1 (26A434), Apple M1 Pro (arm64)
(desktop app, so no `npx hyperframes doctor` output)
previewReads.ts is unchanged on main as of 1d9b367.
Additional context
Possible fixes, any one of which would have prevented this:
- Decode HTML entities (at least
", ', &) in the captured value before the scheme check. The browser does this for attributes, so url("X") really means url("X").
- Skip any captured reference longer than a sane path length (say 4096 characters) before
linksOnTheWay.
- In takeover-explainer's
build.py, write url('...') instead of url("...").
- On the desktop side, Home building a crashing preview at launch turns one bad file into a crash loop. Something that lets the person reach the project would make it recoverable without a terminal.
Workaround for anyone hitting this: with the app quit, replace url("X") with url('X') in the project's index.html (grep -rl --include='*.html' 'url("' ~/.hyperframes-studio/ finds it).
Describe the bug
In the HyperFrames desktop app, a project whose
index.htmlhas a CSSurl("assets/image.png")inside astyleattribute makes the main process spin at 100% CPU from launch, climb past 1 GB and crash withEXC_BREAKPOINT(V8 out of memory) about 90 seconds later. Home builds that project's preview at launch, so the app crash-loops on every relaunch until the file is edited by hand.Cause, confirmed with the inspector attached to the main process: by the time
recordPreviewReferences(packages/studio-server/src/helpers/previewReads.ts) scans the preview HTML, the image has been inlined, so the unquotedurl(branch ofREFERENCEcaptures"data:image/png;base64,iVBOR...". The leading"defeats the scheme check (/^(?:[a-z][a-z0-9+.-]*:|[/#])/i), so the base64 is treated as a relative file path.linksOnTheWaythen walks it one "folder" per/(base64 is full of them) andrecordPreviewReadadds every ancestor of that path to the reads set. The cost is quadratic in the image size: for an 877 KB PNG the "path" was 1,169,082 characters with 20,198 slashes.Where the
"comes from: the desktop app's bundled takeover-explainer format (guides/formats/takeover-explainer/skills/takeover-explainer/scripts/build.py) writesbackground-image:url("...")in five places, so any takeover-style film with a large enough image hits this. That script is not in this repo, so I'm flagging it here.Minimal reproduction
A verbatim copy of
linksOnTheWayandrecordPreviewReferencesfrompreviewReads.ts, run on a one-element page in its post-inlining form, against the same page quoted with'. Node built-ins only.Steps to reproduce
node repro.mjsstyle="background-image:url("assets/big.png")"on an element with a PNG of about that size inassets/, then quit and reopen the app. (I hit this through a takeover-explainer build and have not re-run a hand-made page in the app.)Expected behavior
A data URI is skipped whatever quoting the attribute uses, a captured reference far too long to be a path is ignored, the preview builds and the app opens.
Actual behavior
Script output (cost grows with the square of the image size; the
'control costs nothing):In the desktop app: main process at 100% CPU from launch, around 1 GB RSS, then
EXC_BREAKPOINTonCrBrowserMainabout 90 seconds in (Datadog logs "Application crashed"). Four consecutive launches died the same way. The inspector stack was identical at every pause:At the pause:
path.length1,169,082,rest.lengthstill 14,007, preview HTML 15.6 MB (the same image inlined 12 times, deduplicated to one reference).Environment
HyperFrames desktop 0.1.0 (build b268, prod channel) Electron 44.2.0 @hyperframes/studio-server 0.8.133, @hyperframes/core 0.8.133 macOS 27.0.1 (26A434), Apple M1 Pro (arm64) (desktop app, so no `npx hyperframes doctor` output)previewReads.tsis unchanged onmainas of 1d9b367.Additional context
Possible fixes, any one of which would have prevented this:
",',&) in the captured value before the scheme check. The browser does this for attributes, sourl("X")really meansurl("X").linksOnTheWay.build.py, writeurl('...')instead ofurl("...").Workaround for anyone hitting this: with the app quit, replace
url("X")withurl('X')in the project'sindex.html(grep -rl --include='*.html' 'url("' ~/.hyperframes-studio/finds it).