From d51588d8a0b717176d0f8ac6426f7c9076cb1177 Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Wed, 12 Feb 2025 15:44:27 -0500 Subject: [PATCH 1/3] Revert "Revert "Allow scripts to be cancelable"" --- .../DataGenerationArea/DataPyPanel.tsx | 3 +- .../DataGenerationArea/DataRPanel.tsx | 4 +- .../AnalysisArea/AnalysisPyPanel.tsx | 3 +- .../AnalysisArea/AnalysisRPanel.tsx | 3 +- .../SamplerOutputArea/DrawsTablePanel.tsx | 8 +- .../components/FileEditor/ScriptEditor.tsx | 41 ++++++++-- .../core/Scripting/pyodide/pyodideWorker.ts | 76 ++++++++++++------- .../Scripting/pyodide/pyodideWorkerTypes.ts | 1 + .../Scripting/pyodide/usePyodideWorker.ts | 29 ++++++- gui/src/app/core/Scripting/webR/useWebR.ts | 23 ++++-- gui/vercel.json | 17 +++++ gui/vite.config.ts | 4 + 12 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 gui/vercel.json diff --git a/gui/src/app/areas/ControlArea/DataGenerationArea/DataPyPanel.tsx b/gui/src/app/areas/ControlArea/DataGenerationArea/DataPyPanel.tsx index 38932b21..4b17e0d8 100644 --- a/gui/src/app/areas/ControlArea/DataGenerationArea/DataPyPanel.tsx +++ b/gui/src/app/areas/ControlArea/DataGenerationArea/DataPyPanel.tsx @@ -31,7 +31,7 @@ const DataPyPanel: FunctionComponent = () => { [consoleRef, onData, onStatus], ); - const { run } = usePyodideWorker(callbacks); + const { run, cancel } = usePyodideWorker(callbacks); const handleRun = useCallback( (code: string) => { @@ -62,6 +62,7 @@ const DataPyPanel: FunctionComponent = () => { language="python" status={status} onRun={handleRun} + onCancel={cancel} runnable={true} notRunnableReason="" onHelp={handleHelp} diff --git a/gui/src/app/areas/ControlArea/DataGenerationArea/DataRPanel.tsx b/gui/src/app/areas/ControlArea/DataGenerationArea/DataRPanel.tsx index 784d9fff..3f5ec994 100644 --- a/gui/src/app/areas/ControlArea/DataGenerationArea/DataRPanel.tsx +++ b/gui/src/app/areas/ControlArea/DataGenerationArea/DataRPanel.tsx @@ -18,7 +18,8 @@ const handleHelp = () => const DataRPanel: FunctionComponent = () => { const { consoleRef, status, onStatus, onData } = useDataGenState("r"); - const { run } = useWebR({ consoleRef, onStatus, onData }); + const { run, cancel } = useWebR({ consoleRef, onStatus, onData }); + const handleRun = useCallback( async (code: string) => { clearOutputDivs(consoleRef); @@ -40,6 +41,7 @@ const DataRPanel: FunctionComponent = () => { language="r" status={status} onRun={handleRun} + onCancel={cancel} runnable={true} notRunnableReason="" onHelp={handleHelp} diff --git a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisPyPanel.tsx b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisPyPanel.tsx index 1d03e980..0b28d2b5 100644 --- a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisPyPanel.tsx +++ b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisPyPanel.tsx @@ -36,7 +36,7 @@ const AnalysisPyPanel: FunctionComponent = ({ latestRun }) => { [consoleRef, imagesRef, onStatus], ); - const { run } = usePyodideWorker(callbacks); + const { run, cancel } = usePyodideWorker(callbacks); const handleRun = useCallback( (code: string) => { @@ -69,6 +69,7 @@ const AnalysisPyPanel: FunctionComponent = ({ latestRun }) => { language="python" status={status} onRun={handleRun} + onCancel={cancel} runnable={runnable} notRunnableReason={notRunnableReason} imagesRef={imagesRef} diff --git a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisRPanel.tsx b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisRPanel.tsx index a563c073..e3835a12 100644 --- a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisRPanel.tsx +++ b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/AnalysisArea/AnalysisRPanel.tsx @@ -24,7 +24,7 @@ const AnalysisRPanel: FunctionComponent = ({ latestRun }) => { files, } = useAnalysisState(latestRun); - const { run } = useWebR({ consoleRef, imagesRef, onStatus }); + const { run, cancel } = useWebR({ consoleRef, imagesRef, onStatus }); const handleRun = useCallback( async (userCode: string) => { @@ -48,6 +48,7 @@ const AnalysisRPanel: FunctionComponent = ({ latestRun }) => { language="r" status={status} onRun={handleRun} + onCancel={cancel} runnable={runnable} notRunnableReason={notRunnableReason} imagesRef={imagesRef} diff --git a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/SamplerOutputArea/DrawsTablePanel.tsx b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/SamplerOutputArea/DrawsTablePanel.tsx index 30e72e50..a68b877b 100644 --- a/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/SamplerOutputArea/DrawsTablePanel.tsx +++ b/gui/src/app/areas/ControlArea/SamplingArea/ResultsArea/SamplerOutputArea/DrawsTablePanel.tsx @@ -95,7 +95,9 @@ const DrawsTablePanel: FunctionComponent = ({ Chain Draw {paramNames.map((name, i) => ( - {name} + + {name} + ))} @@ -105,7 +107,9 @@ const DrawsTablePanel: FunctionComponent = ({ {drawChainIds[i]} {drawNumbers[i]} {formattedDraws.map((draw, j) => ( - {draw[i]} + + {draw[i]} + ))} ))} diff --git a/gui/src/app/components/FileEditor/ScriptEditor.tsx b/gui/src/app/components/FileEditor/ScriptEditor.tsx index 5cd8497e..5fcb9dd0 100644 --- a/gui/src/app/components/FileEditor/ScriptEditor.tsx +++ b/gui/src/app/components/FileEditor/ScriptEditor.tsx @@ -1,6 +1,6 @@ import { FunctionComponent, RefObject, useCallback, use, useMemo } from "react"; -import { Help, PlayArrow } from "@mui/icons-material"; +import { Close, Help, PlayArrow } from "@mui/icons-material"; import Box from "@mui/material/Box"; import { Split } from "@geoffcox/react-splitter"; import { useMonaco } from "@monaco-editor/react"; @@ -22,6 +22,7 @@ export type ScriptEditorProps = { filename: FileNames; dataKey: ProjectKnownFiles; onRun: (code: string) => void; + onCancel?: () => void; runnable: boolean; notRunnableReason?: string; onHelp?: () => void; @@ -35,6 +36,7 @@ const ScriptEditor: FunctionComponent = ({ filename, dataKey, onRun, + onCancel, runnable, notRunnableReason, onHelp, @@ -74,11 +76,12 @@ const ScriptEditor: FunctionComponent = ({ const monacoInstance = useMonaco(); - const runCtrlEnter: editor.IActionDescriptor[] = useMemo(() => { + const scriptShortcuts: editor.IActionDescriptor[] = useMemo(() => { if (!monacoInstance) { return []; } - return [ + const actions = [ + // Ctrl-Enter to run { id: "run-script", label: "Run Script", @@ -92,7 +95,19 @@ const ScriptEditor: FunctionComponent = ({ }, }, ]; - }, [monacoInstance, runCode, runnable, unsavedChanges]); + if (onCancel) { + // Ctrl-C to cancel + actions.push({ + id: "cancel-script", + label: "Cancel Script", + keybindings: [ + monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyC, + ], + run: onCancel, + }); + } + return actions; + }, [monacoInstance, onCancel, runCode, runnable, unsavedChanges]); const toolbarItems: ToolbarItem[] = useMemo(() => { return makeToolbar({ @@ -101,11 +116,13 @@ const ScriptEditor: FunctionComponent = ({ runnable: runnable && !unsavedChanges, notRunnableReason, onRun: runCode, + onCancel, onHelp, }); }, [ language, notRunnableReason, + onCancel, onHelp, runCode, runnable, @@ -124,7 +141,7 @@ const ScriptEditor: FunctionComponent = ({ onSaveText={onSaveText} toolbarItems={toolbarItems} contentOnEmpty={contentOnEmpty} - actions={runCtrlEnter} + actions={scriptShortcuts} /> @@ -138,8 +155,9 @@ const makeToolbar = (o: { notRunnableReason?: string; onRun: () => void; onHelp?: () => void; + onCancel?: () => void; }): ToolbarItem[] => { - const { status, onRun, runnable, onHelp, name } = o; + const { status, onRun, runnable, onHelp, name, onCancel } = o; const ret: ToolbarItem[] = []; if (onHelp !== undefined) { ret.push({ @@ -166,6 +184,17 @@ const makeToolbar = (o: { }); } + if (onCancel && status === "running") { + ret.push({ + type: "button", + tooltip: "Cancel", + label: "Cancel", + icon: , + onClick: onCancel, + color: "error", + }); + } + let label: string; let color: ColorOptions; if (status === "loading") { diff --git a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts index 2c706331..4326d195 100644 --- a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts +++ b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts @@ -3,33 +3,29 @@ import { isMonacoWorkerNoise } from "@SpUtil/isMonacoWorkerNoise"; import { InterpreterStatus } from "@SpCore/Scripting/InterpreterTypes"; import { MessageFromPyodideWorker, + MessageToPyodideWorker, PyodideRunSettings, } from "./pyodideWorkerTypes"; import spDrawsScript from "./sp_load_draws.py?raw"; import spMPLScript from "./sp_patch_matplotlib.py?raw"; -let pyodide: PyodideInterface | null = null; const loadPyodideInstance = async () => { - if (pyodide === null) { - pyodide = await loadPyodide({ - indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.2/full", - stdout: (x: string) => { - sendStdout(x); - }, - stderr: (x: string) => { - sendStderr(x); - }, - packages: ["numpy", "micropip", "pandas"], - }); - setStatus("installing"); - - pyodide.FS.writeFile("sp_load_draws.py", spDrawsScript); - pyodide.FS.writeFile("sp_patch_matplotlib.py", spMPLScript); - - return pyodide; - } else { - return pyodide; - } + const pyodide = await loadPyodide({ + indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.2/full", + stdout: (x: string) => { + sendStdout(x); + }, + stderr: (x: string) => { + sendStderr(x); + }, + packages: ["numpy", "micropip", "pandas"], + }); + console.log("pyodide loaded"); + + pyodide.FS.writeFile("sp_load_draws.py", spDrawsScript); + pyodide.FS.writeFile("sp_patch_matplotlib.py", spMPLScript); + + return pyodide; }; const sendMessageToMain = (message: MessageFromPyodideWorker) => { @@ -56,25 +52,38 @@ const addImage = (image: any) => { sendMessageToMain({ type: "addImage", image }); }; -console.log("pyodide worker loaded"); - -self.onmessage = async (e) => { +self.onmessage = async (e: MessageEvent) => { if (isMonacoWorkerNoise(e.data)) { return; } const message = e.data; - await run(message.code, message.spData, message.spRunSettings, message.files); + await run( + message.code, + message.spData, + message.spRunSettings, + message.files, + message.interruptBuffer, + ); }; +console.log("pyodide worker initialized"); + +console.log("opportunistically loading pyodide"); +const pyodidePromise: Promise = loadPyodideInstance(); const run = async ( code: string, spData: Record | undefined, spPySettings: PyodideRunSettings, files: Record | undefined, + interruptBuffer: Uint8Array | undefined, ) => { setStatus("loading"); try { - const pyodide = await loadPyodideInstance(); + const pyodide = await pyodidePromise; + if (interruptBuffer) { + pyodide.setInterruptBuffer(interruptBuffer); + } + setStatus("installing"); const [scriptPreamble, scriptPostamble] = getScriptParts(spPySettings); @@ -95,6 +104,7 @@ const run = async ( let succeeded = false; try { const packageFutures = []; + let patch_http = false; const micropip = pyodide.pyimport("micropip"); if (spPySettings.showsPlots) { @@ -104,10 +114,20 @@ const run = async ( packageFutures.push(micropip.install("arviz")); } } + if (script.includes("requests")) { + patch_http = true; + packageFutures.push( + micropip.install(["requests", "lzma", "pyodide-http"]), + ); + } packageFutures.push(micropip.install("stanio")); packageFutures.push(pyodide.loadPackagesFromImports(script)); - for (const f of packageFutures) { - await f; + await Promise.all(packageFutures); + if (patch_http) { + await pyodide.runPythonAsync(` + from pyodide_http import patch_all + patch_all() + `); } if (files) { diff --git a/gui/src/app/core/Scripting/pyodide/pyodideWorkerTypes.ts b/gui/src/app/core/Scripting/pyodide/pyodideWorkerTypes.ts index d237b879..eb60add8 100644 --- a/gui/src/app/core/Scripting/pyodide/pyodideWorkerTypes.ts +++ b/gui/src/app/core/Scripting/pyodide/pyodideWorkerTypes.ts @@ -17,6 +17,7 @@ export type MessageToPyodideWorker = { spData: Record | undefined; spRunSettings: PyodideRunSettings; files: Record | undefined; + interruptBuffer: Uint8Array | undefined; }; export const isMessageToPyodideWorker = ( diff --git a/gui/src/app/core/Scripting/pyodide/usePyodideWorker.ts b/gui/src/app/core/Scripting/pyodide/usePyodideWorker.ts index 322d8240..45f61fb7 100644 --- a/gui/src/app/core/Scripting/pyodide/usePyodideWorker.ts +++ b/gui/src/app/core/Scripting/pyodide/usePyodideWorker.ts @@ -27,6 +27,7 @@ type RunPyProps = { class PyodideWorkerInterface { #worker: Worker | undefined; + #interruptBuffer: Uint8Array | undefined; private constructor(private callbacks: PyodideWorkerCallbacks) { // do not call this directly, use create() instead @@ -39,6 +40,15 @@ class PyodideWorkerInterface { type: "module", }); + if (window.crossOriginIsolated) { + this.#interruptBuffer = new Uint8Array(new SharedArrayBuffer(1)); + } else { + console.warn( + "SharedArrayBuffer is not available, interrupting the Pyodide worker will not work", + ); + this.#interruptBuffer = undefined; + } + this.#worker.onmessage = (e: MessageEvent) => { const msg = e.data; if (!isMessageFromPyodideWorker(msg)) { @@ -85,8 +95,13 @@ class PyodideWorkerInterface { spData, spRunSettings, files, + interruptBuffer: this.#interruptBuffer, }; if (this.#worker) { + if (this.#interruptBuffer) { + // clear in case previous run was interrupted + this.#interruptBuffer[0] = 0; + } this.#worker.postMessage(msg); } else { throw new Error("pyodide worker is not defined"); @@ -94,8 +109,18 @@ class PyodideWorkerInterface { } cancel() { - this.#worker?.terminate(); - this.#initialize(); + if (this.#interruptBuffer && this.#interruptBuffer[0] === 0) { + // SIGINT + this.#interruptBuffer[0] = 2; + } else { + // if the interrupt buffer doesn't exist, or has already been set + // (and the user is requesting cancellation still) + // we can just terminate the worker + this.#worker?.terminate(); + this.callbacks.onStatus("failed"); + this.callbacks.onStderr("Python execution cancelled by user"); + this.#initialize(); + } } } diff --git a/gui/src/app/core/Scripting/webR/useWebR.ts b/gui/src/app/core/Scripting/webR/useWebR.ts index 7e3a98ae..28800dd5 100644 --- a/gui/src/app/core/Scripting/webR/useWebR.ts +++ b/gui/src/app/core/Scripting/webR/useWebR.ts @@ -1,4 +1,4 @@ -import { RefObject, useCallback, useEffect, useState } from "react"; +import { RefObject, useCallback, useEffect, useMemo, useState } from "react"; import { WebR } from "webr"; import { InterpreterStatus } from "@SpCore/Scripting/InterpreterTypes"; import { writeConsoleOutToDiv } from "@SpCore/Scripting/OutputDivUtils"; @@ -121,11 +121,16 @@ const useWebR = ({ imagesRef, consoleRef, onStatus, onData }: useWebRProps) => { [consoleRef, loadWebRInstance, onData, onStatus], ); - const cancel = useCallback(() => { - if (webR) { - // NOTE: only works if COORS is set to allow shared worker usage - webR.interrupt(); + const cancel = useMemo(() => { + // NOTE: only works if COORS is set to allow shared worker usage + if (window.crossOriginIsolated) { + return () => { + if (webR) { + webR.interrupt(); + } + }; } + return undefined; }, [webR]); return { run, cancel }; @@ -185,6 +190,14 @@ const outputLoop = async ( canvas.getContext("2d")?.drawImage(output.data.image, 0, 0); } break; + case "prompt": + // in our case these are only generated by calls to interrupt() + writeConsoleOutToDiv( + consoleRef, + "R execution cancelled by user", + "stderr", + ); + break; default: console.log("unexpected webR message: ", output); } diff --git a/gui/vercel.json b/gui/vercel.json new file mode 100644 index 00000000..293ef17f --- /dev/null +++ b/gui/vercel.json @@ -0,0 +1,17 @@ +{ + "headers": [ + { + "source": "/", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + } + ] +} diff --git a/gui/vite.config.ts b/gui/vite.config.ts index d536b91c..0a00a05d 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ plugins: [react(), tsconfigPaths()], server: { host: "127.0.0.1", + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, }, worker: { format: "es", From 38fb56eefddeda5c829a6a7b429390ade77eb264 Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Thu, 13 Feb 2025 17:26:56 +0000 Subject: [PATCH 2/3] Add header to assets folder --- gui/vercel.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/gui/vercel.json b/gui/vercel.json index 293ef17f..bb8c2a9f 100644 --- a/gui/vercel.json +++ b/gui/vercel.json @@ -1,7 +1,20 @@ { "headers": [ { - "source": "/", + "source": "**.(js|html)", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + }, + { + "source": "/assets/(.*)", "headers": [ { "key": "Cross-Origin-Embedder-Policy", From 481d89e6a587ccd9b120144ecc884f3c9b25f3be Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Thu, 13 Feb 2025 17:43:02 +0000 Subject: [PATCH 3/3] Include staging site in allowed origins --- backend/stan-wasm-server/src/app/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/stan-wasm-server/src/app/main.py b/backend/stan-wasm-server/src/app/main.py index 5e10633c..3c8e4d5f 100644 --- a/backend/stan-wasm-server/src/app/main.py +++ b/backend/stan-wasm-server/src/app/main.py @@ -50,6 +50,7 @@ def setup_logger() -> None: allow_origins=[ "https://stan-playground.flatironinstitute.org", "https://stan-playground.vercel.app", + "https://stan-playground-staging.vercel.app", "http://127.0.0.1:3000", # yarn dev "http://127.0.0.1:4173", # yarn preview ], @@ -106,7 +107,13 @@ async def download_file( file_path = model_dir / filename if not file_path.is_file(): raise FileNotFoundError(f"File not found: {file_path}") - return FileResponse(file_path) + return FileResponse( + file_path, + headers={ + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Opener-Policy": "same-origin", + }, + ) @app.post("/compile")