diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 89b63870..7bf12e59 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -13,8 +13,16 @@ import { useEmbedContext } from "./embedContext"; import clsx from "clsx"; import { LangConstants } from "@my-code/runtime/languages"; import { useRuntime } from "@my-code/runtime/context"; +import { captureException } from "@sentry/nextjs"; import { MinMaxButton, Modal } from "./modal"; +function handleRuntimeError(error: unknown) { + captureException(error); + window.alert( + "コード実行環境で予期せぬエラーが発生しました: \n" + String(error) + ); +} + interface ExecProps { /* * Pythonの場合はメインファイル1つのみを指定する。 @@ -68,8 +76,9 @@ export function ExecFile(props: ExecProps) { `Language ${props.language.originalLang} does not have a runtime environment.` ); } + const { ready, runFiles, getCommandlineStr, runtimeInfo, interrupt } = - useRuntime(props.language.runtime); + useRuntime(props.language.runtime, { onError: handleRuntimeError }); // ユーザーがクリックした時(triggered) && ランタイムが準備できた時に、実際にinitCommandを実行する(executing) const [executionState, setExecutionState] = useState< diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index c85f4d71..ca90efeb 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -17,6 +17,7 @@ import { useEmbedContext } from "./embedContext"; import { LangConstants } from "@my-code/runtime/languages"; import clsx from "clsx"; import { InlineCode } from "@/markdown/codeBlock"; +import { captureException } from "@sentry/nextjs"; import { emptyMutex, ReplCommand, @@ -26,6 +27,13 @@ import { useRuntime } from "@my-code/runtime/context"; import { MinMaxButton, Modal } from "./modal"; import { StopButtonContent } from "./exec"; +function handleRuntimeError(error: unknown) { + captureException(error); + window.alert( + "コード実行環境で予期せぬエラーが発生しました: \n" + String(error) + ); +} + export function writeOutput( term: Terminal, output: ReplOutput, @@ -37,6 +45,7 @@ export function writeOutput( const message = String(output.message).replace(/\n/g, "\r\n"); switch (output.type) { case "error": + case "fatalError": term.writeln(chalk.red(message)); break; case "trace": @@ -95,7 +104,7 @@ export function ReplTerminal({ checkSyntax, splitReplExamples, runtimeInfo, - } = useRuntime(language.runtime); + } = useRuntime(language.runtime, { onError: handleRuntimeError }); const { tabSize, prompt, promptMore, returnPrefix } = language; if (!prompt) { console.warn(`prompt not defined for language: ${language}`); diff --git a/packages/runtime/src/context.tsx b/packages/runtime/src/context.tsx index a83f96bb..9874812e 100644 --- a/packages/runtime/src/context.tsx +++ b/packages/runtime/src/context.tsx @@ -10,13 +10,20 @@ import { PyodideContext, usePyodide } from "./worker/pyodide"; import { RubyContext, useRuby } from "./worker/ruby"; import { WorkerProvider } from "./worker/runtime"; -export function useRuntime(language: RuntimeLang): RuntimeContext { +interface UseRuntimeOptions { + onError?: (error: unknown) => void; +} +export function useRuntime( + language: RuntimeLang, + options?: UseRuntimeOptions +): RuntimeContext { const runtimes = useRuntimeAll(); const runtime = runtimes[language]; const { init } = runtime; + const { onError } = options ?? {}; useEffect(() => { - init?.(); - }, [init]); + init?.(onError); + }, [init, onError]); return runtime; } export function useRuntimeAll(): Record { diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index 0d8b26ff..9b53a514 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -27,7 +27,7 @@ export interface RuntimeContext { * 初期化とcleanupはuseEffect()で非同期に行うのがよいです。 * */ - init?: () => void; + init?: (onError?: RuntimeErrorHandler) => void; /** * ランタイムの初期化が完了したか、不要である場合true */ @@ -148,11 +148,13 @@ export interface RuntimeInfo { prettyLangName: string; version?: string; } +export type RuntimeErrorHandler = (error: unknown) => void; export const ReplOutputTypeSchema = z.enum([ "stdout", "stderr", "error", + "fatalError", "return", "trace", "system", diff --git a/packages/runtime/src/typescript/runtime.tsx b/packages/runtime/src/typescript/runtime.tsx index 71d24cd2..7c05544e 100644 --- a/packages/runtime/src/typescript/runtime.tsx +++ b/packages/runtime/src/typescript/runtime.tsx @@ -9,9 +9,16 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; -import { ReplOutput, RuntimeContext, RuntimeInfo, UpdatedFile } from "../interface"; +import { + ReplOutput, + RuntimeContext, + RuntimeErrorHandler, + RuntimeInfo, + UpdatedFile, +} from "../interface"; export const compilerOptions: CompilerOptions = { lib: ["ESNext", "WebWorker"], @@ -20,7 +27,7 @@ export const compilerOptions: CompilerOptions = { }; const TypeScriptContext = createContext<{ - init: () => void; + init: (onError?: RuntimeErrorHandler) => void; tsEnv: VirtualTypeScriptEnvironment | null; tsVersion?: string; }>({ init: () => undefined, tsEnv: null }); @@ -28,14 +35,18 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { const [tsEnv, setTSEnv] = useState(null); const [tsVersion, setTSVersion] = useState(undefined); const [doInit, setDoInit] = useState(false); - const init = useCallback(() => setDoInit(true), []); + const onErrorRef = useRef(undefined); + const init = useCallback((onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + setDoInit(true); + }, []); useEffect(() => { // useEffectはサーバーサイドでは実行されないが、 // typeof window !== "undefined" でガードしないとなぜかesbuildが"typescript"を // サーバーサイドでのインポート対象とみなしてしまう。 if (doInit && tsEnv === null && typeof window !== "undefined") { const abortController = new AbortController(); - (async () => { + void (async () => { const ts = await import("typescript"); const vfs = await import("@typescript/vfs"); const system = vfs.createSystem(new Map()); @@ -70,7 +81,12 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { ); setTSEnv(env); setTSVersion(ts.version); - })(); + })().catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + onErrorRef.current?.(error); + }); return () => { abortController.abort(); }; @@ -86,9 +102,11 @@ export function TypeScriptProvider({ children }: { children: ReactNode }) { export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { const { init: tsInit, tsEnv, tsVersion } = useContext(TypeScriptContext); const { init: jsInit } = jsEval; - const init = useCallback(() => { - tsInit(); - jsInit?.(); + const onErrorRef = useRef(undefined); + const init = useCallback((onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + tsInit(onError); + jsInit?.(onError); }, [tsInit, jsInit]); const runFiles = useCallback( @@ -101,6 +119,7 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { onOutput({ type: "error", message: "TypeScript is not ready yet." }); return; } else { + try { for (const [filename, content] of Object.entries(files)) { tsEnv.createFile(filename, content); } @@ -151,6 +170,13 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { { ...files, ...emittedFiles }, onOutput ); + } catch (error) { + onErrorRef.current?.(error); + onOutput({ + type: "fatalError", + message: error instanceof Error ? error.message : String(error), + }); + } } }, [tsEnv, jsEval] diff --git a/packages/runtime/src/wandbox/runtime.tsx b/packages/runtime/src/wandbox/runtime.tsx index 2f6ea65b..ec4485e4 100644 --- a/packages/runtime/src/wandbox/runtime.tsx +++ b/packages/runtime/src/wandbox/runtime.tsx @@ -5,18 +5,27 @@ import { ReactNode, useCallback, useContext, + useEffect, useMemo, + useRef, } from "react"; import useSWR from "swr"; import { compilerInfoFetcher, SelectedCompiler } from "./api"; import { cppRunFiles, selectCppCompiler } from "./cpp"; import { RuntimeLang } from "../languages"; import { rustRunFiles, selectRustCompiler } from "./rust"; -import { ReplOutput, RuntimeContext, RuntimeInfo, UpdatedFile } from "../interface"; +import { + ReplOutput, + RuntimeContext, + RuntimeErrorHandler, + RuntimeInfo, + UpdatedFile, +} from "../interface"; type WandboxLang = "cpp" | "rust"; interface IWandboxContext { + init: (onError?: RuntimeErrorHandler) => void; ready: boolean; getCommandlineStrWithLang: ( lang: WandboxLang @@ -34,10 +43,17 @@ interface IWandboxContext { const WandboxContext = createContext(null!); export function WandboxProvider({ children }: { children: ReactNode }) { + const onErrorRef = useRef(undefined); + const init = useCallback((onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + }, []); const { data: compilerList, error } = useSWR("list", compilerInfoFetcher); - if (error) { - console.error("Failed to fetch compiler list from Wandbox:", error); - } + useEffect(() => { + if (error) { + console.error("Failed to fetch compiler list from Wandbox:", error); + onErrorRef.current?.(error); + } + }, [error]); const ready = !!compilerList; @@ -76,16 +92,29 @@ export function WandboxProvider({ children }: { children: ReactNode }) { onOutput({ type: "error", message: "Wandbox is not ready yet." }); return; } - switch (lang) { - case "cpp": - await cppRunFiles(selectedCompiler.cpp, files, filenames, onOutput); - break; - case "rust": - await rustRunFiles(selectedCompiler.rust, files, filenames, onOutput); - break; - default: - lang satisfies never; - throw new Error(`unsupported language: ${lang}`); + try { + switch (lang) { + case "cpp": + await cppRunFiles(selectedCompiler.cpp, files, filenames, onOutput); + break; + case "rust": + await rustRunFiles( + selectedCompiler.rust, + files, + filenames, + onOutput + ); + break; + default: + lang satisfies never; + throw new Error(`unsupported language: ${lang}`); + } + } catch (error) { + onErrorRef.current?.(error); + onOutput({ + type: "fatalError", + message: error instanceof Error ? error.message : String(error), + }); } }, [selectedCompiler] @@ -94,6 +123,7 @@ export function WandboxProvider({ children }: { children: ReactNode }) { return ( (false); const mutex = useMemo(() => new Mutex(), []); const [doInit, setDoInit] = useState(false); - const init = useCallback(() => setDoInit(true), []); + const onErrorRef = useRef(undefined); + const init = useCallback((onError?: RuntimeErrorHandler) => { + onErrorRef.current = onError; + setDoInit(true); + }, []); const interruptBuffer = useRef(null); const capabilities = useRef(null); const commandHistory = useRef([]); @@ -126,8 +131,12 @@ export function WorkerProvider({ useEffect(() => { if (doInit) { void mutex.runExclusive(async () => { - await initializeWorker(); - setReady(true); + try { + await initializeWorker(); + setReady(true); + } catch (error) { + onErrorRef.current?.(error); + } }); if (isIOS()) { alert( @@ -179,6 +188,8 @@ export function WorkerProvider({ await workerApiRef.current.restoreState(commandHistory.current); } setReady(true); + }).catch((error) => { + onErrorRef.current?.(error); }); break; } @@ -219,6 +230,9 @@ export function WorkerProvider({ proxy(async (item: ReplOutput | UpdatedFile) => { if (item.type !== "file") { output.push(item); + if (item.type === "fatalError") { + onErrorRef.current?.(new Error(item.message)); + } } onOutput(item); }) @@ -227,12 +241,23 @@ export function WorkerProvider({ // Save command to history if interrupt method is 'restart' if (capabilities.current?.interrupt === "restart") { - const hasError = output.some((o) => o.type === "error"); + const hasError = output.some( + (o) => o.type === "error" || o.type === "fatalError" + ); if (!hasError) { commandHistory.current.push(code); } } } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const isExpectedControlError = + message === "Worker interrupted" || message === "Worker terminated"; + if (!isExpectedControlError) { + onErrorRef.current?.(error); + onOutput({ type: "fatalError", message }); + return; + } if (error instanceof Error) { onOutput({ type: "error", message: error.message }); } else { @@ -280,17 +305,33 @@ export function WorkerProvider({ ) { interruptBuffer.current[0] = 0; } - return mutex.runExclusive(async () => { - await trackPromise( - workerApiRef.current!.runFile( - filenames[0], - files, - proxy(async (item: ReplOutput | UpdatedFile) => { - onOutput(item); - }) - ) - ); - }); + try { + return await mutex.runExclusive(async () => { + await trackPromise( + workerApiRef.current!.runFile( + filenames[0], + files, + proxy(async (item: ReplOutput | UpdatedFile) => { + if (item.type === "fatalError") { + onErrorRef.current?.(new Error(item.message)); + } + onOutput(item); + }) + ) + ); + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const isExpectedControlError = + message === "Worker interrupted" || message === "Worker terminated"; + if (!isExpectedControlError) { + onErrorRef.current?.(error); + onOutput({ type: "fatalError", message }); + return; + } + onOutput({ type: "error", message }); + } }, [ready, mutex, trackPromise] );