Skip to content

Studio preview runs out of memory and crash-loops when a style url() uses " around an inlined image #5097

Description

@keznz1991

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 [["&quot; (bug)", `url(&quot;data:image/png;base64,${b64}&quot;)`], ["' (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

  1. node repro.mjs
  2. 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(&quot;assets/big.png&quot;)" 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  &quot; (bug)  refs=1 time=17ms  reads=742  storedChars=7.8M
16KB  ' (control)   refs=0 time=0ms   reads=0    storedChars=0.0M
32KB  &quot; (bug)  refs=1 time=46ms  reads=1316 storedChars=28.3M
64KB  &quot; (bug)  refs=1 time=191ms reads=2784 storedChars=121.4M
128KB &quot; (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 &quot;, &#39;, &amp;) in the captured value before the scheme check. The browser does this for attributes, so url(&quot;X&quot;) 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(&quot;...&quot;).
  • 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(&quot;X&quot;) with url('X') in the project's index.html (grep -rl --include='*.html' 'url(&quot;' ~/.hyperframes-studio/ finds it).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions