From a2f0aeee511b845102b6667b5acef2c6352ea1c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:03:56 +0000 Subject: [PATCH 01/11] Initial plan From 4c57151f017fc2ec6a7df51204e91a2faee8402f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:12:02 +0000 Subject: [PATCH 02/11] Add Ruby runtime implementation with ruby.wasm Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/editor.tsx | 6 +- app/terminal/highlight.ts | 6 +- app/terminal/ruby/page.tsx | 23 +++ app/terminal/ruby/runtime.tsx | 234 +++++++++++++++++++++++++ app/terminal/runtime.tsx | 19 ++- package-lock.json | 27 +++ package.json | 2 + public/ruby.worker.js | 310 ++++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 app/terminal/ruby/page.tsx create mode 100644 app/terminal/ruby/runtime.tsx create mode 100644 public/ruby.worker.js diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 0b8f932..4e81830 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -11,6 +11,7 @@ const AceEditor = dynamic( await import("ace-builds/src-min-noconflict/ext-language_tools"); await import("ace-builds/src-min-noconflict/ext-searchbox"); await import("ace-builds/src-min-noconflict/mode-python"); + await import("ace-builds/src-min-noconflict/mode-ruby"); await import("ace-builds/src-min-noconflict/mode-c_cpp"); await import("ace-builds/src-min-noconflict/mode-json"); await import("ace-builds/src-min-noconflict/mode-csv"); @@ -28,13 +29,16 @@ import { langConstants } from "./runtime"; // snippetを有効化するにはsnippetもimportする必要がある: import "ace-builds/src-min-noconflict/snippets/python"; // mode-xxxx.js のファイル名と、AceEditorの mode プロパティの値が対応する -export type AceLang = "python" | "c_cpp" | "json" | "csv" | "text"; +export type AceLang = "python" | "ruby" | "c_cpp" | "json" | "csv" | "text"; export function getAceLang(lang: string | undefined): AceLang { // Markdownで指定される可能性のある言語名からAceLangを取得 switch (lang) { case "python": case "py": return "python"; + case "ruby": + case "rb": + return "ruby"; case "cpp": case "c++": return "c_cpp"; diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index e5be201..f11202a 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -3,13 +3,17 @@ import chalk from "chalk"; import { RuntimeLang } from "./runtime"; // Python言語定義をインポート import "prismjs/components/prism-python"; +// Ruby言語定義をインポート +import "prismjs/components/prism-ruby"; -type PrismLang = "python"; +type PrismLang = "python" | "ruby"; function getPrismLanguage(language: RuntimeLang): PrismLang { switch (language) { case "python": return "python"; + case "ruby": + return "ruby"; case "cpp": throw new Error( `highlight for ${language} is disabled because it should not support REPL` diff --git a/app/terminal/ruby/page.tsx b/app/terminal/ruby/page.tsx new file mode 100644 index 0000000..a986738 --- /dev/null +++ b/app/terminal/ruby/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { EditorComponent } from "../editor"; +import { ExecFile } from "../exec"; +import { ReplTerminal } from "../repl"; + +export default function RubyPage() { + return ( +
+ > puts 'hello, world!'\nhello, world!"} + /> + + +
+ ); +} diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx new file mode 100644 index 0000000..022d41c --- /dev/null +++ b/app/terminal/ruby/runtime.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { + useState, + useRef, + useCallback, + ReactNode, + createContext, + useContext, + useEffect, +} from "react"; +import { SyntaxStatus, ReplOutput, ReplCommand } from "../repl"; +import { Mutex, MutexInterface } from "async-mutex"; +import { useEmbedContext } from "../embedContext"; +import { RuntimeContext } from "../runtime"; + +const RubyContext = createContext(null!); + +export function useRuby(): RuntimeContext { + const context = useContext(RubyContext); + if (!context) { + throw new Error("useRuby must be used within a RubyProvider"); + } + return context; +} + +type MessageToWorker = + | { + type: "init"; + payload: { RUBY_WASM_URL: string; interruptBuffer: Uint8Array }; + } + | { + type: "runRuby"; + payload: { code: string }; + } + | { + type: "checkSyntax"; + payload: { code: string }; + } + | { + type: "runFile"; + payload: { name: string; files: Record }; + }; + +type MessageFromWorker = + | { id: number; payload: unknown } + | { id: number; error: string }; + +type InitPayloadFromWorker = { success: boolean }; +type RunPayloadFromWorker = { + output: ReplOutput[]; + updatedFiles: [string, string][]; +}; +type StatusPayloadFromWorker = { status: SyntaxStatus }; + +export function RubyProvider({ children }: { children: ReactNode }) { + const workerRef = useRef(null); + const [ready, setReady] = useState(false); + const mutex = useRef(new Mutex()); + const { files, writeFile } = useEmbedContext(); + const messageCallbacks = useRef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Map void, (error: string) => void]> + >(new Map()); + const nextMessageId = useRef(0); + const interruptBuffer = useRef(null); + + function postMessage({ type, payload }: MessageToWorker) { + const id = nextMessageId.current++; + return new Promise((resolve, reject) => { + messageCallbacks.current.set(id, [resolve, reject]); + workerRef.current?.postMessage({ id, type, payload }); + }); + } + + useEffect(() => { + const worker = new Worker("/ruby.worker.js"); + workerRef.current = worker; + + interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); + + worker.onmessage = (event) => { + const data = event.data as MessageFromWorker; + if (messageCallbacks.current.has(data.id)) { + const [resolve, reject] = messageCallbacks.current.get(data.id)!; + if ("error" in data) { + reject(data.error); + } else { + resolve(data.payload); + } + messageCallbacks.current.delete(data.id); + } + }; + + // Use CDN URL for Ruby WASM with stdlib + const RUBY_WASM_URL = + "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; + + postMessage({ + type: "init", + payload: { RUBY_WASM_URL, interruptBuffer: interruptBuffer.current }, + }).then(({ success }) => { + if (success) { + setReady(true); + } + }); + + return () => { + workerRef.current?.terminate(); + }; + }, []); + + const interrupt = useCallback(() => { + if (interruptBuffer.current) { + interruptBuffer.current[0] = 2; + } + }, []); + + const runCommand = useCallback( + async (code: string): Promise => { + if (!mutex.current.isLocked()) { + throw new Error("mutex of RubyContext must be locked for runCommand"); + } + if (!workerRef.current || !ready) { + return [{ type: "error", message: "Ruby VM is not ready yet." }]; + } + + if (interruptBuffer.current) { + interruptBuffer.current[0] = 0; + } + + const { output, updatedFiles } = await postMessage({ + type: "runRuby", + payload: { code }, + }); + for (const [name, content] of updatedFiles) { + writeFile(name, content); + } + return output; + }, + [ready, writeFile] + ); + + const checkSyntax = useCallback( + async (code: string): Promise => { + if (!workerRef.current || !ready) return "invalid"; + const { status } = await mutex.current.runExclusive(() => + postMessage({ + type: "checkSyntax", + payload: { code }, + }) + ); + return status; + }, + [ready] + ); + + const runFiles = useCallback( + async (filenames: string[]): Promise => { + if (filenames.length !== 1) { + return [ + { + type: "error", + message: "Ruby execution requires exactly one filename", + }, + ]; + } + if (!workerRef.current || !ready) { + return [{ type: "error", message: "Ruby VM is not ready yet." }]; + } + if (interruptBuffer.current) { + interruptBuffer.current[0] = 0; + } + return mutex.current.runExclusive(async () => { + const { output, updatedFiles } = + await postMessage({ + type: "runFile", + payload: { name: filenames[0], files }, + }); + for (const [newName, content] of updatedFiles) { + writeFile(newName, content); + } + return output; + }); + }, + [files, ready, writeFile] + ); + + const splitReplExamples = useCallback((content: string): ReplCommand[] => { + const initCommands: { command: string; output: ReplOutput[] }[] = []; + for (const line of content.split("\n")) { + if (line.startsWith(">> ")) { + // Ruby IRB uses >> as the prompt + initCommands.push({ command: line.slice(3), output: [] }); + } else if (line.startsWith("?> ")) { + // Ruby IRB uses ?> for continuation + if (initCommands.length > 0) { + initCommands[initCommands.length - 1].command += "\n" + line.slice(3); + } + } else { + // Lines without prompt are output from the previous command + if (initCommands.length > 0) { + initCommands[initCommands.length - 1].output.push({ + type: "stdout", + message: line, + }); + } + } + } + return initCommands; + }, []); + + const getCommandlineStr = useCallback( + (filenames: string[]) => `ruby ${filenames[0]}`, + [] + ); + + return ( + + {children} + + ); +} diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 7292c19..117754c 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -1,6 +1,7 @@ import { MutexInterface } from "async-mutex"; import { ReplOutput, SyntaxStatus, ReplCommand } from "./repl"; import { PyodideProvider, usePyodide } from "./python/runtime"; +import { RubyProvider, useRuby } from "./ruby/runtime"; import { useWandbox, WandboxProvider } from "./wandbox/runtime"; import { AceLang } from "./editor"; import { ReactNode } from "react"; @@ -28,7 +29,7 @@ export interface LangConstants { prompt?: string; promptMore?: string; } -export type RuntimeLang = "python" | "cpp"; +export type RuntimeLang = "python" | "ruby" | "cpp"; export function getRuntimeLang( lang: string | undefined @@ -38,6 +39,9 @@ export function getRuntimeLang( case "python": case "py": return "python"; + case "ruby": + case "rb": + return "ruby"; case "cpp": case "c++": return "cpp"; @@ -49,11 +53,14 @@ export function getRuntimeLang( export function useRuntime(language: RuntimeLang): RuntimeContext { // すべての言語のcontextをインスタンス化 const pyodide = usePyodide(); + const ruby = useRuby(); const wandboxCpp = useWandbox("cpp"); switch (language) { case "python": return pyodide; + case "ruby": + return ruby; case "cpp": return wandboxCpp; default: @@ -64,7 +71,9 @@ export function useRuntime(language: RuntimeLang): RuntimeContext { export function RuntimeProvider({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } @@ -77,6 +86,12 @@ export function langConstants(lang: RuntimeLang | AceLang): LangConstants { prompt: ">>> ", promptMore: "... ", }; + case "ruby": + return { + tabSize: 2, + prompt: ">> ", + promptMore: "?> ", + }; case "c_cpp": case "cpp": return { diff --git a/package-lock.json b/package-lock.json index 403fe39..ead9a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/3.3-wasm-wasi": "^2.7.2", + "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -7646,6 +7648,12 @@ "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.3.0.tgz", + "integrity": "sha512-FlRBYttPRLcWORzBe6g8nmYTafBkOEFeOqMYM4tAHJzFsQy4+xJA94z85a9BCs8S+Uzfh9LrkpII7DXr2iUVFg==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", @@ -10290,6 +10298,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@ruby/3.3-wasm-wasi": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.7.2.tgz", + "integrity": "sha512-NTA/PqrnOKLhjseOD4JsC7LiaNgQ6FGw3OQxKPD4EoqSh0/odIp6clWlPtaforwIKAuxpV/rOdJ+fBb3USN7xQ==", + "license": "MIT", + "dependencies": { + "@ruby/wasm-wasi": "^2.0.0" + } + }, + "node_modules/@ruby/wasm-wasi": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@ruby/wasm-wasi/-/wasm-wasi-2.7.2.tgz", + "integrity": "sha512-KiVtLRFUC4V9hiiNV+ABzZL8tYQ0/nhBEQw9Lu+wj4zxCOw43RYcu+Yru7hwlb8ADppGRKYSv0CeDQqZ8OSqKw==", + "license": "MIT", + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.3.0", + "tslib": "^2.8.1" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", diff --git a/package.json b/package.json index 799139b..45135b1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", + "@ruby/3.3-wasm-wasi": "^2.7.2", + "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", diff --git a/public/ruby.worker.js b/public/ruby.worker.js new file mode 100644 index 0000000..4d26767 --- /dev/null +++ b/public/ruby.worker.js @@ -0,0 +1,310 @@ +// Ruby.wasm web worker +let rubyVM = null; +let rubyOutput = []; +let rubyModule = null; + +// Helper function to read all files from the virtual file system +function readAllFiles() { + if (!rubyVM) return []; + const updatedFiles = []; + + try { + // Get list of files in the home directory + const result = rubyVM.eval(` + require 'json' + files = {} + Dir.glob('*').each do |filename| + if File.file?(filename) + files[filename] = File.read(filename) + end + end + JSON.generate(files) + `); + const filesObj = JSON.parse(result.toString()); + for (const [filename, content] of Object.entries(filesObj)) { + updatedFiles.push([filename, content]); + } + } catch (e) { + console.error("Error reading files:", e); + } + + return updatedFiles; +} + +async function init(id, payload) { + const { RUBY_WASM_URL, interruptBuffer } = payload; + + if (!rubyVM) { + try { + // Import the browser WASI shim + importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); + + // Fetch and compile the Ruby WASM module + const response = await fetch(RUBY_WASM_URL); + const buffer = await response.arrayBuffer(); + rubyModule = await WebAssembly.compile(buffer); + + // Import the Ruby WASM runtime + // We need to use the browser WASI shim + const { WASI } = self.WASI || globalThis.WASI; + + // Create WASI instance + const wasi = new WASI([], [], [ + { + path: 'stdout', + write: (buf) => { + const text = new TextDecoder().decode(buf); + rubyOutput.push({ type: 'stdout', message: text }); + return buf.byteLength; + } + }, + { + path: 'stderr', + write: (buf) => { + const text = new TextDecoder().decode(buf); + rubyOutput.push({ type: 'stderr', message: text }); + return buf.byteLength; + } + } + ]); + + // Import the RubyVM from @ruby/wasm-wasi + importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); + const { DefaultRubyVM } = self.ruby; + + const { vm } = await DefaultRubyVM(rubyModule, { + consolePrint: false + }); + + rubyVM = vm; + + // Set up stdout/stderr capture + rubyVM.eval(` + class << $stdout + alias_method :original_write, :write + def write(str) + str + end + end + + class << $stderr + alias_method :original_write, :write + def write(str) + str + end + end + `); + + } catch (e) { + console.error("Failed to initialize Ruby VM:", e); + self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); + return; + } + } + + self.postMessage({ id, payload: { success: true } }); +} + +async function runRuby(id, payload) { + const { code } = payload; + + if (!rubyVM) { + self.postMessage({ id, error: "Ruby VM not initialized" }); + return; + } + + try { + // Capture output + rubyOutput = []; + + const result = rubyVM.eval(code); + const resultStr = result.toString(); + + // Add result to output if it's not nil + if (resultStr !== '' && resultStr !== 'nil') { + rubyOutput.push({ + type: 'return', + message: resultStr + }); + } + } catch (e) { + console.log(e); + if (e instanceof Error) { + // Clean up Ruby error messages + let errorMessage = e.message; + + // Remove internal Ruby traceback lines for cleaner output + if (errorMessage.includes('Traceback')) { + const lines = errorMessage.split('\n'); + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.includes(':') + ).join('\n').trim(); + } + + rubyOutput.push({ + type: 'error', + message: errorMessage + }); + } else { + rubyOutput.push({ + type: 'error', + message: `予期せぬエラー: ${String(e).trim()}` + }); + } + } + + const updatedFiles = readAllFiles(); + const output = [...rubyOutput]; + rubyOutput = []; + + self.postMessage({ + id, + payload: { output, updatedFiles } + }); +} + +async function runFile(id, payload) { + const { name, files } = payload; + + if (!rubyVM) { + self.postMessage({ id, error: "Ruby VM not initialized" }); + return; + } + + try { + rubyOutput = []; + + // Write files to the virtual file system + for (const [filename, content] of Object.entries(files)) { + if (content) { + rubyVM.eval(`File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})`); + } + } + + // Run the specified file + const fileContent = files[name]; + if (!fileContent) { + throw new Error(`File not found: ${name}`); + } + + rubyVM.eval(fileContent); + + } catch (e) { + console.log(e); + if (e instanceof Error) { + let errorMessage = e.message; + + if (errorMessage.includes('Traceback')) { + const lines = errorMessage.split('\n'); + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.includes(':') + ).join('\n').trim(); + } + + rubyOutput.push({ + type: 'error', + message: errorMessage + }); + } else { + rubyOutput.push({ + type: 'error', + message: `予期せぬエラー: ${String(e).trim()}` + }); + } + } + + const updatedFiles = readAllFiles(); + const output = [...rubyOutput]; + rubyOutput = []; + + self.postMessage({ + id, + payload: { output, updatedFiles } + }); +} + +async function checkSyntax(id, payload) { + const { code } = payload; + + if (!rubyVM) { + self.postMessage({ + id, + payload: { status: "invalid" } + }); + return; + } + + try { + // Try to parse the code to check syntax + // Ruby doesn't have a built-in compile_command like Python + // We'll use a simple heuristic + const trimmed = code.trim(); + + // Check for incomplete syntax patterns + const incompletePatterns = [ + /\bif\b.*(? pattern.test(trimmed))) { + self.postMessage({ id, payload: { status: "incomplete" } }); + return; + } + + // Try to compile/evaluate in check mode + try { + rubyVM.eval(`BEGIN { raise "check" }; ${code}`); + } catch (e) { + // If it's our check exception, syntax is valid + if (e.message && e.message.includes('check')) { + self.postMessage({ id, payload: { status: "complete" } }); + return; + } + // Otherwise it's a syntax error + self.postMessage({ id, payload: { status: "invalid" } }); + return; + } + + self.postMessage({ id, payload: { status: "complete" } }); + } catch (e) { + console.error("Syntax check error:", e); + self.postMessage({ + id, + payload: { status: "invalid" } + }); + } +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + + switch (type) { + case "init": + await init(id, payload); + return; + case "runRuby": + await runRuby(id, payload); + return; + case "runFile": + await runFile(id, payload); + return; + case "checkSyntax": + await checkSyntax(id, payload); + return; + default: + console.error(`Unknown message type: ${type}`); + return; + } +}; From 00f3e8507340f2b9f3a659aa54d8578efa6e268c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:14:15 +0000 Subject: [PATCH 03/11] Improve Ruby worker implementation with proper WASI stdout/stderr capture Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 6 +- public/ruby.worker.js | 137 ++++++++++++++++++++-------------- 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 022d41c..f1566c6 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -27,7 +27,7 @@ export function useRuby(): RuntimeContext { type MessageToWorker = | { type: "init"; - payload: { RUBY_WASM_URL: string; interruptBuffer: Uint8Array }; + payload: { RUBY_WASM_URL: string }; } | { type: "runRuby"; @@ -77,8 +77,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { const worker = new Worker("/ruby.worker.js"); workerRef.current = worker; - interruptBuffer.current = new Uint8Array(new SharedArrayBuffer(1)); - worker.onmessage = (event) => { const data = event.data as MessageFromWorker; if (messageCallbacks.current.has(data.id)) { @@ -98,7 +96,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { postMessage({ type: "init", - payload: { RUBY_WASM_URL, interruptBuffer: interruptBuffer.current }, + payload: { RUBY_WASM_URL }, }).then(({ success }) => { if (success) { setReady(true); diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 4d26767..0179420 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -1,7 +1,8 @@ // Ruby.wasm web worker let rubyVM = null; let rubyOutput = []; -let rubyModule = null; +let stdoutBuffer = ""; +let stderrBuffer = ""; // Helper function to read all files from the virtual file system function readAllFiles() { @@ -32,69 +33,57 @@ function readAllFiles() { } async function init(id, payload) { - const { RUBY_WASM_URL, interruptBuffer } = payload; + const { RUBY_WASM_URL } = payload; if (!rubyVM) { try { - // Import the browser WASI shim + // Import the browser WASI shim and Ruby runtime importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); + importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); // Fetch and compile the Ruby WASM module const response = await fetch(RUBY_WASM_URL); const buffer = await response.arrayBuffer(); - rubyModule = await WebAssembly.compile(buffer); + const rubyModule = await WebAssembly.compile(buffer); - // Import the Ruby WASM runtime - // We need to use the browser WASI shim - const { WASI } = self.WASI || globalThis.WASI; + const { DefaultRubyVM } = rubyWasmWasi; - // Create WASI instance - const wasi = new WASI([], [], [ + // Create custom WASI to capture stdout/stderr + const { WASI } = globalThis.WASI; + const fds = [ { - path: 'stdout', - write: (buf) => { - const text = new TextDecoder().decode(buf); - rubyOutput.push({ type: 'stdout', message: text }); - return buf.byteLength; - } + // stdin + path: "stdin", + read: () => new Uint8Array([]), }, { - path: 'stderr', - write: (buf) => { - const text = new TextDecoder().decode(buf); - rubyOutput.push({ type: 'stderr', message: text }); - return buf.byteLength; - } - } - ]); - - // Import the RubyVM from @ruby/wasm-wasi - importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); - const { DefaultRubyVM } = self.ruby; + // stdout + path: "stdout", + write: (buffer) => { + const text = new TextDecoder().decode(buffer); + stdoutBuffer += text; + return buffer.byteLength; + }, + }, + { + // stderr + path: "stderr", + write: (buffer) => { + const text = new TextDecoder().decode(buffer); + stderrBuffer += text; + return buffer.byteLength; + }, + }, + ]; - const { vm } = await DefaultRubyVM(rubyModule, { - consolePrint: false + const wasi = new WASI([], [], fds); + const { vm } = await rubyWasmWasi.RubyVM.instantiateModule({ + module: rubyModule, + wasip1: wasi, }); rubyVM = vm; - // Set up stdout/stderr capture - rubyVM.eval(` - class << $stdout - alias_method :original_write, :write - def write(str) - str - end - end - - class << $stderr - alias_method :original_write, :write - def write(str) - str - end - end - `); - } catch (e) { console.error("Failed to initialize Ruby VM:", e); self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); @@ -105,6 +94,32 @@ async function init(id, payload) { self.postMessage({ id, payload: { success: true } }); } +function flushOutput() { + if (stdoutBuffer) { + const lines = stdoutBuffer.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + rubyOutput.push({ type: 'stdout', message: lines[i] }); + } + stdoutBuffer = lines[lines.length - 1]; + } + if (stderrBuffer) { + const lines = stderrBuffer.split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + rubyOutput.push({ type: 'stderr', message: lines[i] }); + } + stderrBuffer = lines[lines.length - 1]; + } + // Final flush if there's remaining text + if (stdoutBuffer) { + rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); + stdoutBuffer = ""; + } + if (stderrBuffer) { + rubyOutput.push({ type: 'stderr', message: stderrBuffer }); + stderrBuffer = ""; + } +} + async function runRuby(id, payload) { const { code } = payload; @@ -114,13 +129,18 @@ async function runRuby(id, payload) { } try { - // Capture output rubyOutput = []; + stdoutBuffer = ""; + stderrBuffer = ""; const result = rubyVM.eval(code); + + // Flush any buffered output + flushOutput(); + const resultStr = result.toString(); - // Add result to output if it's not nil + // Add result to output if it's not nil and not empty if (resultStr !== '' && resultStr !== 'nil') { rubyOutput.push({ type: 'return', @@ -129,17 +149,19 @@ async function runRuby(id, payload) { } } catch (e) { console.log(e); + flushOutput(); + if (e instanceof Error) { - // Clean up Ruby error messages let errorMessage = e.message; - // Remove internal Ruby traceback lines for cleaner output - if (errorMessage.includes('Traceback')) { + // Clean up Ruby error messages + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { const lines = errorMessage.split('\n'); + // Filter out internal lines errorMessage = lines.filter(line => !line.includes('(eval)') || line.includes('Error') || - line.includes(':') + line.match(/:\d+:/) ).join('\n').trim(); } @@ -175,6 +197,8 @@ async function runFile(id, payload) { try { rubyOutput = []; + stdoutBuffer = ""; + stderrBuffer = ""; // Write files to the virtual file system for (const [filename, content] of Object.entries(files)) { @@ -191,17 +215,22 @@ async function runFile(id, payload) { rubyVM.eval(fileContent); + // Flush any buffered output + flushOutput(); + } catch (e) { console.log(e); + flushOutput(); + if (e instanceof Error) { let errorMessage = e.message; - if (errorMessage.includes('Traceback')) { + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { const lines = errorMessage.split('\n'); errorMessage = lines.filter(line => !line.includes('(eval)') || line.includes('Error') || - line.includes(':') + line.match(/:\d+:/) ).join('\n').trim(); } From 0b686e97066c1f9f08a110596c3a8bfa0834aeff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:18:43 +0000 Subject: [PATCH 04/11] Address code review feedback - fix interrupt buffer and error handling Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 14 ++---- public/ruby.worker.js | 86 ++++++++++++++--------------------- 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index f1566c6..18b71d1 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -63,7 +63,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { Map void, (error: string) => void]> >(new Map()); const nextMessageId = useRef(0); - const interruptBuffer = useRef(null); function postMessage({ type, payload }: MessageToWorker) { const id = nextMessageId.current++; @@ -109,9 +108,9 @@ export function RubyProvider({ children }: { children: ReactNode }) { }, []); const interrupt = useCallback(() => { - if (interruptBuffer.current) { - interruptBuffer.current[0] = 2; - } + // TODO: Implement interrupt functionality for Ruby + // Ruby WASM doesn't currently support interrupts like Pyodide does + console.warn("Ruby interrupt is not yet implemented"); }, []); const runCommand = useCallback( @@ -123,10 +122,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { return [{ type: "error", message: "Ruby VM is not ready yet." }]; } - if (interruptBuffer.current) { - interruptBuffer.current[0] = 0; - } - const { output, updatedFiles } = await postMessage({ type: "runRuby", payload: { code }, @@ -166,9 +161,6 @@ export function RubyProvider({ children }: { children: ReactNode }) { if (!workerRef.current || !ready) { return [{ type: "error", message: "Ruby VM is not ready yet." }]; } - if (interruptBuffer.current) { - interruptBuffer.current[0] = 0; - } return mutex.current.runExclusive(async () => { const { output, updatedFiles } = await postMessage({ diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 0179420..4143058 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -109,15 +109,37 @@ function flushOutput() { } stderrBuffer = lines[lines.length - 1]; } - // Final flush if there's remaining text - if (stdoutBuffer) { + // Final flush if there's remaining non-empty text + if (stdoutBuffer && stdoutBuffer.trim()) { rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); - stdoutBuffer = ""; } - if (stderrBuffer) { + stdoutBuffer = ""; + + if (stderrBuffer && stderrBuffer.trim()) { rubyOutput.push({ type: 'stderr', message: stderrBuffer }); - stderrBuffer = ""; } + stderrBuffer = ""; +} + +function formatRubyError(error) { + if (!(error instanceof Error)) { + return `予期せぬエラー: ${String(error).trim()}`; + } + + let errorMessage = error.message; + + // Clean up Ruby error messages by filtering out internal eval lines + if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { + const lines = errorMessage.split('\n'); + // Keep lines that either don't contain (eval), or contain Error, or have line numbers + errorMessage = lines.filter(line => + !line.includes('(eval)') || + line.includes('Error') || + line.match(/:\d+:/) + ).join('\n').trim(); + } + + return errorMessage; } async function runRuby(id, payload) { @@ -151,30 +173,10 @@ async function runRuby(id, payload) { console.log(e); flushOutput(); - if (e instanceof Error) { - let errorMessage = e.message; - - // Clean up Ruby error messages - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - // Filter out internal lines - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); - } - - rubyOutput.push({ - type: 'error', - message: errorMessage - }); - } else { - rubyOutput.push({ - type: 'error', - message: `予期せぬエラー: ${String(e).trim()}` - }); - } + rubyOutput.push({ + type: 'error', + message: formatRubyError(e) + }); } const updatedFiles = readAllFiles(); @@ -222,28 +224,10 @@ async function runFile(id, payload) { console.log(e); flushOutput(); - if (e instanceof Error) { - let errorMessage = e.message; - - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); - } - - rubyOutput.push({ - type: 'error', - message: errorMessage - }); - } else { - rubyOutput.push({ - type: 'error', - message: `予期せぬエラー: ${String(e).trim()}` - }); - } + rubyOutput.push({ + type: 'error', + message: formatRubyError(e) + }); } const updatedFiles = readAllFiles(); From bb921eff5738687a758df4d95a83de7a874fda3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:05:09 +0000 Subject: [PATCH 05/11] Implement interrupt with command history and VM restart Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/ruby/runtime.tsx | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 18b71d1..75970c4 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -63,6 +63,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { Map void, (error: string) => void]> >(new Map()); const nextMessageId = useRef(0); + const commandHistory = useRef([]); function postMessage({ type, payload }: MessageToWorker) { const id = nextMessageId.current++; @@ -72,7 +73,7 @@ export function RubyProvider({ children }: { children: ReactNode }) { }); } - useEffect(() => { + const initializeWorker = useCallback(() => { const worker = new Worker("/ruby.worker.js"); workerRef.current = worker; @@ -93,10 +94,14 @@ export function RubyProvider({ children }: { children: ReactNode }) { const RUBY_WASM_URL = "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; - postMessage({ + return postMessage({ type: "init", payload: { RUBY_WASM_URL }, - }).then(({ success }) => { + }); + }, []); + + useEffect(() => { + initializeWorker().then(({ success }) => { if (success) { setReady(true); } @@ -105,13 +110,35 @@ export function RubyProvider({ children }: { children: ReactNode }) { return () => { workerRef.current?.terminate(); }; - }, []); + }, [initializeWorker]); - const interrupt = useCallback(() => { - // TODO: Implement interrupt functionality for Ruby - // Ruby WASM doesn't currently support interrupts like Pyodide does - console.warn("Ruby interrupt is not yet implemented"); - }, []); + const interrupt = useCallback(async () => { + // Terminate the current worker + if (workerRef.current) { + workerRef.current.terminate(); + } + + // Mark as not ready during reinitialization + setReady(false); + + // Reinitialize the worker + const { success } = await initializeWorker(); + + if (success) { + // Re-execute all saved commands to restore state + for (const cmd of commandHistory.current) { + try { + await postMessage({ + type: "runRuby", + payload: { code: cmd }, + }); + } catch (e) { + console.error("Error restoring command:", cmd, e); + } + } + setReady(true); + } + }, [initializeWorker]); const runCommand = useCallback( async (code: string): Promise => { @@ -126,6 +153,14 @@ export function RubyProvider({ children }: { children: ReactNode }) { type: "runRuby", payload: { code }, }); + + // Check if the command succeeded (no errors) + const hasError = output.some((o) => o.type === "error"); + if (!hasError) { + // Save successful command to history + commandHistory.current.push(code); + } + for (const [name, content] of updatedFiles) { writeFile(name, content); } From 6fd45b749932565d8aae78649ab05980294adce1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:09:34 +0000 Subject: [PATCH 06/11] Remove unused Ruby npm dependencies (loaded from CDN instead) Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- package-lock.json | 27 --------------------------- package.json | 2 -- 2 files changed, 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index ead9a1d..403fe39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,6 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", - "@ruby/3.3-wasm-wasi": "^2.7.2", - "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", @@ -7648,12 +7646,6 @@ "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" }, - "node_modules/@bjorn3/browser_wasi_shim": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.3.0.tgz", - "integrity": "sha512-FlRBYttPRLcWORzBe6g8nmYTafBkOEFeOqMYM4tAHJzFsQy4+xJA94z85a9BCs8S+Uzfh9LrkpII7DXr2iUVFg==", - "license": "MIT OR Apache-2.0" - }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", @@ -10298,25 +10290,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ruby/3.3-wasm-wasi": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.7.2.tgz", - "integrity": "sha512-NTA/PqrnOKLhjseOD4JsC7LiaNgQ6FGw3OQxKPD4EoqSh0/odIp6clWlPtaforwIKAuxpV/rOdJ+fBb3USN7xQ==", - "license": "MIT", - "dependencies": { - "@ruby/wasm-wasi": "^2.0.0" - } - }, - "node_modules/@ruby/wasm-wasi": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@ruby/wasm-wasi/-/wasm-wasi-2.7.2.tgz", - "integrity": "sha512-KiVtLRFUC4V9hiiNV+ABzZL8tYQ0/nhBEQw9Lu+wj4zxCOw43RYcu+Yru7hwlb8ADppGRKYSv0CeDQqZ8OSqKw==", - "license": "MIT", - "dependencies": { - "@bjorn3/browser_wasi_shim": "^0.3.0", - "tslib": "^2.8.1" - } - }, "node_modules/@rushstack/eslint-patch": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", diff --git a/package.json b/package.json index 45135b1..799139b 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "@fontsource-variable/noto-sans-jp": "^5.2.6", "@google/genai": "^1.21.0", "@opennextjs/cloudflare": "^1.7.1", - "@ruby/3.3-wasm-wasi": "^2.7.2", - "@ruby/wasm-wasi": "^2.7.2", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", "ace-builds": "^1.43.2", From 43d5db46f588f8239306b03765c34821a8dde2c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:43:59 +0000 Subject: [PATCH 07/11] Add Ruby runtime tests to test suite Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/page.tsx | 3 +++ app/terminal/tests.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index 857523b..4bfd2d2 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -4,6 +4,7 @@ import "mocha/mocha.js"; import "mocha/mocha.css"; import { useEffect, useRef, useState } from "react"; import { usePyodide } from "./python/runtime"; +import { useRuby } from "./ruby/runtime"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; import { useEmbedContext } from "./embedContext"; @@ -11,10 +12,12 @@ import { defineTests } from "./tests"; export default function RuntimeTestPage() { const pyodide = usePyodide(); + const ruby = useRuby(); const wandboxCpp = useWandbox("cpp"); const runtimeRef = useRef>(null!); runtimeRef.current = { python: pyodide, + ruby: ruby, cpp: wandboxCpp, }; const { files, writeFile } = useEmbedContext(); diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 82620ae..1233723 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -12,6 +12,7 @@ export function defineTests( ( { python: 2000, + ruby: 2000, cpp: 10000, } as Record )[lang] @@ -30,6 +31,7 @@ export function defineTests( const printCode = ( { python: `print("${msg}")`, + ruby: `puts "${msg}"`, cpp: null, } satisfies Record )[lang]; @@ -54,6 +56,7 @@ export function defineTests( const [setIntVarCode, printIntVarCode] = ( { python: [`${varName} = ${value}`, `print(${varName})`], + ruby: [`${varName} = ${value}`, `puts ${varName}`], cpp: [null, null], } satisfies Record )[lang]; @@ -80,6 +83,7 @@ export function defineTests( const errorCode = ( { python: `raise Exception("${errorMsg}")`, + ruby: `raise "${errorMsg}"`, cpp: null, } satisfies Record )[lang]; @@ -99,6 +103,7 @@ export function defineTests( const [setIntVarCode, infLoopCode, printIntVarCode] = ( { python: [`testVar = 42`, `while True:\n pass`, `print(testVar)`], + ruby: [`testVar = 42`, `loop do\nend`, `puts testVar`], cpp: [null, null, null], } satisfies Record )[lang]; @@ -132,6 +137,7 @@ export function defineTests( const [filename, code] = ( { python: ["test.py", `print("${msg}")`], + ruby: ["test.rb", `puts "${msg}"`], cpp: [ "test.cpp", `#include \nint main() {\n std::cout << "${msg}" << std::endl;\n return 0;\n}\n`, @@ -156,6 +162,7 @@ export function defineTests( const [filename, code] = ( { python: ["test_error.py", `raise Exception("${errorMsg}")\n`], + ruby: ["test_error.rb", `raise "${errorMsg}"\n`], cpp: [ "test_error.cpp", `#include \nint main() {\n throw std::runtime_error("${errorMsg}");\n return 0;\n}\n`, @@ -183,6 +190,14 @@ export function defineTests( }, ["test_multi_main.py"], ], + ruby: [ + { + "test_multi_main.rb": + "require_relative 'test_multi_sub'\nprint_message\n", + "test_multi_sub.rb": `def print_message\n puts "${msg}"\nend\n`, + }, + ["test_multi_main.rb"], + ], cpp: [ { "test_multi_main.cpp": From e0eaed0eef1ebc77f29d40127d61c517bdfd49f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:47:00 +0000 Subject: [PATCH 08/11] Add Ruby worker headers configuration Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- public/_headers | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/_headers b/public/_headers index 2472c04..c88c393 100644 --- a/public/_headers +++ b/public/_headers @@ -3,3 +3,6 @@ /pyodide.worker.js Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp +/ruby.worker.js + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp From bba92bd4f865b3645320387c1edaadd50c794307 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:02:53 +0900 Subject: [PATCH 09/11] =?UTF-8?q?ruby=20runtime=E3=81=AE=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E3=82=92=E4=BF=AE=E6=AD=A3,=20wasip1=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E6=B6=88=E3=81=97defaultrubyvm=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/ruby/runtime.tsx | 65 +++++--- app/terminal/tests.ts | 25 +++- public/ruby.worker.js | 272 +++++++++++++++++----------------- 3 files changed, 192 insertions(+), 170 deletions(-) diff --git a/app/terminal/ruby/runtime.tsx b/app/terminal/ruby/runtime.tsx index 75970c4..716b09b 100644 --- a/app/terminal/ruby/runtime.tsx +++ b/app/terminal/ruby/runtime.tsx @@ -27,7 +27,7 @@ export function useRuby(): RuntimeContext { type MessageToWorker = | { type: "init"; - payload: { RUBY_WASM_URL: string }; + payload: {}; } | { type: "runRuby"; @@ -90,13 +90,9 @@ export function RubyProvider({ children }: { children: ReactNode }) { } }; - // Use CDN URL for Ruby WASM with stdlib - const RUBY_WASM_URL = - "https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; - return postMessage({ type: "init", - payload: { RUBY_WASM_URL }, + payload: {}, }); }, []); @@ -112,32 +108,39 @@ export function RubyProvider({ children }: { children: ReactNode }) { }; }, [initializeWorker]); - const interrupt = useCallback(async () => { + const interrupt = useCallback(() => { // Terminate the current worker if (workerRef.current) { workerRef.current.terminate(); } + // reject all pending messages + for (const [, [, reject]] of messageCallbacks.current) { + reject("Execution interrupted"); + } + // Mark as not ready during reinitialization setReady(false); - // Reinitialize the worker - const { success } = await initializeWorker(); - - if (success) { - // Re-execute all saved commands to restore state - for (const cmd of commandHistory.current) { - try { - await postMessage({ - type: "runRuby", - payload: { code: cmd }, - }); - } catch (e) { - console.error("Error restoring command:", cmd, e); + void mutex.current.runExclusive(async () => { + // Reinitialize the worker + const { success } = await initializeWorker(); + + if (success) { + // Re-execute all saved commands to restore state + for (const cmd of commandHistory.current) { + try { + await postMessage({ + type: "runRuby", + payload: { code: cmd }, + }); + } catch (e) { + console.error("Error restoring command:", cmd, e); + } } + setReady(true); } - setReady(true); - } + }); }, [initializeWorker]); const runCommand = useCallback( @@ -152,15 +155,22 @@ export function RubyProvider({ children }: { children: ReactNode }) { const { output, updatedFiles } = await postMessage({ type: "runRuby", payload: { code }, + }).catch((error) => { + return { + output: [ + { type: "error", message: `Execution error: ${error}` }, + ] as ReplOutput[], + updatedFiles: [] as [string, string][], + }; }); - + // Check if the command succeeded (no errors) const hasError = output.some((o) => o.type === "error"); if (!hasError) { // Save successful command to history commandHistory.current.push(code); } - + for (const [name, content] of updatedFiles) { writeFile(name, content); } @@ -201,6 +211,13 @@ export function RubyProvider({ children }: { children: ReactNode }) { await postMessage({ type: "runFile", payload: { name: filenames[0], files }, + }).catch((error) => { + return { + output: [ + { type: "error", message: `Execution error: ${error}` }, + ] as ReplOutput[], + updatedFiles: [] as [string, string][], + }; }); for (const [newName, content] of updatedFiles) { writeFile(newName, content); diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 1233723..8cf6fae 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -12,7 +12,7 @@ export function defineTests( ( { python: 2000, - ruby: 2000, + ruby: 5000, cpp: 10000, } as Record )[lang] @@ -110,17 +110,26 @@ export function defineTests( if (!setIntVarCode || !infLoopCode || !printIntVarCode) { this.skip(); } - const result = await ( + const runPromise = ( runtimeRef.current[lang].mutex || emptyMutex ).runExclusive(async () => { await runtimeRef.current[lang].runCommand!(setIntVarCode); - const runPromise = runtimeRef.current[lang].runCommand!(infLoopCode); - // Wait a bit to ensure the infinite loop has started - await new Promise((resolve) => setTimeout(resolve, 1000)); - runtimeRef.current[lang].interrupt!(); - await runPromise; - return runtimeRef.current[lang].runCommand!(printIntVarCode); + return runtimeRef.current[lang].runCommand!(infLoopCode); }); + // Wait a bit to ensure the infinite loop has started + await new Promise((resolve) => setTimeout(resolve, 1000)); + runtimeRef.current[lang].interrupt!(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await runPromise; + while (!runtimeRef.current[lang].ready) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + const result = await ( + runtimeRef.current[lang].mutex || emptyMutex + ).runExclusive(() => + runtimeRef.current[lang].runCommand!(printIntVarCode) + ); console.log(`${lang} REPL interrupt recovery test: `, result); expect(result).to.be.deep.equal([ { diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 4143058..286fecf 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -4,119 +4,87 @@ let rubyOutput = []; let stdoutBuffer = ""; let stderrBuffer = ""; -// Helper function to read all files from the virtual file system -function readAllFiles() { - if (!rubyVM) return []; - const updatedFiles = []; - - try { - // Get list of files in the home directory - const result = rubyVM.eval(` - require 'json' - files = {} - Dir.glob('*').each do |filename| - if File.file?(filename) - files[filename] = File.read(filename) - end - end - JSON.generate(files) - `); - const filesObj = JSON.parse(result.toString()); - for (const [filename, content] of Object.entries(filesObj)) { - updatedFiles.push([filename, content]); - } - } catch (e) { - console.error("Error reading files:", e); - } - - return updatedFiles; -} +const RUBY_JS_URL = + "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js"; +const RUBY_WASM_URL = + "https://cdn.jsdelivr.net/npm/@ruby/3.4-wasm-wasi@2.7.2/dist/ruby+stdlib.wasm"; + +globalThis.stdout = { + write(str) { + stdoutBuffer += str; + }, +}; +globalThis.stderr = { + write(str) { + stderrBuffer += str; + }, +}; async function init(id, payload) { - const { RUBY_WASM_URL } = payload; - + // const { } = payload; + if (!rubyVM) { try { - // Import the browser WASI shim and Ruby runtime - importScripts('https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js'); - importScripts('https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.2/dist/browser.umd.js'); - + importScripts(RUBY_JS_URL); + // Fetch and compile the Ruby WASM module - const response = await fetch(RUBY_WASM_URL); - const buffer = await response.arrayBuffer(); - const rubyModule = await WebAssembly.compile(buffer); - - const { DefaultRubyVM } = rubyWasmWasi; - - // Create custom WASI to capture stdout/stderr - const { WASI } = globalThis.WASI; - const fds = [ - { - // stdin - path: "stdin", - read: () => new Uint8Array([]), - }, - { - // stdout - path: "stdout", - write: (buffer) => { - const text = new TextDecoder().decode(buffer); - stdoutBuffer += text; - return buffer.byteLength; - }, - }, - { - // stderr - path: "stderr", - write: (buffer) => { - const text = new TextDecoder().decode(buffer); - stderrBuffer += text; - return buffer.byteLength; - }, - }, - ]; - - const wasi = new WASI([], [], fds); - const { vm } = await rubyWasmWasi.RubyVM.instantiateModule({ - module: rubyModule, - wasip1: wasi, - }); - + const rubyModule = await WebAssembly.compileStreaming( + await fetch(RUBY_WASM_URL) + ); + const { DefaultRubyVM } = globalThis["ruby-wasm-wasi"]; + const { vm } = await DefaultRubyVM(rubyModule); rubyVM = vm; - + + rubyVM.eval(` +$stdout = Object.new.tap do |obj| + def obj.write(str) + require "js" + JS.global[:stdout].write(str) + end +end +$stderr = Object.new.tap do |obj| + def obj.write(str) + require "js" + JS.global[:stderr].write(str) + end +end +`); } catch (e) { console.error("Failed to initialize Ruby VM:", e); - self.postMessage({ id, error: `Failed to initialize Ruby: ${e.message}` }); + self.postMessage({ + id, + error: `Failed to initialize Ruby: ${e.message}`, + }); return; } } - + self.postMessage({ id, payload: { success: true } }); } function flushOutput() { if (stdoutBuffer) { - const lines = stdoutBuffer.split('\n'); + const lines = stdoutBuffer.split("\n"); for (let i = 0; i < lines.length - 1; i++) { - rubyOutput.push({ type: 'stdout', message: lines[i] }); + rubyOutput.push({ type: "stdout", message: lines[i] }); } stdoutBuffer = lines[lines.length - 1]; } + // Final flush if there's remaining non-empty text + if (stdoutBuffer && stdoutBuffer.trim()) { + rubyOutput.push({ type: "stdout", message: stdoutBuffer }); + } + stdoutBuffer = ""; + if (stderrBuffer) { - const lines = stderrBuffer.split('\n'); + const lines = stderrBuffer.split("\n"); for (let i = 0; i < lines.length - 1; i++) { - rubyOutput.push({ type: 'stderr', message: lines[i] }); + rubyOutput.push({ type: "stderr", message: lines[i] }); } stderrBuffer = lines[lines.length - 1]; } - // Final flush if there's remaining non-empty text - if (stdoutBuffer && stdoutBuffer.trim()) { - rubyOutput.push({ type: 'stdout', message: stdoutBuffer }); - } - stdoutBuffer = ""; - if (stderrBuffer && stderrBuffer.trim()) { - rubyOutput.push({ type: 'stderr', message: stderrBuffer }); + rubyOutput.push({ type: "stderr", message: stderrBuffer }); } stderrBuffer = ""; } @@ -125,138 +93,138 @@ function formatRubyError(error) { if (!(error instanceof Error)) { return `予期せぬエラー: ${String(error).trim()}`; } - + let errorMessage = error.message; - + // Clean up Ruby error messages by filtering out internal eval lines - if (errorMessage.includes('Traceback') || errorMessage.includes('Error')) { - const lines = errorMessage.split('\n'); - // Keep lines that either don't contain (eval), or contain Error, or have line numbers - errorMessage = lines.filter(line => - !line.includes('(eval)') || - line.includes('Error') || - line.match(/:\d+:/) - ).join('\n').trim(); + if (errorMessage.includes("Traceback") || errorMessage.includes("Error")) { + const lines = errorMessage.split("\n"); + // Keep lines that either don't contain eval, or contain Error, or have line numbers + errorMessage = lines + .filter((line) => !line.includes("in 'Kernel.eval'")) + .join("\n") + .trim(); } - + return errorMessage; } async function runRuby(id, payload) { const { code } = payload; - + if (!rubyVM) { self.postMessage({ id, error: "Ruby VM not initialized" }); return; } - + try { rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; - + const result = rubyVM.eval(code); - + // Flush any buffered output flushOutput(); - + const resultStr = result.toString(); - + // Add result to output if it's not nil and not empty - if (resultStr !== '' && resultStr !== 'nil') { + if (resultStr !== "" && resultStr !== "nil") { rubyOutput.push({ - type: 'return', - message: resultStr + type: "return", + message: resultStr, }); } } catch (e) { console.log(e); flushOutput(); - + rubyOutput.push({ - type: 'error', - message: formatRubyError(e) + type: "error", + message: formatRubyError(e), }); } - + const updatedFiles = readAllFiles(); const output = [...rubyOutput]; rubyOutput = []; - + self.postMessage({ id, - payload: { output, updatedFiles } + payload: { output, updatedFiles }, }); } async function runFile(id, payload) { const { name, files } = payload; - + if (!rubyVM) { self.postMessage({ id, error: "Ruby VM not initialized" }); return; } - + try { rubyOutput = []; stdoutBuffer = ""; stderrBuffer = ""; - + // Write files to the virtual file system for (const [filename, content] of Object.entries(files)) { if (content) { - rubyVM.eval(`File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})`); + rubyVM.eval( + `File.write(${JSON.stringify(filename)}, ${JSON.stringify(content)})` + ); } } - + // Run the specified file const fileContent = files[name]; if (!fileContent) { throw new Error(`File not found: ${name}`); } - + rubyVM.eval(fileContent); - + // Flush any buffered output flushOutput(); - } catch (e) { console.log(e); flushOutput(); - + rubyOutput.push({ - type: 'error', - message: formatRubyError(e) + type: "error", + message: formatRubyError(e), }); } - + const updatedFiles = readAllFiles(); const output = [...rubyOutput]; rubyOutput = []; - + self.postMessage({ id, - payload: { output, updatedFiles } + payload: { output, updatedFiles }, }); } async function checkSyntax(id, payload) { const { code } = payload; - + if (!rubyVM) { self.postMessage({ id, - payload: { status: "invalid" } + payload: { status: "invalid" }, }); return; } - + try { // Try to parse the code to check syntax // Ruby doesn't have a built-in compile_command like Python // We'll use a simple heuristic const trimmed = code.trim(); - + // Check for incomplete syntax patterns const incompletePatterns = [ /\bif\b.*(? pattern.test(trimmed))) { + if (incompletePatterns.some((pattern) => pattern.test(trimmed))) { self.postMessage({ id, payload: { status: "incomplete" } }); return; } - + // Try to compile/evaluate in check mode try { rubyVM.eval(`BEGIN { raise "check" }; ${code}`); } catch (e) { // If it's our check exception, syntax is valid - if (e.message && e.message.includes('check')) { + if (e.message && e.message.includes("check")) { self.postMessage({ id, payload: { status: "complete" } }); return; } @@ -289,20 +257,48 @@ async function checkSyntax(id, payload) { self.postMessage({ id, payload: { status: "invalid" } }); return; } - + self.postMessage({ id, payload: { status: "complete" } }); } catch (e) { console.error("Syntax check error:", e); self.postMessage({ id, - payload: { status: "invalid" } + payload: { status: "invalid" }, }); } } +// Helper function to read all files from the virtual file system +function readAllFiles() { + if (!rubyVM) return []; + const updatedFiles = []; + + try { + // Get list of files in the home directory + const result = rubyVM.eval(` + require 'json' + files = {} + Dir.glob('*').each do |filename| + if File.file?(filename) + files[filename] = File.read(filename) + end + end + JSON.generate(files) + `); + const filesObj = JSON.parse(result.toString()); + for (const [filename, content] of Object.entries(filesObj)) { + updatedFiles.push([filename, content]); + } + } catch (e) { + console.error("Error reading files:", e); + } + + return updatedFiles; +} + self.onmessage = async (event) => { const { id, type, payload } = event.data; - + switch (type) { case "init": await init(id, payload); From 6dc8c9f892368d276746f8165a32f94383efb0b6 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:04:54 +0900 Subject: [PATCH 10/11] =?UTF-8?q?eval=E8=A1=8C=E6=B6=88=E3=81=95=E3=81=AA?= =?UTF-8?q?=E3=81=8F=E3=81=A6=E3=81=84=E3=81=84=E3=82=88=E3=81=86=E3=81=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ruby.worker.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/public/ruby.worker.js b/public/ruby.worker.js index 286fecf..e4d2522 100644 --- a/public/ruby.worker.js +++ b/public/ruby.worker.js @@ -94,19 +94,7 @@ function formatRubyError(error) { return `予期せぬエラー: ${String(error).trim()}`; } - let errorMessage = error.message; - - // Clean up Ruby error messages by filtering out internal eval lines - if (errorMessage.includes("Traceback") || errorMessage.includes("Error")) { - const lines = errorMessage.split("\n"); - // Keep lines that either don't contain eval, or contain Error, or have line numbers - errorMessage = lines - .filter((line) => !line.includes("in 'Kernel.eval'")) - .join("\n") - .trim(); - } - - return errorMessage; + return error.message; } async function runRuby(id, payload) { From 8b04d28fc03e650df717a2f64bc25839996e8355 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:26:15 +0900 Subject: [PATCH 11/11] =?UTF-8?q?interrupt()=E3=81=AEmutex=E3=81=AB?= =?UTF-8?q?=E3=81=A4=E3=81=84=E3=81=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/terminal/README.md b/app/terminal/README.md index 9e44cd4..f0044d1 100644 --- a/app/terminal/README.md +++ b/app/terminal/README.md @@ -14,8 +14,10 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * ランタイムの初期化が完了したか、不要である場合true * mutex?: `MutexInterface` * ランタイムに排他制御が必要な場合、MutexInterfaceのインスタンスを返してください。 -* interrupt?: `() => Promise` - * 実行中のコマンドを中断します。呼び出し側でmutexのロックはされません +* interrupt?: `() => void` + * 実行中のコマンドを中断します。 + * 呼び出し側でmutexのロックはしません。interrupt()を呼ぶ際にはrunCommand()やrunFiles()が実行中であるためmutexはすでにロックされているはずです。 + * interrupt()内で実行中の処理のPromiseをrejectしたあと、runtimeを再開する際の処理に必要であればmutexをロックすることも可能です。 ### REPL用 @@ -25,7 +27,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * checkSyntax?: `(code: string) => Promise` * コードの構文チェックを行います。行がコマンドとして完結していれば`complete`、次の行に続く場合(if文の条件式の途中など)は`incomplete`を返してください。 * REPLでEnterを押した際の動作に影響します。 - * 呼び出し側でmutexのロックはされません + * 呼び出し側でmutexのロックはせず、必要であればcheckSyntax()内でロックします。 * splitReplExamples?: `(code: string) => ReplCommands[]` * markdown内に記述されているREPLのサンプルコードをパースします。例えば ``` @@ -51,7 +53,7 @@ runtime.tsx の `useRuntime(lang)` は各言語のフックを呼び出し、そ * runFiles: `(filenames: string[]) => Promise` * 指定されたファイルを実行します。ファイルの中身はEmbedContextから取得されます。 - * 呼び出し側でmutexのロックはされません + * 呼び出し側でmutexのロックはせず、必要であればrunFiles()内でロックします。 * getCommandlineStr: `(filenames: string[]) => string` * 指定されたファイルを実行するためのコマンドライン引数文字列を返します。表示用です。