← All Pull Requests

Render view is read-only; slotSize only on own change; real render errors (fixes #57) #1

Merged opened by Ray 2026-09-22

Fixes #57: Generate and compose fail whenever the studio is open in a tab.

Cause. The render view (/?render=1) ran the full interactive surface. It measured the showcase slot at its headless viewport and POSTed slotSize. The open studio, at another width, POSTed its own value back. Each POST was broadcast to the other page, so the two ping-ponged and render-hero.mjs's networkidle2 wait never settled. The gate then showed only "Node.js v18.19.1", because server.js reported the last stderr line.

Changes

  • public/js/app.js
    • RENDER_VIEW: in ?render=1, post() resolves locally without a request, and the page opens no /events stream and no 1 s poll. It renders the state it loaded. Console relay is unchanged.
    • slotSize is reported only when this surface's own measurement changes (window._slotSent), not whenever it differs from state.slotSize. That also ends the same loop between two ordinary tabs at different widths.
  • server.js: renderError() returns the last stderr line naming an error, skipping the Node.js vN footer. Used by compose and by every Generate renderer leg.
  • test/render-view.mjs (npm run test:render): starts a real server on an OS-picked port, attaches a second surface that measures differently (modeled as the old page logic over /events), then loads /?render=1 with networkidle2.

Verified

  • Fixed: 8 passed, 0 failed.
  • The same test on 0.21.1: 4 failed. The render view times out after 10 s, POSTs /state, and draws 10,705 corrective slotSize POSTs in 10 s, and renderError is missing.
  • Live: with this app.js on the running 0.21.1 studio, compose went from the 30 s timeout to 3 s, with fresh lint and no slotSize POSTs since.

No version bump in this PR; that's yours at publish.

Diff Skip to comments (1)

--- a/public/js/app.js+++ b/public/js/app.js@@ -1,1658 +1,1672 @@⋯ 2 unchanged lines ⋯    lands with M4, when the panel code entangled with render() is deleted. */  if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';+// The render view (?render=1) is what Generate and compose screenshot. It is a READ-ONLY consumer of+// the state it loaded: it must never POST state, and never subscribe to changes mid-screenshot.+// Before this it ran the full surface: its slotSize measurement (headless viewport, 1109x1269)+// disagreed with the human's open studio (1109x1227), each POST was broadcast to the other, and the+// two ping-ponged hundreds of times a second, so the renderer's networkidle2 wait never settled and+// every Generate died at the 30 s navigation timeout (2026-09-22, adom/adom-bluegreen).+const RENDER_VIEW = new URLSearchParams(location.search).has('render');  /* console relay: the AI reads GET /console instead of DevTools */ (function () {⋯ 30 unchanged lines ⋯ // owning ai-thread (#39): the page IS that instance's surface, and the human // acting through it acts within that thread's session. const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});-const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });+const post = (path, body) => RENDER_VIEW+  ? Promise.resolve(new Response('{}', { headers: { 'Content-Type': 'application/json' } }))+  : fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) }); const send = (patch) => post('state', patch).catch(() => {});  function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }⋯ 328 unchanged lines ⋯     el.dataset.size = w + 'x' + h;     el.textContent = 'shoot the showcase image at ' + w + 'x' + h +       ' (aspect ' + (wb.width / wb.height).toFixed(2) + ') for this placement; click to copy';-    const cur = state.slotSize || {};-    if (cur.w !== w || cur.h !== h) send({ slotSize: { w: w, h: h } });+    // Report only when THIS surface's own measurement changes. Comparing against the server's value+    // made two surfaces at different widths (two tabs, or a tab and the render view) correct each+    // other forever: each POST is broadcast, the other sees a mismatch and posts back.+    const key = w + 'x' + h;+    if (window._slotSent !== key) { window._slotSent = key; send({ slotSize: { w: w, h: h } }); }   });    // showcase content: real screenshot in the window, or full-hero import⋯ 1246 unchanged lines ⋯     const el = document.querySelector(location.hash);     if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);   }+  if (RENDER_VIEW) return;   // render view: the state it loaded is the state it renders   const es = new EventSource('events');   es.addEventListener('state', e => { state = JSON.parse(e.data); syncPagesIfStale(state); render(); });   es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });⋯ 22 unchanged lines ⋯   window._abDismissed = (state.approved || {}).ts;   $('approvedbar').style.display = 'none'; });+--- a/server.js+++ b/server.js@@ -1,1700 +1,1709 @@⋯ 2 unchanged lines ⋯ const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+// The useful line of a renderer's stderr. Node ends an uncaught exception with a blank line and+// "Node.js v18.19.1", so taking the LAST line reported the Node version as the error (2026-09-22:+// the human saw "Generate failed: local: Node.js v18.19.1"). Prefer the last line naming an error.+function renderError(buf) {+  const lines = String(buf || '').split('\n').map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l));+  const named = lines.filter((l) => /(^|\b)(\w*Error|error)\b[:\s]/.test(l) && !/^at /.test(l));+  return (named.pop() || lines.pop() || '').slice(0, 300);+} const crypto = require('crypto');  // Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked⋯ 1107 unchanged lines ⋯       clearTimeout(t);       if (code !== 0 || !fs.existsSync(tmp)) {         res.writeHead(500, { 'Content-Type': 'application/json' });-        return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' }));+        return res.end(JSON.stringify({ ok: false, error: renderError(eb) || 'compose render failed' }));       }       // hero-studio#51: the headless render evaluated the SAME linter the       // surface runs; store its findings as fresh-by-construction, so the⋯ 404 unchanged lines ⋯       child.on('close', (code) => {         clearTimeout(timer);         if (code === 0 && fs.existsSync(outFile)) return cb(null);-        cb(errBuf.trim().split('\n').pop() || a.label + ' render exited ' + code);+        cb(renderError(errBuf) || a.label + ' render exited ' + code);       });     };     const tryNext = () => {⋯ 164 unchanged lines ⋯   writeRegistry();   console.log('hero-studio on ' + PORT + ' (thread "' + AI_THREAD + '", ttl ' + (KEEP_ALIVE ? 'off' : TTL_HOURS + 'h') + ')'); });+--- a/test/render-view.mjs+++ b/test/render-view.mjs@@ -0,0 +1,99 @@+#!/usr/bin/env node+// Render-view tests: node test/render-view.mjs+//+// Generate and compose screenshot /?render=1 with puppeteer, waiting for networkidle2. On+// 2026-09-22 every Generate died at the 30 s navigation timeout: the render view ran the full+// interactive surface, measured the showcase slot at its own headless viewport (1109x1269), and+// POSTed it; the human's open studio (1109x1227) saw a mismatch and POSTed back; each POST was+// broadcast; the two ping-ponged hundreds of times a second (poseRev reached 43,988) and the+// network never went idle. The error the human saw was "Node.js v18.19.1", the last stderr line.+//+// This starts a real server on a spare port, opens an interactive surface at one width, then+// loads the render view at another, and asserts:+//   - the render view makes no state-changing POST,+//   - it reaches networkidle2 while the other surface is open,+//   - no slotSize ping-pong: the server sees at most one slotSize POST per surface,+//   - renderError() extracts the real error line, not the Node.js version footer.+import { spawn } from 'child_process';+import net from 'net';+import fs from 'fs';+import path from 'path';+import { fileURLToPath } from 'url';+import { loadPuppeteer, findChrome } from '../doctor.mjs';++const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');+// A free port picked by the OS: a fixed one collides with whatever else runs on the box.+const PORT = Number(process.env.RV_PORT) || await new Promise((res) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => res(p)); }); });+let pass = 0; const fails = [];+const ok = (name, cond, detail) => { if (cond) { pass++; console.log('  ok   ' + name); } else { fails.push(name); console.log('  FAIL ' + name + (detail ? ': ' + detail : '')); } };++// renderError, pure+const src = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8');+const fn = src.match(/function renderError[\s\S]*?\n}\n/);+ok('server.js defines renderError', !!fn);+if (fn) {+  const renderError = new Function(fn[0] + '; return renderError;')();+  const eb = 'file:///x/Deferred.js:57\n  this.#timeoutError = new TimeoutError(opts.message);\n        ^\n\nTimeoutError: Navigation timeout of 30000 ms exceeded\n    at new Deferred (file:///x)\n\nNode.js v18.19.1\n';+  ok('renderError names the real error, not the Node version', renderError(eb) === 'TimeoutError: Navigation timeout of 30000 ms exceeded', renderError(eb));+  ok('renderError falls back to the last plain line', renderError('chrome exited\n\nNode.js v22.1.0\n') === 'chrome exited', renderError('chrome exited\n\nNode.js v22.1.0\n'));+}++const server = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(PORT), '--ai-thread', 'render-view test', '--ttl', '1'],+  { env: { ...process.env, HERO_STUDIO_PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'] });+const base = 'http://localhost:' + PORT;+let up = false;+for (let i = 0; i < 50 && !up; i++) { try { const r = await fetch(base + '/state'); up = r.ok && 'slotSize' in (await r.json()); } catch {} if (!up) await new Promise(r => setTimeout(r, 200)); }+ok('a hero-studio server answers on :' + PORT, up);+if (!up) { server.kill(); console.log('\n' + pass + ' passed, ' + (fails.length) + ' failed'); process.exit(1); }++const puppeteer = await loadPuppeteer();+const browser = await puppeteer.launch({ executablePath: findChrome(), headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });+try {+  // Another surface that measures the showcase slot differently (the human's studio tab, at its+  // own width), modeled as the old page logic: follow the event stream and POST its own slotSize+  // whenever the server's differs. Any render view that posts state will ping-pong with it.+  const OTHER = { w: 1109, h: 1227 };+  let otherPosts = 0; const ctl = new AbortController();+  (async () => {+    try {+      const r = await fetch(base + '/events', { signal: ctl.signal });+      const dec = new TextDecoder(); let buf = '';+      for await (const chunk of r.body) {+        buf += dec.decode(chunk, { stream: true });+        let k;+        while ((k = buf.indexOf('\n\n')) >= 0) {+          const ev = buf.slice(0, k); buf = buf.slice(k + 2);+          if (!/^event: state/m.test(ev)) continue;+          const st = JSON.parse((ev.match(/^data: (.*)$/m) || [])[1] || '{}');+          const cur = st.slotSize || {};+          if (cur.w !== OTHER.w || cur.h !== OTHER.h) {+            otherPosts++;+            fetch(base + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'render-view test', slotSize: OTHER }) }).catch(() => {});+          }+        }+      }+    } catch {}+  })();+  await new Promise(r => setTimeout(r, 800));++  // the render view at the renderer's own viewport+  const view = await browser.newPage();+  await view.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 });+  const posts = [];+  view.on('request', (r) => { if (r.method() === 'POST' && !/\/console$/.test(r.url())) posts.push(r.url()); });+  const t0 = Date.now(); let idle = true;+  try { await view.goto(base + '/?render=1', { waitUntil: 'networkidle2', timeout: 10000 }); } catch (e) { idle = false; }+  ok('render view reaches networkidle2 with another surface open', idle, 'timed out after ' + (Date.now() - t0) + ' ms');+  await new Promise(r => setTimeout(r, 2000));+  ok('render view makes no state-changing POST', posts.length === 0, posts.slice(0, 3).join(', '));+  ok('render view still renders the hero', !!(await view.$('#hero')));++  ctl.abort();+  ok('no slotSize ping-pong with a surface that measures differently', otherPosts <= 2, otherPosts + ' corrective posts from the other surface');+} finally {+  await browser.close();+  server.kill();+}+console.log('\n' + pass + ' passed, ' + fails.length + ' failed');+process.exit(fails.length ? 1 : 0);+--- a/package.json+++ b/package.json@@ -1,99 +1,101 @@⋯ 63 unchanged lines ⋯   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh",-    "test:brand": "node test/brand.mjs"+    "test:brand": "node test/brand.mjs",+    "test:render": "node test/render-view.mjs"   },   "needs_sudo": false,   "hero": {⋯ 26 unchanged lines ⋯     "screenshots/hero.png"   ] }+

Comments

Colby Knox · 28d ago

Merged as is and shipped in 0.21.2. Thanks for the deterministic repro and the regression test; it is wired up as npm run test:render and documented in the SKILL.md.

Log in to comment.