Add pause-resume-stop functionality and UI

This commit is contained in:
Nilay Majorwar 2021-12-15 21:31:25 +05:30
parent 29b243d6f2
commit 13ff6da638
8 changed files with 262 additions and 31 deletions

View File

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

View File

@ -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";
};
/**

View File

@ -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> =

View File

@ -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");

View File

@ -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>

View File

@ -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}
/>
)}
/>
);
};

76
ui/execution-controls.tsx Normal file
View File

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

View File

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