diff --git a/engines/setup-worker.ts b/engines/setup-worker.ts index 46c5432..7ca9f50 100644 --- a/engines/setup-worker.ts +++ b/engines/setup-worker.ts @@ -30,7 +30,7 @@ const resultMessage = ( /** Create a worker response for unexpected errors */ const errorMessage = ( - error: Error + error: E.WorkerError ): C.WorkerResponseData => ({ type: "error", error }); /** Initialize the execution controller */ @@ -129,15 +129,16 @@ export const setupWorker = (engine: LanguageEngine) => { if (ev.data.type === "ValidateCode") return validateCode(controller, ev.data.params.code); if (ev.data.type === "Execute") - return execute(controller, ev.data.params.interval); + return await 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)); + const err = error as Error; + postMessage(errorMessage(E.serializeError(err))); + return; } throw new Error("Invalid worker message type"); }); diff --git a/engines/worker-constants.ts b/engines/worker-constants.ts index 238aef8..2e690dc 100644 --- a/engines/worker-constants.ts +++ b/engines/worker-constants.ts @@ -73,4 +73,4 @@ export type WorkerResponseData = error?: E.WorkerRuntimeError; } /** Response indicating a bug in worker/engine logic */ - | { type: "error"; error: Error }; + | { type: "error"; error: E.WorkerError }; diff --git a/engines/worker-errors.ts b/engines/worker-errors.ts index d1f18ea..687be9a 100644 --- a/engines/worker-errors.ts +++ b/engines/worker-errors.ts @@ -58,6 +58,13 @@ export type WorkerRuntimeError = { message: string; }; +/** Error sent by worker indicating an implementation bug */ +export type WorkerError = { + name: string; + message: string; + stack?: string; +}; + /** Serialize a RuntimeError instance into a plain object */ export const serializeRuntimeError = ( error: RuntimeError @@ -69,3 +76,8 @@ export const serializeRuntimeError = ( export const serializeParseError = (error: ParseError): WorkerParseError => { return { name: "ParseError", message: error.message, range: error.range }; }; + +/** Serialize an arbitrary error into a plain object */ +export const serializeError = (error: Error): WorkerError => { + return { name: error.name, message: error.message, stack: error.stack }; +}; diff --git a/pages/_app.tsx b/pages/_app.tsx index a331b70..21129ec 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -5,12 +5,15 @@ import "@blueprintjs/icons/lib/css/blueprint-icons.css"; import "react-mosaic-component/react-mosaic-component.css"; import type { AppProps } from "next/app"; import { DarkModeProvider } from "../ui/providers/dark-mode-provider"; +import { ErrorBoundaryProvider } from "../ui/providers/error-boundary-provider"; function MyApp({ Component, pageProps }: AppProps) { return ( - - - + + + + + ); } diff --git a/ui/providers/error-boundary-provider.tsx b/ui/providers/error-boundary-provider.tsx new file mode 100644 index 0000000..9afc364 --- /dev/null +++ b/ui/providers/error-boundary-provider.tsx @@ -0,0 +1,68 @@ +import { Colors, Text, Toast, Toaster } from "@blueprintjs/core"; +import * as React from "react"; + +const CREATE_ISSUE_URL = "https://github.com/nilaymaj/esolang-park/issues/new"; + +const styles = { + errorTitle: { + fontWeight: "bold", + whiteSpace: "pre-wrap" as "pre-wrap", + }, + callStack: { + padding: 10, + borderRadius: 10, + background: Colors.RED1, + whiteSpace: "pre-wrap" as "pre-wrap", + border: "1px solid " + Colors.RED4, + }, +}; + +const ErrorBoundaryContext = React.createContext<{ + throwError: (error: Error) => void; +}>({ throwError: () => {} }); + +/** Context provider for error handling */ +export const ErrorBoundaryProvider = (props: { children: React.ReactNode }) => { + const [error, setError] = React.useState(null); + + return ( + + + {error && ( + setError(null)} + message={ + +

An unexpected error occurred:

+
{error.message}
+
{error.stack}
+

+ Please{" "} + + create an issue on GitHub + {" "} + with: +

+
    +
  • The language you were using
  • +
  • The code you tried to run
  • +
  • The error details and call stack above
  • +
+

+ Once done, refresh the page to reload the IDE. If needed,{" "} + copy your code before refreshing the page. +

+
+ } + /> + )} +
+ {props.children} +
+ ); +}; + +/** Utility hook to access error boundary */ +export const useErrorBoundary = () => React.useContext(ErrorBoundaryContext); diff --git a/ui/use-exec-controller.ts b/ui/use-exec-controller.ts index e7539d3..c5ceb64 100644 --- a/ui/use-exec-controller.ts +++ b/ui/use-exec-controller.ts @@ -5,6 +5,7 @@ import { WorkerResponseData, } from "../engines/worker-constants"; import { WorkerParseError, WorkerRuntimeError } from "../engines/worker-errors"; +import { useErrorBoundary } from "./providers/error-boundary-provider"; /** Possible states for the worker to be in */ type WorkerState = @@ -26,6 +27,7 @@ type WorkerState = export const useExecController = (langName: string) => { const workerRef = React.useRef(null); const [workerState, setWorkerState] = React.useState("loading"); + const errorBoundary = useErrorBoundary(); /** * Type-safe wrapper to abstract request-response cycle into @@ -77,6 +79,20 @@ export const useExecController = (langName: string) => { (async () => { if (workerRef.current) throw new Error("Tried to reinitialize worker"); workerRef.current = new Worker(`../workers/${langName}.js`); + // Add event listener for bubbling errors to main thread + workerRef.current.addEventListener( + "message", + (event: MessageEvent>) => { + if (event.data.type !== "error") return; + const plainError = event.data.error; + const err = new Error( + `[Worker] ${plainError.name}: ${plainError.message}` + ); + err.stack = event.data.error.stack; + errorBoundary.throwError(err); + // throw err; + } + ); const res = await requestWorker({ type: "Init" }); if (res.type === "ack" && res.data === "init") setWorkerState("empty"); else throwUnexpectedRes("init", res);