From 2eefde6f493714319b49d4bb20f02635bcc266a3 Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Mon, 3 Feb 2025 17:52:06 +0000 Subject: [PATCH 1/5] Allow scripts to be cancelable --- .../DataGenerationArea/DataPyPanel.tsx | 3 +- .../DataGenerationArea/DataRPanel.tsx | 4 +- .../AnalysisArea/AnalysisPyPanel.tsx | 3 +- .../AnalysisArea/AnalysisRPanel.tsx | 3 +- .../components/FileEditor/ScriptEditor.tsx | 41 ++++++++++++++++--- .../core/Scripting/pyodide/pyodideWorker.ts | 15 ++++++- .../Scripting/pyodide/pyodideWorkerTypes.ts | 1 + .../Scripting/pyodide/usePyodideWorker.ts | 29 ++++++++++++- gui/src/app/core/Scripting/webR/useWebR.ts | 23 ++++++++--- gui/vite.config.ts | 5 +++ 10 files changed, 108 insertions(+), 19 deletions(-) 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/components/FileEditor/ScriptEditor.tsx b/gui/src/app/components/FileEditor/ScriptEditor.tsx index 5cd8497e..aee27ad8 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 && ["running", "loading", "installing"].includes(status)) { + 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..35859000 100644 --- a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts +++ b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts @@ -3,6 +3,7 @@ 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"; @@ -58,12 +59,18 @@ const addImage = (image: any) => { 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, + ); }; const run = async ( @@ -71,10 +78,14 @@ const run = async ( spData: Record | undefined, spPySettings: PyodideRunSettings, files: Record | undefined, + interruptBuffer: Uint8Array | undefined, ) => { setStatus("loading"); try { const pyodide = await loadPyodideInstance(); + if (interruptBuffer) { + pyodide.setInterruptBuffer(interruptBuffer); + } const [scriptPreamble, scriptPostamble] = getScriptParts(spPySettings); 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/vite.config.ts b/gui/vite.config.ts index d536b91c..0a69e263 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -45,6 +45,11 @@ export default defineConfig({ plugins: [react(), tsconfigPaths()], server: { host: "127.0.0.1", + headers: { + "Cross-Origin-Resource-Policy": "same-origin", + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, }, worker: { format: "es", From 0fcfa5f3303bc03f34de5cf9caf2035f4bfebf64 Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Mon, 3 Feb 2025 19:41:51 +0000 Subject: [PATCH 2/5] Opportunistically load pyodide on worker creation --- .../core/Scripting/pyodide/pyodideWorker.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts index 35859000..4aad8cea 100644 --- a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts +++ b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts @@ -9,28 +9,23 @@ import { 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) => { @@ -57,8 +52,6 @@ const addImage = (image: any) => { sendMessageToMain({ type: "addImage", image }); }; -console.log("pyodide worker loaded"); - self.onmessage = async (e: MessageEvent) => { if (isMonacoWorkerNoise(e.data)) { return; @@ -72,6 +65,10 @@ self.onmessage = async (e: MessageEvent) => { message.interruptBuffer, ); }; +console.log("pyodide worker initialized"); + +console.log("opportunistically loading pyodide"); +const pyodidePromise: Promise = loadPyodideInstance(); const run = async ( code: string, @@ -82,10 +79,11 @@ const run = async ( ) => { setStatus("loading"); try { - const pyodide = await loadPyodideInstance(); + const pyodide = await pyodidePromise; if (interruptBuffer) { pyodide.setInterruptBuffer(interruptBuffer); } + setStatus("installing"); const [scriptPreamble, scriptPostamble] = getScriptParts(spPySettings); From aff7031e679ee54919642d4255eed0d7aa6e571c Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Tue, 4 Feb 2025 17:03:45 +0000 Subject: [PATCH 3/5] Fix pyodide 'requests' with CORS enabled --- .../app/core/Scripting/pyodide/pyodideWorker.ts | 15 +++++++++++++-- gui/vite.config.ts | 1 - 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts index 4aad8cea..4326d195 100644 --- a/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts +++ b/gui/src/app/core/Scripting/pyodide/pyodideWorker.ts @@ -104,6 +104,7 @@ const run = async ( let succeeded = false; try { const packageFutures = []; + let patch_http = false; const micropip = pyodide.pyimport("micropip"); if (spPySettings.showsPlots) { @@ -113,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/vite.config.ts b/gui/vite.config.ts index 0a69e263..0a00a05d 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -46,7 +46,6 @@ export default defineConfig({ server: { host: "127.0.0.1", headers: { - "Cross-Origin-Resource-Policy": "same-origin", "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }, From 2b80d07c4778bde433d30cffe89d1a3ec148bccb Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Fri, 7 Feb 2025 19:39:07 +0000 Subject: [PATCH 4/5] Add vercel.json with header config --- gui/vercel.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 gui/vercel.json 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" + } + ] + } + ] +} From dfde90589dab5f256687320caa8f3da987883a93 Mon Sep 17 00:00:00 2001 From: Brian Ward Date: Wed, 12 Feb 2025 18:55:49 +0000 Subject: [PATCH 5/5] Only allow cancelling during 'running' status --- .../ResultsArea/SamplerOutputArea/DrawsTablePanel.tsx | 8 ++++++-- gui/src/app/components/FileEditor/ScriptEditor.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) 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 aee27ad8..5fcb9dd0 100644 --- a/gui/src/app/components/FileEditor/ScriptEditor.tsx +++ b/gui/src/app/components/FileEditor/ScriptEditor.tsx @@ -184,7 +184,7 @@ const makeToolbar = (o: { }); } - if (onCancel && ["running", "loading", "installing"].includes(status)) { + if (onCancel && status === "running") { ret.push({ type: "button", tooltip: "Cancel",