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,
   };
 };