Add error handling logic

This commit is contained in:
Nilay Majorwar 2022-01-22 13:53:57 +05:30
parent d8481c097b
commit 0efd4c79ef
9 changed files with 291 additions and 131 deletions

View File

@ -1,33 +0,0 @@
import { DocumentRange } from "./types";
/**
* Special error class, to be thrown when encountering a
* syntax error while parsing a program.
*/
export class ParseError extends Error {
/** Location of syntax error in the program */
range: DocumentRange;
/**
* Create an instance of ParseError
* @param message Error message
* @param range Location of syntactically incorrect code
*/
constructor(message: string, range: DocumentRange) {
super(message);
this.range = range;
this.name = "ParseError";
}
}
/**
* Special error class, to be thrown when something happens
* that is indicative of a bug in the language implementation.
*/
export class UnexpectedError extends Error {
/** Create an instance of UnexpectedError */
constructor() {
super("Something unexpected occured");
this.name = "UnexpectedError";
}
}

View File

@ -1,4 +1,9 @@
import { LanguageEngine, StepExecutionResult } from "./types";
import {
isRuntimeError,
serializeRuntimeError,
WorkerRuntimeError,
} from "./worker-errors";
type ExecuteAllArgs<RS> = {
/** Interval between two execution steps, in milliseconds */
@ -94,30 +99,53 @@ class ExecutionController<RS> {
* Run a single step of execution
* @returns Result of execution
*/
executeStep(): StepExecutionResult<RS> {
this._result = this._engine.executeStep();
this._result.signal = "paused";
return this._result;
executeStep(): {
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
} {
try {
this._result = this._engine.executeStep();
this._result.signal = "paused";
return { result: this._result };
} catch (error) {
if (isRuntimeError(error))
return { result: this._result!, error: serializeRuntimeError(error) };
else throw error;
}
}
/**
* Execute the loaded program until stopped.
* Execute the loaded program until stopped. Throws if an error other than RuntimeError is encountered.
* @param param0.interval Interval between two execution steps
* @param param0.onResult Callback called with result on each execution step
* @returns Promise that resolves with result of last execution step
* @returns Promise that resolves with result of last execution step and RuntimeError if any
*/
executeAll({ interval, onResult }: ExecuteAllArgs<RS>) {
executeAll({ interval, onResult }: ExecuteAllArgs<RS>): Promise<{
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
}> {
// Clear paused state
this._isPaused = false;
return new Promise(async (resolve) => {
return new Promise(async (resolve, reject) => {
while (true) {
const doBreak = this.runExecLoopIteration();
onResult(this._result!);
if (doBreak) break;
await this.sleep(interval);
try {
const doBreak = this.runExecLoopIteration();
onResult(this._result!);
if (doBreak) break;
await this.sleep(interval);
} catch (error) {
if (isRuntimeError(error)) {
this._isPaused = true;
resolve({
result: this._result!,
error: serializeRuntimeError(error),
});
} else reject(error);
break;
}
}
resolve(this._result!);
resolve({ result: this._result! });
});
}

View File

@ -1,25 +1,33 @@
import ExecutionController from "./execution-controller";
import { LanguageEngine, StepExecutionResult } from "./types";
import {
WorkerAckType,
WorkerRequestData,
WorkerResponseData,
} from "./worker-constants";
import * as E from "./worker-errors";
import * as C from "./worker-constants";
/** Create a worker response for update acknowledgement */
const ackMessage = <RS>(state: WorkerAckType): WorkerResponseData<RS> => ({
/** Create a worker response for acknowledgement */
const ackMessage = <RS, A extends C.WorkerAckType>(
ackType: A,
error?: C.WorkerAckError[A]
): C.WorkerResponseData<RS, A> => ({
type: "ack",
data: state,
data: ackType,
error,
});
/** Create a worker response for execution result */
const resultMessage = <RS>(
result: StepExecutionResult<RS>
): WorkerResponseData<RS> => ({
const resultMessage = <RS, A extends C.WorkerAckType>(
result: StepExecutionResult<RS>,
error?: E.WorkerRuntimeError
): C.WorkerResponseData<RS, A> => ({
type: "result",
data: result,
error,
});
/** Create a worker response for unexpected errors */
const errorMessage = <RS, A extends C.WorkerAckType>(
error: Error
): C.WorkerResponseData<RS, A> => ({ type: "error", error });
/** Initialize the execution controller */
const initController = () => {
postMessage(ackMessage("init"));
@ -42,8 +50,14 @@ const prepare = <RS>(
controller: ExecutionController<RS>,
{ code, input }: { code: string; input: string }
) => {
controller.prepare(code, input);
postMessage(ackMessage("prepare"));
try {
controller.prepare(code, input);
postMessage(ackMessage("prepare"));
} catch (error) {
if (E.isParseError(error))
postMessage(ackMessage("prepare", E.serializeParseError(error)));
else throw error;
}
};
/**
@ -62,11 +76,15 @@ const updateBreakpoints = <RS>(
* Execute the entire program loaded on engine,
* and return result of execution.
*/
const execute = <RS>(controller: ExecutionController<RS>, interval: number) => {
controller.executeAll({
const execute = async <RS>(
controller: ExecutionController<RS>,
interval: number
) => {
const { result, error } = await controller.executeAll({
interval,
onResult: (res) => postMessage(resultMessage(res)),
});
if (error) postMessage(resultMessage(result, error));
};
/** Trigger pause in program execution */
@ -77,8 +95,8 @@ const pauseExecution = async <RS>(controller: ExecutionController<RS>) => {
/** Run a single execution step */
const executeStep = <RS>(controller: ExecutionController<RS>) => {
const result = controller.executeStep();
postMessage(resultMessage(result));
const { result, error } = controller.executeStep();
postMessage(resultMessage(result, error));
};
/**
@ -88,16 +106,23 @@ const executeStep = <RS>(controller: ExecutionController<RS>) => {
export const setupWorker = <RS>(engine: LanguageEngine<RS>) => {
const controller = new ExecutionController(engine);
addEventListener("message", async (ev: MessageEvent<WorkerRequestData>) => {
if (ev.data.type === "Init") return initController();
if (ev.data.type === "Reset") return resetController(controller);
if (ev.data.type === "Prepare") return prepare(controller, ev.data.params);
if (ev.data.type === "Execute")
return execute(controller, ev.data.params.interval);
if (ev.data.type === "Pause") return await pauseExecution(controller);
if (ev.data.type === "ExecuteStep") return executeStep(controller);
if (ev.data.type === "UpdateBreakpoints")
return updateBreakpoints(controller, ev.data.params.points);
addEventListener("message", async (ev: MessageEvent<C.WorkerRequestData>) => {
try {
if (ev.data.type === "Init") return initController();
if (ev.data.type === "Reset") return resetController(controller);
if (ev.data.type === "Prepare")
return prepare(controller, ev.data.params);
if (ev.data.type === "Execute")
return execute(controller, ev.data.params.interval);
if (ev.data.type === "Pause") return await pauseExecution(controller);
if (ev.data.type === "ExecuteStep") return executeStep(controller);
if (ev.data.type === "UpdateBreakpoints")
return updateBreakpoints(controller, ev.data.params.points);
} catch (error) {
// Error here indicates an implementation bug
console.error(error);
postMessage(errorMessage(error as Error));
}
throw new Error("Invalid worker message type");
});
};

View File

@ -1,4 +1,5 @@
import { StepExecutionResult } from "./types";
import { DocumentRange, StepExecutionResult } from "./types";
import * as E from "./worker-errors";
/** Types of requests the worker handles */
export type WorkerRequestData =
@ -39,7 +40,28 @@ export type WorkerAckType =
| "prepare" // on preparing for execution
| "pause"; // on pausing execution
/** Errors associated with each response ack type */
export type WorkerAckError = {
init: undefined;
reset: undefined;
"bp-update": undefined;
prepare: E.WorkerParseError;
pause: undefined;
};
/** Types of responses the worker can send */
export type WorkerResponseData<RS> =
| { type: "ack"; data: WorkerAckType }
| { type: "result"; data: StepExecutionResult<RS> };
export type WorkerResponseData<RS, A extends WorkerAckType> =
/** Ack for one-off requests, optionally containing error occured (if any) */
| {
type: "ack";
data: A;
error?: WorkerAckError[A];
}
/** Response containing step execution result, and runtime error (if any) */
| {
type: "result";
data: StepExecutionResult<RS>;
error?: E.WorkerRuntimeError;
}
/** Response indicating a bug in worker/engine logic */
| { type: "error"; error: Error };

71
engines/worker-errors.ts Normal file
View File

@ -0,0 +1,71 @@
import { DocumentRange } from "./types";
/**
* Special error class, to be thrown when encountering a
* syntax error while parsing a program.
*/
export class ParseError extends Error {
/** Location of syntax error in the program */
range: DocumentRange;
/**
* Create an instance of ParseError
* @param message Error message
* @param range Location of syntactically incorrect code
*/
constructor(message: string, range: DocumentRange) {
super(message);
this.name = "ParseError";
this.range = range;
}
}
/**
* Special error class, to be thrown when encountering an error
* at runtime that is indicative of a bug in the user's program.
*/
export class RuntimeError extends Error {
/**
* Create an instance of RuntimeError
* @param message Error message
*/
constructor(message: string) {
super(message);
this.name = "RuntimeError";
}
}
/** Check if an error object is instance of a ParseError */
export const isParseError = (error: any): error is ParseError => {
return error instanceof ParseError || error.name === "ParseError";
};
/** Check if an error object is instance of a RuntimeError */
export const isRuntimeError = (error: any): error is RuntimeError => {
return error instanceof RuntimeError || error.name === "RuntimeError";
};
/** Error sent by worker in case of parsing error */
export type WorkerParseError = {
name: "ParseError";
message: string;
range: DocumentRange;
};
/** Error sent by worker in case error at runtime */
export type WorkerRuntimeError = {
name: "RuntimeError";
message: string;
};
/** Serialize a RuntimeError instance into a plain object */
export const serializeRuntimeError = (
error: RuntimeError
): WorkerRuntimeError => {
return { name: "RuntimeError", message: error.message };
};
/** Serialize a ParseError instance into a plain object */
export const serializeParseError = (error: ParseError): WorkerParseError => {
return { name: "ParseError", message: error.message, range: error.range };
};

View File

@ -7,6 +7,7 @@ import { LanguageProvider, StepExecutionResult } from "../engines/types";
import { OutputViewer, OutputViewerRef } from "../ui/output-viewer";
import { ExecutionControls } from "./execution-controls";
import { RendererRef, RendererWrapper } from "./renderer-wrapper";
import { WorkerRuntimeError } from "../engines/worker-errors";
type Props<RS> = {
langName: string;
@ -36,10 +37,14 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
const [execInterval, setExecInterval] = React.useState(20);
/** Utility that updates UI with the provided execution result */
const updateWithResult = (result: StepExecutionResult<any>) => {
const updateWithResult = (
result: StepExecutionResult<any>,
error?: WorkerRuntimeError
) => {
rendererRef.current!.updateState(result.rendererState);
codeEditorRef.current!.updateHighlights(result.nextStepLocation);
outputEditorRef.current!.append(result.output);
if (error) outputEditorRef.current!.setError(error);
};
/** Reset and begin a new execution */
@ -54,13 +59,14 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
// Reset any existing execution state
outputEditorRef.current!.reset();
await execController.resetState();
await execController.prepare(
const error = await execController.prepare(
codeEditorRef.current!.getValue(),
inputEditorRef.current!.getValue()
);
// Begin execution
await execController.execute(updateWithResult, execInterval);
// Check for ParseError, else begin execution
if (error) outputEditorRef.current!.setError(error);
else await execController.execute(updateWithResult, execInterval);
};
/** Pause the ongoing execution */
@ -82,8 +88,8 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
}
// Run and update execution states
const result = await execController.executeStep();
updateWithResult(result);
const response = await execController.executeStep();
updateWithResult(response.result, response.error);
};
/** Resume the currently paused execution */
@ -101,7 +107,7 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
/** Stop the currently active execution */
const stopExecution = async () => {
// Check if controller has execution
if (!["paused", "processing"].includes(execController.state)) {
if (!["paused", "processing", "error"].includes(execController.state)) {
console.error("No active execution in controller");
return;
}
@ -112,7 +118,6 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
// Reset all execution states
await execController.resetState();
outputEditorRef.current!.reset();
rendererRef.current!.updateState(null);
codeEditorRef.current!.updateHighlights(null);
};
@ -122,6 +127,7 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
const currState = execController.state;
if (currState === "processing") return "running";
else if (currState === "paused") return "paused";
else if (currState === "error") return "error";
else return "off";
};

View File

@ -25,6 +25,9 @@ const styles = {
},
};
/** Possible states of the debug controls component */
type DebugControlsState = "off" | "running" | "paused" | "error";
/** Input field for changing execution interval */
const IntervalInput = (props: {
disabled: boolean;
@ -62,25 +65,30 @@ const RunButton = ({ onClick }: { onClick: () => void }) => (
/** Button group for debugging controls */
const DebugControls = (props: {
paused: boolean;
state: DebugControlsState;
onPause: () => void;
onResume: () => void;
onStep: () => void;
onStop: () => void;
}) => {
const paused = props.state === "paused" || props.state === "error";
const pauseDisabled = props.state === "error";
const stepDisabled = ["off", "running", "error"].includes(props.state);
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" />}
title={paused ? "Pause" : "Resume"}
disabled={pauseDisabled}
onClick={paused ? props.onResume : props.onPause}
icon={<Icon icon={paused ? "play" : "pause"} intent="primary" />}
/>
<Button
small
title="Step"
onClick={props.onStep}
disabled={!props.paused}
disabled={stepDisabled}
icon={<Icon icon="step-forward" intent="warning" />}
/>
<Button
@ -94,7 +102,7 @@ const DebugControls = (props: {
};
type Props = {
state: "off" | "running" | "paused";
state: DebugControlsState;
onRun: () => void;
onPause: () => void;
onResume: () => void;
@ -110,7 +118,7 @@ export const ExecutionControls = (props: Props) => {
<RunButton onClick={props.onRun} />
) : (
<DebugControls
paused={props.state === "paused"}
state={props.state}
onPause={props.onPause}
onResume={props.onResume}
onStep={props.onStep}

View File

@ -1,18 +1,18 @@
import React from "react";
import { TextArea } from "@blueprintjs/core";
import { Colors, Text } from "@blueprintjs/core";
import { WorkerParseError, WorkerRuntimeError } from "../engines/worker-errors";
/**
* For aesthetic reasons, we use readonly textarea for displaying output.
* Textarea displays placeholder if value passed is empty string, which is undesired.
* This function is a fake-whitespace workaround.
*
* @param value Value received from parent. Placeholder shown on `null`.
* @returns Value to pass as prop to Blueprint TextArea
*/
const toTextareaValue = (value: string | null): string | undefined => {
if (value == null) return undefined; // Placeholder shown
if (value === "") return "\u0020"; // Fake whitespace to hide placeholder
return value; // Non-empty output value
/** Format a ParseError for displaying as output */
const formatParseError = (error: WorkerParseError): string => {
const line = error.range.line + 1;
const start = error.range.charRange?.start;
const end = error.range.charRange?.end;
console.log(line, start, end);
let cols: string | null = null;
if (start != null && end != null) cols = `col ${start + 1}-${end + 1}`;
else if (start != null) cols = `col ${start + 1}`;
else if (end != null) cols = `col ${end + 1}`;
return `ParseError: line ${line}, ${cols}\n${error.message}`;
};
export interface OutputViewerRef {
@ -20,26 +20,36 @@ export interface OutputViewerRef {
reset: () => void;
/** Append string to the displayed output */
append: (str?: string) => void;
/** Add error text below the output text */
setError: (error: WorkerRuntimeError | WorkerParseError | null) => void;
}
const OutputViewerComponent = (_: {}, ref: React.Ref<OutputViewerRef>) => {
const [value, setValue] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useImperativeHandle(ref, () => ({
reset: () => setValue(null),
reset: () => {
setValue(null);
setError(null);
},
append: (s) => setValue((o) => (o || "") + (s || "")),
setError: (error: WorkerRuntimeError | WorkerParseError | null) => {
if (!error) setError(null);
else if (error.name === "RuntimeError")
setError("RuntimeError: " + error.message);
else if (error.name === "ParseError") setError(formatParseError(error));
},
}));
return (
<TextArea
fill
large
readOnly
growVertically
value={toTextareaValue(value)}
placeholder="Run code to see output..."
style={{ height: "100%", resize: "none", boxShadow: "none" }}
/>
<div style={{ padding: 10, fontSize: 16 }}>
<Text style={{ fontFamily: "monospace" }}>{value}</Text>
{value && <div style={{ height: 10 }} />}
<Text style={{ fontFamily: "monospace", color: Colors.RED3 }}>
{error}
</Text>
</div>
);
};

View File

@ -4,6 +4,7 @@ import {
WorkerRequestData,
WorkerResponseData,
} from "../engines/worker-constants";
import { WorkerParseError, WorkerRuntimeError } from "../engines/worker-errors";
/** Possible states for the worker to be in */
type WorkerState =
@ -12,6 +13,7 @@ type WorkerState =
| "ready" // Ready to start execution
| "processing" // Executing code
| "paused" // Execution currently paused
| "error" // Execution ended due to error
| "done"; // Program ended, reset now
/**
@ -40,10 +42,10 @@ export const useExecController = <RS>(langName: string) => {
*/
const requestWorker = (
request: WorkerRequestData,
onData?: (data: WorkerResponseData<RS>) => boolean
): Promise<WorkerResponseData<RS>> => {
onData?: (data: WorkerResponseData<RS, any>) => boolean
): Promise<WorkerResponseData<RS, any>> => {
return new Promise((resolve) => {
const handler = (ev: MessageEvent<WorkerResponseData<RS>>) => {
const handler = (ev: MessageEvent<WorkerResponseData<RS, any>>) => {
if (!onData) {
// Normal mode
workerRef.current!.removeEventListener("message", handler);
@ -65,7 +67,7 @@ export const useExecController = <RS>(langName: string) => {
/** Utility to throw error on unexpected response */
const throwUnexpectedRes = (
fnName: string,
res: WorkerResponseData<RS>
res: WorkerResponseData<RS, any>
): never => {
throw new Error(`Unexpected response on ${fnName}: ${JSON.stringify(res)}`);
};
@ -75,6 +77,9 @@ export const useExecController = <RS>(langName: string) => {
(async () => {
if (workerRef.current) throw new Error("Tried to reinitialize worker");
workerRef.current = new Worker(`../workers/${langName}.js`);
workerRef.current!.addEventListener("error", (ev) =>
console.log("Ev: ", ev)
);
const res = await requestWorker({ type: "Init" });
if (res.type === "ack" && res.data === "init") setWorkerState("empty");
else throwUnexpectedRes("init", res);
@ -92,14 +97,19 @@ export const useExecController = <RS>(langName: string) => {
* @param code Code content
* @param input User input
*/
const prepare = React.useCallback(async (code: string, input: string) => {
const res = await requestWorker({
type: "Prepare",
params: { code, input },
});
if (res.type === "ack" && res.data === "prepare") setWorkerState("ready");
else throwUnexpectedRes("loadCode", res);
}, []);
const prepare = React.useCallback(
async (code: string, input: string): Promise<WorkerParseError | void> => {
const res = await requestWorker({
type: "Prepare",
params: { code, input },
});
if (res.type === "ack" && res.data === "prepare") {
if (res.error) return res.error;
else setWorkerState("ready");
} else throwUnexpectedRes("loadCode", res);
},
[]
);
/**
* Update debugging breakpoints in the execution controller.
@ -142,14 +152,18 @@ export const useExecController = <RS>(langName: string) => {
* Run a single step of execution
* @return Execution result
*/
const executeStep = React.useCallback(async () => {
const executeStep = React.useCallback(async (): Promise<{
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
}> => {
const res = await requestWorker(
{ type: "ExecuteStep" },
(res) => res.type !== "result"
);
if (res.type !== "result") throw new Error("Something unexpected happened");
if (!res.data.nextStepLocation) setWorkerState("done");
return res.data;
return { result: res.data, error: res.error };
}, []);
/**
@ -158,20 +172,29 @@ export const useExecController = <RS>(langName: string) => {
*/
const execute = React.useCallback(
async (
onResult: (result: StepExecutionResult<RS>) => void,
onResult: (
result: StepExecutionResult<RS>,
error?: WorkerRuntimeError
) => void,
interval: number
) => {
setWorkerState("processing");
// Set up a streaming-response cycle with the worker
await requestWorker({ type: "Execute", params: { interval } }, (res) => {
if (res.type !== "result") return true;
onResult(res.data);
onResult(res.data, res.error);
if (!res.data.nextStepLocation) {
// Program execution complete
setWorkerState("done");
return false;
} else if (res.data.signal === "paused") {
// Execution paused by user or breakpoint
setWorkerState("paused");
return false;
} else if (res.error) {
// Runtime error occured
setWorkerState("error");
return false;
} else return true;
});
},