From 13ff6da6386b0d132773ed8eb91af44a965052b2 Mon Sep 17 00:00:00 2001 From: Nilay Majorwar <nilaymajorwar@gmail.com> Date: Wed, 15 Dec 2021 21:31:25 +0530 Subject: [PATCH] Add pause-resume-stop functionality and UI --- engines/execution-controller.ts | 67 +++++++++++++++++++++++-- engines/types.ts | 3 ++ engines/worker-constants.ts | 11 ++++- engines/worker.ts | 11 ++++- ui/MainLayout.tsx | 5 +- ui/Mainframe.tsx | 88 ++++++++++++++++++++++++++------- ui/execution-controls.tsx | 76 ++++++++++++++++++++++++++++ ui/use-exec-controller.ts | 32 +++++++++--- 8 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 ui/execution-controls.tsx diff --git a/engines/execution-controller.ts b/engines/execution-controller.ts index d88cf7d..a087545 100644 --- a/engines/execution-controller.ts +++ b/engines/execution-controller.ts @@ -14,6 +14,8 @@ class ExecutionController<RS> { private _engine: LanguageEngine<RS>; private _breakpoints: number[] = []; private _result: StepExecutionResult<RS> | null; + private _resolvePause: (() => void) | null = null; + private _isPaused: boolean = false; /** * Create a new ExecutionController. @@ -51,16 +53,75 @@ class ExecutionController<RS> { this._breakpoints = points; } + /** + * Pause the ongoing execution. + * - If already paused, returns immediately + * - Queues up with any existing pause calls + * @returns Promise that resolves when execution is paused + */ + async pauseExecution(): Promise<void> { + // If already paused, return immediately + if (this._isPaused) return; + + // If there's another "pause" call waiting, chain up with older resolver. + // This kinda creates a linked list of resolvers, with latest resolver at head. + if (this._resolvePause) { + console.log("Chaining pause calls"); + return new Promise((resolve) => { + // Keep a reference to the existing resolver + const oldResolve = this._resolvePause; + // Replace resolver with new chained resolver + this._resolvePause = () => { + oldResolve && oldResolve(); + resolve(); + }; + }); + } + + // Else, create a callback to be called by the execution loop + // when it finishes current execution step. + return new Promise( + (resolve) => + (this._resolvePause = () => { + this._resolvePause = null; + this._isPaused = true; + resolve(); + }) + ); + } + + /** + * Execute the loaded program until stopped. + * @param param0.interval Interval between two execution steps + * @param param0.onResult Callback called with result on each execution step + * @returns Returns last (already used) execution result + */ async executeAll({ interval, onResult }: ExecuteAllArgs<RS>) { while (true) { this._result = this._engine.executeStep(); - onResult && onResult(this._result); - if (!this._result.nextStepLocation) break; - await this.sleep(interval || 0); + console.log("Result: ", this._result); + if (!this._result.nextStepLocation) { + // End of program + onResult && onResult(this._result); + this._resolvePause && this._resolvePause(); // In case pause happens on same cycle + break; + } else if (this._resolvePause) { + // Execution has been paused/stopped + this._result.signal = "paused"; + onResult && onResult(this._result); + this._resolvePause(); + break; + } else { + onResult && onResult(this._result); + // Sleep for specified interval + await this.sleep(interval || 0); + } } + return this._result; } + /** Asynchronously sleep for a period of time */ private async sleep(millis: number) { return new Promise<void>((resolve) => setTimeout(resolve, millis)); } diff --git a/engines/types.ts b/engines/types.ts index 57d00c4..3b49d9e 100644 --- a/engines/types.ts +++ b/engines/types.ts @@ -35,6 +35,9 @@ export type StepExecutionResult<RS> = { * Passing `null` indicates reaching the end of program. */ nextStepLocation: DocumentRange | null; + + /** Signal if execution has been paused/stopped */ + signal?: "paused"; }; /** diff --git a/engines/worker-constants.ts b/engines/worker-constants.ts index c1ac3a6..cec0a74 100644 --- a/engines/worker-constants.ts +++ b/engines/worker-constants.ts @@ -21,10 +21,19 @@ export type WorkerRequestData = | { type: "Execute"; params: { interval?: number }; + } + | { + type: "Pause"; + params?: null; }; /** Kinds of acknowledgement responses the worker can send */ -export type WorkerAckType = "init" | "reset" | "bp-update" | "prepare"; +export type WorkerAckType = + | "init" // on initialization + | "reset" // on state reset + | "bp-update" // on updating breakpoints + | "prepare" // on preparing for execution + | "pause"; // on pausing execution /** Types of responses the worker can send */ export type WorkerResponseData<RS> = diff --git a/engines/worker.ts b/engines/worker.ts index 8042537..94826f1 100644 --- a/engines/worker.ts +++ b/engines/worker.ts @@ -71,11 +71,20 @@ const execute = (interval?: number) => { }); }; -addEventListener("message", (ev: MessageEvent<WorkerRequestData>) => { +/** + * Trigger pause in program execution + */ +const pauseExecution = async () => { + await _controller!.pauseExecution(); + postMessage(ackMessage("pause")); +}; + +addEventListener("message", async (ev: MessageEvent<WorkerRequestData>) => { if (ev.data.type === "Init") return initController(); if (ev.data.type === "Reset") return resetController(); if (ev.data.type === "Prepare") return prepare(ev.data.params); if (ev.data.type === "Execute") return execute(ev.data.params.interval); + if (ev.data.type === "Pause") return await pauseExecution(); if (ev.data.type === "UpdateBreakpoints") return updateBreakpoints(ev.data.params.points); throw new Error("Invalid worker message type"); diff --git a/ui/MainLayout.tsx b/ui/MainLayout.tsx index 161ffe5..7421d2f 100644 --- a/ui/MainLayout.tsx +++ b/ui/MainLayout.tsx @@ -16,6 +16,7 @@ type Props = { renderRenderer: () => React.ReactNode; renderInput: () => React.ReactNode; renderOutput: () => React.ReactNode; + renderExecControls: () => React.ReactNode; }; export const MainLayout = (props: Props) => { @@ -48,7 +49,9 @@ export const MainLayout = (props: Props) => { <MosaicWindow<number> path={path} title={WindowTitles[windowId]} - toolbarControls={<span />} + toolbarControls={ + windowId === "editor" ? props.renderExecControls() : <span /> + } > {MOSAIC_MAP[windowId]()} </MosaicWindow> diff --git a/ui/Mainframe.tsx b/ui/Mainframe.tsx index 1c26c0f..c71b9a7 100644 --- a/ui/Mainframe.tsx +++ b/ui/Mainframe.tsx @@ -6,6 +6,7 @@ import { useExecController } from "../ui/use-exec-controller"; import { DocumentRange, LanguageProvider } from "../engines/types"; import BrainfuckProvider from "../engines/brainfuck"; import { OutputViewer } from "../ui/output-viewer"; +import { ExecutionControls } from "./execution-controls"; export const Mainframe = () => { const codeEditorRef = React.useRef<CodeEditorRef>(null); @@ -20,18 +21,18 @@ export const Mainframe = () => { DocumentRange | undefined >(); - const testDrive = React.useCallback(async () => { - console.info("=== RUNNING TEST DRIVE ==="); - - // Check that controller is ready to execute - const readyStates = ["empty", "ready", "done"]; + /** Reset and begin a new execution */ + const runProgram = async () => { + // Check if controller is free for execution + const readyStates = ["empty", "done"]; if (!readyStates.includes(execController.state)) { console.error(`Controller not ready: state is ${execController.state}`); return; } - // Prepare for execution + // Reset any existing execution state setOutput(""); + setRendererState(null); await execController.resetState(); await execController.prepare( codeEditorRef.current!.getValue(), @@ -39,21 +40,65 @@ export const Mainframe = () => { ); // Begin execution - await execController.executeAll((result) => { + await execController.execute((result) => { setRendererState(result.rendererState); setCodeHighlights(result.nextStepLocation || undefined); setOutput((o) => (o || "") + (result.output || "")); - }, 20); - }, [execController.state]); + }, 1000); + }; - React.useEffect(() => { - const handler = (ev: KeyboardEvent) => { - if (!(ev.ctrlKey && ev.code === "KeyY")) return; - testDrive(); - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [testDrive]); + /** Pause the ongoing execution */ + const pauseExecution = async () => { + // Check if controller is indeed executing code + if (execController.state !== "processing") { + console.error("Controller not processing any code"); + return; + } + await execController.pauseExecution(); + }; + + /** Resume the currently paused execution */ + const resumeExecution = async () => { + // Check if controller is indeed paused + if (execController.state !== "paused") { + console.error("Controller is not paused"); + return; + } + + // Begin execution + await execController.execute((result) => { + setRendererState(result.rendererState); + setCodeHighlights(result.nextStepLocation || undefined); + setOutput((o) => (o || "") + (result.output || "")); + }, 1000); + }; + + /** Stop the currently active execution */ + const stopExecution = async () => { + // Check if controller has execution + if (!["paused", "processing"].includes(execController.state)) { + console.error("No active execution in controller"); + return; + } + + // If currently processing, pause execution loop first + if (execController.state === "processing") + await execController.pauseExecution(); + + // Reset all execution states + await execController.resetState(); + setOutput(null); + setRendererState(null); + setCodeHighlights(undefined); + }; + + /** Translate execution controller state to debug controls state */ + const getDebugState = () => { + const currState = execController.state; + if (currState === "processing") return "running"; + else if (currState === "paused") return "paused"; + else return "off"; + }; return ( <MainLayout @@ -74,6 +119,15 @@ export const Mainframe = () => { )} renderInput={() => <InputEditor ref={inputEditorRef} />} renderOutput={() => <OutputViewer value={output} />} + renderExecControls={() => ( + <ExecutionControls + state={getDebugState()} + onRun={runProgram} + onPause={pauseExecution} + onResume={resumeExecution} + onStop={stopExecution} + /> + )} /> ); }; diff --git a/ui/execution-controls.tsx b/ui/execution-controls.tsx new file mode 100644 index 0000000..3942bc4 --- /dev/null +++ b/ui/execution-controls.tsx @@ -0,0 +1,76 @@ +import { Button, ButtonGroup, Icon } from "@blueprintjs/core"; + +const styles = { + container: { + display: "flex", + alignItems: "center", + paddingRight: 5, + }, +}; + +/** Button for starting code execution */ +const RunButton = ({ onClick }: { onClick: () => void }) => ( + <Button + small + onClick={onClick} + rightIcon={<Icon icon="play" intent="success" />} + > + Run code + </Button> +); + +/** Button group for debugging controls */ +const DebugControls = (props: { + paused: boolean; + onPause: () => void; + onResume: () => void; + onStop: () => void; +}) => { + return ( + <ButtonGroup> + <Button + small + title={props.paused ? "Pause" : "Resume"} + onClick={props.paused ? props.onResume : props.onPause} + icon={<Icon icon={props.paused ? "play" : "pause"} intent="primary" />} + /> + <Button + small + title="Step" + disabled={!props.paused} + icon={<Icon icon="step-forward" intent="warning" />} + /> + <Button + small + title="Stop" + onClick={props.onStop} + icon={<Icon icon="stop" intent="danger" />} + /> + </ButtonGroup> + ); +}; + +type Props = { + state: "off" | "running" | "paused"; + onRun: () => void; + onPause: () => void; + onResume: () => void; + onStop: () => void; +}; + +export const ExecutionControls = (props: Props) => { + return ( + <div style={styles.container}> + {props.state === "off" ? ( + <RunButton onClick={props.onRun} /> + ) : ( + <DebugControls + paused={props.state === "paused"} + onPause={props.onPause} + onResume={props.onResume} + onStop={props.onStop} + /> + )} + </div> + ); +}; diff --git a/ui/use-exec-controller.ts b/ui/use-exec-controller.ts index 490727f..d4f6adc 100644 --- a/ui/use-exec-controller.ts +++ b/ui/use-exec-controller.ts @@ -9,8 +9,9 @@ import { type WorkerState = | "loading" // Worker is not initialized yet | "empty" // Worker loaded, no code loaded yet - | "ready" // Code loaded, ready to execute + | "ready" // Ready to start execution | "processing" // Executing code + | "paused" // Execution currently paused | "done"; // Program ended, reset now /** @@ -66,7 +67,7 @@ export const useExecController = <RS>() => { fnName: string, res: WorkerResponseData<RS> ): never => { - throw new Error(`Unexpected response on ${fnName}: ${res.toString()}`); + throw new Error(`Unexpected response on ${fnName}: ${JSON.stringify(res)}`); }; // Initialization and cleanup of web worker @@ -128,11 +129,22 @@ export const useExecController = <RS>() => { else throwUnexpectedRes("resetState", res); }, []); + /** + * Pause program execution + */ + const pauseExecution = React.useCallback(async () => { + await requestWorker({ type: "Pause" }, (res) => { + // We don't update state here - that's done by the execution stream instead + if (!(res.type === "ack" && res.data === "pause")) return true; + return false; + }); + }, []); + /** * Execute the code loaded into the engine * @param onResult Callback used when an execution result is received */ - const executeAll = React.useCallback( + const execute = React.useCallback( async ( onResult: (result: StepExecutionResult<RS>) => void, interval?: number @@ -142,10 +154,13 @@ export const useExecController = <RS>() => { await requestWorker({ type: "Execute", params: { interval } }, (res) => { if (res.type !== "result") return true; onResult(res.data); - if (res.data.nextStepLocation) return true; - // Clean up and terminate response stream - setWorkerState("done"); - return false; + if (!res.data.nextStepLocation) { + setWorkerState("done"); + return false; + } else if (res.data.signal === "paused") { + setWorkerState("paused"); + return false; + } else return true; }); }, [] @@ -155,7 +170,8 @@ export const useExecController = <RS>() => { state: workerState, resetState, prepare, - executeAll, + pauseExecution, + execute, updateBreakpoints, }; };