From c5cc6e99f1f5bfa2ca2dcaadc42f9d568da3bcf3 Mon Sep 17 00:00:00 2001 From: Kazuhiro NISHIYAMA Date: Mon, 13 Jul 2026 10:37:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AB(RUN)=E3=82=92=20Web=20Work?= =?UTF-8?q?er=20=E5=8C=96=E3=81=97=E3=81=A6=E5=81=9C=E6=AD=A2=E3=83=BB?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=82=A2=E3=82=A6=E3=83=88=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUN ボタンは ruby.wasm をメインスレッドで同期実行しており、無限ループや 長い sleep を含むサンプルではタブの UI が完全に固まっていた(fix #239)。 - 実行を新設の theme/default/js/run-worker.js (module Worker) に移動。 wasm のコンパイルはこれまでどおりメインスレッド側で一度だけ行いキャッシュ し、WebAssembly.Module を実行ごとに使い捨ての Worker へ postMessage で 渡す。VM は実行のたびに作り直すので、terminate() 後の再実行やコンパイル やり直しの問題は生じない - 実行中は RUN ボタンを STOP に切り替え、クリックで Worker.terminate() して中断できるようにした。30 秒(RUN_TIMEOUT_MS)経過でも自動的に terminate し、出力欄にその旨を追記する - $stdout/$stderr を StringIO に溜めて eval 完了後に一括取得していたのを やめ、Worker 内で書き込みのたびに JS.global.call(:postOutput, text) で メインスレッドへ逐次 postMessage するようにした。表示側の 64KB 打ち切り (旧 truncateOutput 相当)は accumulateOutput として維持しつつ、打ち切り 後の追加チャンクは即座に捨てるようにして無限出力ループでも O(1) で済む ようにした - Worker スクリプトの URL は new URL('run-worker.js', import.meta.url) で run.js 自身の URL から解決し、ページの階層やテンプレート側の custom_js_url() の実装に依存しないようにした - run-worker.js も run.js 同様に .mjs ではなく .js 命名にした(#217 と同じ MIME 判定の理由)。statichtml のテーマファイルコピー処理 (copy_run_ruby_wasm_script)を RUN_RUBY_WASM_JS_FILES 経由で2ファイルとも コピーするよう拡張し、片方だけ欠けているテーマも許容する - test/js/test_run.mjs に純粋関数のテスト(accumulateOutput・ STOPPED_NOTE・timeoutNote・run-worker.js の PRELUDE/formatRunError)を 追加。Worker/DOM は QuickJS では扱えないため、テストはこれまでどおり 純粋関数部分のみを対象にしている Co-Authored-By: Claude Fable 5 --- .../subcommands/statichtml_command.rb | 28 ++- .../subcommands/statichtml_command.rbs | 2 + test/js/test_run.mjs | 58 ++++- test/test_run_worker_prelude.rb | 83 +++++++ test/test_statichtml_command.rb | 27 +++ theme/default/js/run-worker.js | 111 ++++++++++ theme/default/js/run.js | 205 ++++++++++++------ 7 files changed, 427 insertions(+), 87 deletions(-) create mode 100644 test/test_run_worker_prelude.rb create mode 100644 theme/default/js/run-worker.js diff --git a/lib/bitclust/subcommands/statichtml_command.rb b/lib/bitclust/subcommands/statichtml_command.rb index 3809eed..8e4ead9 100644 --- a/lib/bitclust/subcommands/statichtml_command.rb +++ b/lib/bitclust/subcommands/statichtml_command.rb @@ -327,18 +327,26 @@ def create_search_index(outputdir, db, fdb) :verbose => @verbose, :preserve => true) end - # Copy the RUN-button script (statichtml --run-ruby-wasm). A themedir - # without js/run.js is tolerated: warn and skip instead of aborting the - # whole build. + # Scripts for the RUN-button feature (statichtml --run-ruby-wasm): + # run.js sets up the button and compiles the wasm module on the main + # thread; run-worker.js is the module Worker it spawns per execution + # (see theme/default/js/run.js for why: STOP/timeout both just + # Worker#terminate() it). Both are required for the feature to work. + RUN_RUBY_WASM_JS_FILES = %w[run.js run-worker.js].freeze + + # Copy the RUN-button scripts. A themedir missing one of them is + # tolerated: warn and skip instead of aborting the whole build. def copy_run_ruby_wasm_script - run_js = @manager_config[:themedir] + "js" + "run.js" - unless run_js.file? - $stderr.puts "warning: #{run_js} not found; RUN button script not copied" - return - end jsdir = @outputdir + "js" - FileUtils.mkdir_p(jsdir) unless jsdir.directory? - FileUtils.cp(run_js.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true) + RUN_RUBY_WASM_JS_FILES.each do |name| + src = @manager_config[:themedir] + "js" + name + unless src.file? + $stderr.puts "warning: #{src} not found; RUN button script not copied" + next + end + FileUtils.mkdir_p(jsdir) unless jsdir.directory? + FileUtils.cp(src.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true) + end end def create_html_file(entry, manager, outputdir, db) diff --git a/sig/bitclust/subcommands/statichtml_command.rbs b/sig/bitclust/subcommands/statichtml_command.rbs index 4b51d86..4e7c108 100644 --- a/sig/bitclust/subcommands/statichtml_command.rbs +++ b/sig/bitclust/subcommands/statichtml_command.rbs @@ -121,6 +121,8 @@ module BitClust def create_index_html: (Pathname outputdir) -> void + RUN_RUBY_WASM_JS_FILES: Array[String] + def copy_run_ruby_wasm_script: () -> void SEARCH_JS_FILES: Array[String] diff --git a/test/js/test_run.mjs b/test/js/test_run.mjs index 9682c9f..002a98e 100644 --- a/test/js/test_run.mjs +++ b/test/js/test_run.mjs @@ -1,8 +1,16 @@ -// QuickJS-based tests for the pure logic in theme/default/js/run.js. +// QuickJS-based tests for the pure logic in theme/default/js/run.js and +// theme/default/js/run-worker.js. // Run with: qjs test/js/test_run.mjs (see the "test:js" rake task) -// Importing the module doubles as a syntax/eval smoke test: its DOM setup is -// guarded by `typeof window !== 'undefined'`. -import { createOnceLoader, PRELUDE, formatRunError, truncateOutput } from '../../theme/default/js/run.js' +// Importing each module doubles as a syntax/eval smoke test: run.js's DOM +// setup is guarded by `typeof window !== 'undefined'`, and run-worker.js's +// Worker setup is guarded by `typeof self !== 'undefined' && typeof +// WorkerGlobalScope !== 'undefined'` -- neither exists under QuickJS, so +// importing either file here never touches a real DOM/Worker. +import { + createOnceLoader, formatRunError, truncateOutput, + accumulateOutput, STOPPED_NOTE, timeoutNote, +} from '../../theme/default/js/run.js' +import { PRELUDE, formatRunError as formatWorkerRunError } from '../../theme/default/js/run-worker.js' let failures = 0 function assert(cond, message) { @@ -52,11 +60,20 @@ function assert(cond, message) { 'next call after a rejection retries the load') } -// PRELUDE captures both streams into one StringIO -assert(PRELUDE.includes('require "stringio"') && - PRELUDE.includes('$stdout = StringIO.new') && +// PRELUDE (run-worker.js) streams both channels to the main thread via the +// JS bridge, instead of buffering into a StringIO like the old single-eval +// PRELUDE did. +assert(PRELUDE.includes('require "js"') && + PRELUDE.includes('JS.global.call(:postOutput, text)') && + PRELUDE.includes('$stdout = JSStreamIO.new') && PRELUDE.includes('$stderr = $stdout'), - 'PRELUDE redirects $stdout and $stderr into one StringIO') + 'PRELUDE redirects $stdout and $stderr through postOutput()') + +// run-worker.js's formatRunError is the same shape as run.js's copy (each +// runs in its own realm -- main thread vs. worker -- so it is a small +// intentional duplication rather than an import cycle across the two files) +assert(formatWorkerRunError(new Error('boom')) === 'boom', + 'run-worker.js formatRunError matches run.js formatRunError') // formatRunError assert(formatRunError(new Error('boom')) === 'boom', @@ -78,6 +95,31 @@ assert(truncateOutput('short') === 'short', 'truncateOutput keeps short output') 'truncateOutput cuts long output with a marker') } +// accumulateOutput: incremental version of truncateOutput, used to append +// each Worker 'output' message to what is already on screen. +assert(accumulateOutput('foo', 'bar') === 'foobar', + 'accumulateOutput appends a chunk to the existing text') +assert(accumulateOutput('', 'first') === 'first', + 'accumulateOutput handles an empty starting value') +{ + const grown = accumulateOutput('x'.repeat(8), 'y'.repeat(8), 10) + assert(grown === 'x'.repeat(8) + 'y'.repeat(2) + '\n... (truncated)', + 'accumulateOutput truncates once the cap is crossed mid-chunk') +} +{ + // A chunk arriving after the cap was already hit must be a no-op, not a + // second "... (truncated)" marker or renewed growth. + const alreadyTruncated = truncateOutput('x'.repeat(20), 10) + const next = accumulateOutput(alreadyTruncated, 'more output', 10) + assert(next === alreadyTruncated, + 'accumulateOutput drops further chunks once truncated') +} + +// STOPPED_NOTE / timeoutNote: the notes appended to output on STOP / timeout +assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice') +assert(timeoutNote(30) === '(30秒でタイムアウトしました)', + 'timeoutNote formats the configured timeout in seconds') + if (failures > 0) { throw new Error(failures + ' JS test(s) failed') } diff --git a/test/test_run_worker_prelude.rb b/test/test_run_worker_prelude.rb new file mode 100644 index 0000000..61c062a --- /dev/null +++ b/test/test_run_worker_prelude.rb @@ -0,0 +1,83 @@ +require 'test/unit' + +# theme/default/js/run-worker.js の PRELUDE(Ruby コード)の検証。 +# Kernel#puts/print/p は $stdout が IO でないとき #write 経由で書き込むが、 +# マニュアルのサンプルは $stderr.puts や $stdout.putc のように IO のメソッドを +# 直接呼ぶこともある(旧実装の StringIO はそれらを備えていた)。JS ブリッジを +# スタブした実 Ruby で PRELUDE を eval し、両方の経路の出力を確認する。 +class TestRunWorkerPrelude < Test::Unit::TestCase + WORKER_JS = File.expand_path('../theme/default/js/run-worker.js', __dir__) + + module JSStub + OUTPUT = [] + def self.global + self + end + + def self.call(name, text) + raise ArgumentError, "unexpected JS call: #{name}" unless name == :postOutput + OUTPUT << text.to_s + end + end + + def setup + src = File.read(WORKER_JS) + prelude = src[/export const PRELUDE = `\n(.*?)\n`/m, 1] + assert_not_nil(prelude, 'PRELUDE not found in run-worker.js') + # ruby.wasm 上でだけ存在する js gem をスタブに差し替える + @prelude = prelude.sub(/\Arequire "js"\n/, '') + JSStub::OUTPUT.clear + Object.const_set(:JS, JSStub) + end + + def teardown + Object.send(:remove_const, :JS) if Object.const_defined?(:JS) + Object.send(:remove_const, :JSStreamIO) if Object.const_defined?(:JSStreamIO) + end + + def run_with_prelude + orig_stdout = $stdout + orig_stderr = $stderr + begin + eval(@prelude, TOPLEVEL_BINDING) + yield + ensure + $stdout = orig_stdout + $stderr = orig_stderr + end + JSStub::OUTPUT.join + end + + def test_kernel_methods_stream_through_post_output + out = run_with_prelude do + puts 'hello' + print 'a', 'b' + p 123 + end + assert_equal("hello\nab123\n", out) + end + + def test_direct_io_methods_match_old_stringio_behavior + out = run_with_prelude do + $stdout.puts 'direct' + $stderr.puts 'err' + $stdout.putc 'XY' + $stdout.print 'pr' + $stdout.printf('%05d', 42) + $stdout << 'chained' << '!' + $stdout.flush + $stdout.puts ['a', ['b']] + $stdout.puts + end + assert_equal("direct\nerr\nXpr00042chained!a\nb\n\n", out) + end + + def test_stream_reports_not_a_tty_and_accepts_sync + run_with_prelude do + assert_false($stdout.tty?) + assert_false($stderr.isatty) + assert_true($stdout.sync) + $stdout.sync = true + end + end +end diff --git a/test/test_statichtml_command.rb b/test/test_statichtml_command.rb index 41932a3..d661364 100644 --- a/test/test_statichtml_command.rb +++ b/test/test_statichtml_command.rb @@ -57,9 +57,11 @@ def test_run_js_is_copied FileUtils.mkdir_p(File.join(themedir, 'js')) FileUtils.mkdir_p(outputdir) File.write(File.join(themedir, 'js', 'run.js'), "// run\n") + File.write(File.join(themedir, 'js', 'run-worker.js'), "// worker\n") cmd = build_command(themedir, outputdir) cmd.send(:copy_run_ruby_wasm_script) assert_true(File.file?(File.join(outputdir, 'js', 'run.js'))) + assert_true(File.file?(File.join(outputdir, 'js', 'run-worker.js'))) end end @@ -74,10 +76,35 @@ def test_theme_without_run_js_is_tolerated begin assert_nothing_raised { cmd.send(:copy_run_ruby_wasm_script) } assert_match(/run\.js not found/, $stderr.string) + assert_match(/run-worker\.js not found/, $stderr.string) ensure $stderr = orig_stderr end assert_false(File.exist?(File.join(outputdir, 'js', 'run.js'))) + assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js'))) + end + end + + # A themedir with run.js but not yet upgraded with run-worker.js (or vice + # versa) should still copy whichever file it does have, rather than + # aborting or silently skipping both. + def test_partial_theme_copies_what_it_has + Dir.mktmpdir do |dir| + themedir = File.join(dir, 'theme') + outputdir = File.join(dir, 'out') + FileUtils.mkdir_p(File.join(themedir, 'js')) + FileUtils.mkdir_p(outputdir) + File.write(File.join(themedir, 'js', 'run.js'), "// run\n") + cmd = build_command(themedir, outputdir) + orig_stderr, $stderr = $stderr, StringIO.new + begin + cmd.send(:copy_run_ruby_wasm_script) + assert_match(/run-worker\.js not found/, $stderr.string) + ensure + $stderr = orig_stderr + end + assert_true(File.file?(File.join(outputdir, 'js', 'run.js'))) + assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js'))) end end end diff --git a/theme/default/js/run-worker.js b/theme/default/js/run-worker.js new file mode 100644 index 0000000..34ea6c1 --- /dev/null +++ b/theme/default/js/run-worker.js @@ -0,0 +1,111 @@ +// Module Worker that runs one RUN-button sample. Loaded by theme/default/js/run.js +// via `new Worker(url, { type: 'module' })`; one Worker instance per execution +// (see run.js for why: a fresh VM per run needs no cross-run state, and it +// makes terminate()-to-stop trivial). Named .js rather than .mjs for the same +// MIME-serving reason as run.js (see the comment at the top of that file). +// +// Protocol: +// main -> worker: { module: WebAssembly.Module, code: string } +// worker -> main: { type: 'output', text: string } (zero or more, in order) +// worker -> main: { type: 'done' } (exactly one, on success) +// worker -> main: { type: 'error', message: string } (exactly one, on failure) +// Exactly one of 'done'/'error' is posted, always last. The main thread may +// also just call worker.terminate() (STOP button / timeout); the worker does +// not need to post anything in that case. + +const VM_ESM_URL = 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser/+esm' + +// Same idea as run.js's old (pre-Worker) PRELUDE -- redirect $stdout/$stderr +// -- but instead of buffering into a StringIO for one bulk read at the end, +// each write is forwarded to the main thread immediately via postOutput() +// so long-running or infinite-output samples show progress instead of +// growing an in-wasm string forever. $stderr shares the same sink so +// interleaving still reads naturally, matching the previous StringIO-based +// behavior. Ruby only ever hands the JS bridge a plain string, so +// $stdout.write calls self.postOutput(), a small helper defined below +// (rather than calling self.postMessage directly from Ruby) so that +// postMessage itself stays the single place that emits the { type: ... } +// envelope; main.onmessage never has to disambiguate a bare string from a +// control message. +// +// Kernel#puts/print/p reach a non-IO $stdout through its #write, but the +// manual's samples also call IO methods on $stdout/$stderr *directly* +// ($stderr.puts, $stdout.putc, $stdout.print, $stdout.flush, $stderr.tty?, +// ...). The old StringIO provided those for free, so the common ones are +// implemented here; their semantics are covered by test/test_run_worker_prelude.rb, +// which evals this prelude in a real Ruby with the JS bridge stubbed. +export const PRELUDE = ` +require "js" +class JSStreamIO + def write(*parts) + text = parts.join + JS.global.call(:postOutput, text) + text.bytesize + end + def <<(obj) + write(obj) + self + end + def puts(*args) + if args.empty? + write("\n") + else + args.flatten.each do |arg| + s = arg.to_s + s = s + "\n" unless s.end_with?("\n") + write(s) + end + end + nil + end + def print(*args) + write(args.join) + nil + end + def printf(*args) + write(sprintf(*args)) + nil + end + def putc(ch) + write(ch.is_a?(String) ? ch[0] : ch.chr) + ch + end + def flush; self; end + def sync; true; end + def sync=(value); value; end + def tty?; false; end + alias isatty tty? +end +$stdout = JSStreamIO.new +$stderr = $stdout +` + +export function formatRunError(error) { + const text = String((error && error.message) || error) + return text.split('\n').slice(0, 20).join('\n') +} + +// Guarded the same way run.js guards its init(): this file is imported by +// test/js/test_run.mjs under QuickJS, which has neither `self` (as the +// Worker global) nor a real postMessage/onmessage. +if (typeof self !== 'undefined' && typeof WorkerGlobalScope !== 'undefined') { + self.postOutput = (text) => self.postMessage({ type: 'output', text }) + + self.onmessage = async (event) => { + const { module, code } = event.data + try { + const { DefaultRubyVM } = await import(VM_ESM_URL) + const { vm } = await DefaultRubyVM(module, { consolePrint: false }) + vm.eval(PRELUDE) + try { + vm.eval(code) + } catch (e) { + self.postMessage({ type: 'error', message: formatRunError(e) }) + return + } + self.postMessage({ type: 'done' }) + } catch (e) { + self.postMessage({ type: 'error', message: formatRunError(e) }) + } + } +} diff --git a/theme/default/js/run.js b/theme/default/js/run.js index fe238e9..2562f2d 100644 --- a/theme/default/js/run.js +++ b/theme/default/js/run.js @@ -1,19 +1,30 @@ // RUN button for Ruby sample code: executes the sample in-browser with -// ruby.wasm. Named .js rather than .mjs: module scripts are subject to -// strict MIME checking, and servers whose MIME table lacks an "mjs" entry -// (e.g. nginx before 1.21.4) serve .mjs as application/octet-stream, which -// browsers refuse to execute. Enabled per page via +// ruby.wasm, off the main thread in a Web Worker (theme/default/js/run-worker.js) +// so an infinite loop or long sleep in the sample can't freeze the page; a +// STOP button and a timeout both just Worker.terminate() it. Named .js +// rather than .mjs: module scripts are subject to strict MIME checking, and +// servers whose MIME table lacks an "mjs" entry (e.g. nginx before 1.21.4) +// serve .mjs as application/octet-stream, which browsers refuse to execute. +// The same reasoning applies to run-worker.js. Enabled per page via // // which the layout emits when statichtml was invoked with --run-ruby-wasm. // The wasm URL is chosen by the build side to match the documented Ruby -// version; this file only pins the loader library. - -// Exact pin of the npm "latest" dist-tag at the time of writing. Note the -// version scheme: stable @ruby/* packages are published as -// "-" (e.g. 2.9.3-2.9.4); plain "2.9.4" -// does not exist on npm. -const VM_ESM_URL = 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser/+esm' +// version. This file only compiles that wasm (WebAssembly.compileStreaming +// needs no loader library, just the bytes); the CDN-hosted @ruby/wasm-wasi +// loader is pinned and imported inside run-worker.js, which is the only +// place that actually instantiates a VM from the compiled module. const OUTPUT_LIMIT = 64 * 1024 +const RUN_TIMEOUT_MS = 30 * 1000 + +// Resolved relative to this module's own URL (not the page URL), so it keeps +// working regardless of how deep the current page is under the site root or +// how custom_js_url() built run.js's own