fix(preview): hung-frame watchdog + require approval for guessed action calls
A runaway preview script (sync infinite loop, or a re-render loop outpacing the bootstrap's own coalescing) had nothing detecting it - the frame just spun. The bootstrap now heartbeats every second, and the parent tears the iframe down if it goes _WATCHDOG_MS silent, whatever the cause. _coerce_tool_calls recovers a tool call guessed from `content` for models with no native tool_calls field. That guess is weaker evidence than the API's own structured field - a model can land on JSON shaped like a call while only meaning to describe one - so an action tool recovered this way now always requires approval, even under the "allow" policy that lets a native tool_calls field run unattended.
This commit is contained in:
@@ -243,6 +243,9 @@ const _PREVIEW_BOOTSTRAP = `<script>
|
||||
observers[observers.length - 1].observe(document.body);
|
||||
}
|
||||
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
|
||||
// Heartbeat: the parent's watchdog needs a message even when nothing is
|
||||
// changing, or an idle-but-alive frame reads the same as a hung one.
|
||||
setInterval(post, 1000);
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
@@ -304,6 +307,14 @@ const _MIN_PREVIEW_H = 160;
|
||||
const _MAX_PREVIEW_H = 720;
|
||||
const _MAX_H_STEPS = 60;
|
||||
|
||||
// A frame that never posts again — a synchronous `while(true)` in the user's
|
||||
// own script, or a runaway re-render loop the bootstrap's own coalescing
|
||||
// can't outpace — has nothing else to signal it. Silence past this long since
|
||||
// mount (or since the last message) is treated as hung and the frame is torn
|
||||
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
|
||||
// gets close to this.
|
||||
const _WATCHDOG_MS = 6000;
|
||||
|
||||
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
||||
// via a sandboxed iframe whose document is an encoded data: URL.
|
||||
//
|
||||
@@ -407,9 +418,11 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
const [doc, setDoc] = useState("");
|
||||
const [buildError, setBuildError] = useState("");
|
||||
const [height, setHeight] = useState(240);
|
||||
const [hung, setHung] = useState(false);
|
||||
const frameRef = useRef(null);
|
||||
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
||||
const stepsRef = useRef(0);
|
||||
const lastMsgRef = useRef(0); // set for real by the watchdog effect below
|
||||
|
||||
// Receive the bootstrap's reports. The frame is on an opaque origin, so
|
||||
// e.origin is the string "null" and proves nothing - identify the sender by
|
||||
@@ -419,6 +432,7 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (!data || data.__nexusPreview !== 1) return;
|
||||
lastMsgRef.current = Date.now();
|
||||
|
||||
if (typeof data.err === "string" && data.err) setError(data.err);
|
||||
|
||||
@@ -435,6 +449,23 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
// Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
|
||||
// synchronous infinite loop in the model's own script, which blocks even
|
||||
// the bootstrap's heartbeat from ever running — gets torn down rather than
|
||||
// left spinning. Checked on an interval rather than a single timeout so a
|
||||
// message arriving late (slow compile, heavy first paint) keeps resetting
|
||||
// the clock instead of tripping early.
|
||||
useEffect(() => {
|
||||
lastMsgRef.current = Date.now();
|
||||
const id = setInterval(() => {
|
||||
if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
|
||||
setHung(true);
|
||||
clearInterval(id);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [lang, value]);
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setDoc("");
|
||||
@@ -448,9 +479,13 @@ function PreviewFrame({ lang, value, expanded }) {
|
||||
}, [lang, value]);
|
||||
|
||||
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
||||
// the message stands in for the frame rather than sitting under it.
|
||||
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||
const shown = buildError || error;
|
||||
// the message stands in for the frame rather than sitting under it. A hung
|
||||
// frame tears down the same way: dropping frameUrl unmounts the iframe,
|
||||
// which is what actually stops a runaway script from holding the tab.
|
||||
const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
||||
const shown = hung
|
||||
? "Preview stopped responding (likely an infinite loop) and was stopped."
|
||||
: buildError || error;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user