Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow scripts to be cancelable #274

Merged
merged 5 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const DataPyPanel: FunctionComponent = () => {
[consoleRef, onData, onStatus],
);

const { run } = usePyodideWorker(callbacks);
const { run, cancel } = usePyodideWorker(callbacks);

const handleRun = useCallback(
(code: string) => {
Expand Down Expand Up @@ -62,6 +62,7 @@ const DataPyPanel: FunctionComponent = () => {
language="python"
status={status}
onRun={handleRun}
onCancel={cancel}
runnable={true}
notRunnableReason=""
onHelp={handleHelp}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -40,6 +41,7 @@ const DataRPanel: FunctionComponent = () => {
language="r"
status={status}
onRun={handleRun}
onCancel={cancel}
runnable={true}
notRunnableReason=""
onHelp={handleHelp}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const AnalysisPyPanel: FunctionComponent<NeedsLatestRun> = ({ latestRun }) => {
[consoleRef, imagesRef, onStatus],
);

const { run } = usePyodideWorker(callbacks);
const { run, cancel } = usePyodideWorker(callbacks);

const handleRun = useCallback(
(code: string) => {
Expand Down Expand Up @@ -69,6 +69,7 @@ const AnalysisPyPanel: FunctionComponent<NeedsLatestRun> = ({ latestRun }) => {
language="python"
status={status}
onRun={handleRun}
onCancel={cancel}
runnable={runnable}
notRunnableReason={notRunnableReason}
imagesRef={imagesRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const AnalysisRPanel: FunctionComponent<NeedsLatestRun> = ({ latestRun }) => {
files,
} = useAnalysisState(latestRun);

const { run } = useWebR({ consoleRef, imagesRef, onStatus });
const { run, cancel } = useWebR({ consoleRef, imagesRef, onStatus });

const handleRun = useCallback(
async (userCode: string) => {
Expand All @@ -48,6 +48,7 @@ const AnalysisRPanel: FunctionComponent<NeedsLatestRun> = ({ latestRun }) => {
language="r"
status={status}
onRun={handleRun}
onCancel={cancel}
runnable={runnable}
notRunnableReason={notRunnableReason}
imagesRef={imagesRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ const DrawsTablePanel: FunctionComponent<DrawsTableProps> = ({
<TableCell key="chain">Chain</TableCell>
<TableCell key="draw">Draw</TableCell>
{paramNames.map((name, i) => (
<TableCell key={i}>{name}</TableCell>
<TableCell padding="checkbox" key={i}>
{name}
</TableCell>
))}
</TableRow>
</SuccessColoredTableHead>
Expand All @@ -105,7 +107,9 @@ const DrawsTablePanel: FunctionComponent<DrawsTableProps> = ({
<TableCell>{drawChainIds[i]}</TableCell>
<TableCell>{drawNumbers[i]}</TableCell>
{formattedDraws.map((draw, j) => (
<TableCell key={j}>{draw[i]}</TableCell>
<TableCell padding="checkbox" key={j}>
{draw[i]}
</TableCell>
))}
</SuccessBorderedTableRow>
))}
Expand Down
41 changes: 35 additions & 6 deletions gui/src/app/components/FileEditor/ScriptEditor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,6 +22,7 @@ export type ScriptEditorProps = {
filename: FileNames;
dataKey: ProjectKnownFiles;
onRun: (code: string) => void;
onCancel?: () => void;
runnable: boolean;
notRunnableReason?: string;
onHelp?: () => void;
Expand All @@ -35,6 +36,7 @@ const ScriptEditor: FunctionComponent<ScriptEditorProps> = ({
filename,
dataKey,
onRun,
onCancel,
runnable,
notRunnableReason,
onHelp,
Expand Down Expand Up @@ -74,11 +76,12 @@ const ScriptEditor: FunctionComponent<ScriptEditorProps> = ({

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",
Expand All @@ -92,7 +95,19 @@ const ScriptEditor: FunctionComponent<ScriptEditorProps> = ({
},
},
];
}, [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({
Expand All @@ -101,11 +116,13 @@ const ScriptEditor: FunctionComponent<ScriptEditorProps> = ({
runnable: runnable && !unsavedChanges,
notRunnableReason,
onRun: runCode,
onCancel,
onHelp,
});
}, [
language,
notRunnableReason,
onCancel,
onHelp,
runCode,
runnable,
Expand All @@ -124,7 +141,7 @@ const ScriptEditor: FunctionComponent<ScriptEditorProps> = ({
onSaveText={onSaveText}
toolbarItems={toolbarItems}
contentOnEmpty={contentOnEmpty}
actions={runCtrlEnter}
actions={scriptShortcuts}
/>
<ConsoleOutputPanel consoleRef={consoleRef} />
</Split>
Expand All @@ -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({
Expand All @@ -166,6 +184,17 @@ const makeToolbar = (o: {
});
}

if (onCancel && status === "running") {
ret.push({
type: "button",
tooltip: "Cancel",
label: "Cancel",
icon: <Close />,
onClick: onCancel,
color: "error",
});
}

let label: string;
let color: ColorOptions;
if (status === "loading") {
Expand Down
76 changes: 48 additions & 28 deletions gui/src/app/core/Scripting/pyodide/pyodideWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
WardBrian marked this conversation as resolved.
Show resolved Hide resolved
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) => {
Expand All @@ -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<MessageToPyodideWorker>) => {
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<PyodideInterface> = loadPyodideInstance();

const run = async (
code: string,
spData: Record<string, any> | undefined,
spPySettings: PyodideRunSettings,
files: Record<string, string> | 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);

Expand All @@ -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) {
Expand All @@ -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"]),
);
}
WardBrian marked this conversation as resolved.
Show resolved Hide resolved
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) {
Expand Down
1 change: 1 addition & 0 deletions gui/src/app/core/Scripting/pyodide/pyodideWorkerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type MessageToPyodideWorker = {
spData: Record<string, any> | undefined;
spRunSettings: PyodideRunSettings;
files: Record<string, string> | undefined;
interruptBuffer: Uint8Array | undefined;
};

export const isMessageToPyodideWorker = (
Expand Down
29 changes: 27 additions & 2 deletions gui/src/app/core/Scripting/pyodide/usePyodideWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
);
Comment on lines +46 to +48
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this message isn't quite right--as I understand you can still interrupt it, you're just killing the worker (and will have to reload pyodide in that case)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I guess you can no longer cancel the R scripts.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, though whether or not we even should provide this worker-killing fallback is an open question to me. What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, I don't mind it--if you're stuck in an infinite loop, or something just takes much longer than anticipated, it's nice to be able to terminate what's happening. Even if it means you have to start over next time.

this.#interruptBuffer = undefined;
}

this.#worker.onmessage = (e: MessageEvent) => {
const msg = e.data;
if (!isMessageFromPyodideWorker(msg)) {
Expand Down Expand Up @@ -85,17 +95,32 @@ 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");
}
}

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();
}
}
}

Expand Down
Loading
Loading