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..7d81c7d 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, DONE_NOTE, timeoutNote, editableText, +} 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,43 @@ 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 / DONE_NOTE / timeoutNote: the notes appended to the output +// on STOP / normal completion / timeout +assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice') +assert(DONE_NOTE === '(正常終了しました)', + 'DONE_NOTE marks a normal completion (visible even with empty output)') +assert(timeoutNote(30) === '(30秒でタイムアウトしました)', + 'timeoutNote formats the configured timeout in seconds') + +// editableText: flattening for edit drops the compiler's leading newline +// (the artifact after ) but keeps interior/trailing newlines intact +assert(editableText('\np 1\np 2\n') === 'p 1\np 2\n', + 'editableText strips the leading newline artifact') +assert(editableText('\n\n\np 1\n') === 'p 1\n', + 'editableText strips all leading blank lines') +assert(editableText('p 1\n\np 2\n') === 'p 1\n\np 2\n', + 'editableText keeps interior and trailing newlines') + if (failures > 0) { throw new Error(failures + ' JS test(s) failed') } diff --git a/test/js/test_script.mjs b/test/js/test_script.mjs index d112531..7d4eed3 100644 --- a/test/js/test_script.mjs +++ b/test/js/test_script.mjs @@ -37,11 +37,16 @@ class FakeElement { if (name === 'class') this.className = value } select() {} + // 実 DOM 同様、挿入時は元の親から外して付け替える(parentNode も追跡) appendChild(child) { + if (child.parentNode) child.parentNode.removeChild(child) + child.parentNode = this this.children.push(child) return child } insertBefore(child, ref) { + if (child.parentNode) child.parentNode.removeChild(child) + child.parentNode = this const i = this.children.indexOf(ref) if (ref == null || i < 0) this.children.push(child) else this.children.splice(i, 0, child) @@ -49,12 +54,21 @@ class FakeElement { } removeChild(child) { const i = this.children.indexOf(child) - if (i >= 0) this.children.splice(i, 1) + if (i >= 0) { + this.children.splice(i, 1) + child.parentNode = null + } return child } get firstChild() { return this.children.length > 0 ? this.children[0] : null } + get previousElementSibling() { + if (!this.parentNode) return null + const siblings = this.parentNode.children + const i = siblings.indexOf(this) + return i > 0 ? siblings[i - 1] : null + } get textContent() { return this.ownText + this.children.map(c => c.textContent).join('') } @@ -108,13 +122,17 @@ function makeDocument(elements) { const here = import.meta.url.replace(/^file:\/\//, '').replace(/\/[^/]*$/, '') const source = std.loadFile(here + '/../../theme/default/script.js') +// script.js は pre の直前(親の子リスト)にツールバーを差し込むので、 +// テスト対象の要素は root(body 相当)にぶら下げて親を持たせる function runOnload(elements, navigatorFake) { + const root = new FakeElement('div') + elements.forEach((e) => root.appendChild(e)) globalThis.document = makeDocument(elements) globalThis.window = { setTimeout() { return 0 } } globalThis.navigator = navigatorFake || {} ;(0, eval)(source) globalThis.window.onload() - return elements + return root } // Clipboard API を捕捉する navigator フェイク @@ -139,15 +157,35 @@ async function settle() { await 0 } +// pre の直前のツールバー行 → ボタン置き場 → COPY を辿るヘルパー +function toolbarOf(elem) { + const prev = elem.previousElementSibling + if (prev && prev.className.split(' ').indexOf('highlight__toolbar') >= 0) return prev + return null +} +function buttonGroupOf(elem) { + const toolbar = toolbarOf(elem) + return toolbar && toolbar.childByClass('highlight__button-group') +} +function copyButtonOf(elem) { + const group = buttonGroupOf(elem) + return group && group.childByClass('highlight__copy-button') +} + // A ruby sample keeps getting the COPY button (regression) { const spy = clipboardSpy() const pre = new FakeElement('pre', 'highlight ruby') pre.appendChild(new FakeElement('code', '', 'puts 1\n')) runOnload([pre], spy.navigator) - const btn = pre.childByClass('highlight__copy-button') + const btn = copyButtonOf(pre) assert(btn !== null, 'pre.highlight.ruby gets a COPY button') - assert(pre.firstChild === btn, 'COPY button is prepended as the first child') + assert(toolbarOf(pre) !== null, + 'a toolbar row is inserted just before the pre (outside of it)') + assert(pre.childByClass('highlight__button-group') === null, + 'no button container is injected inside the pre itself') + assert(buttonGroupOf(pre).firstChild === btn, + 'COPY button lives inside the toolbar button group') btn.onclick() await settle() assert(spy.written.length === 1 && spy.written[0] === 'puts 1\n', @@ -161,7 +199,7 @@ async function settle() { const spy = clipboardSpy() const pre = new FakeElement('pre', '', 'ary = []\n') runOnload([pre], spy.navigator) - const btn = pre.childByClass('highlight__copy-button') + const btn = copyButtonOf(pre) assert(btn !== null, 'plain
 without highlight class gets a COPY button')
   btn.onclick()
   await settle()
@@ -174,7 +212,7 @@ async function settle() {
   const pre = new FakeElement('pre', 'highlight c')
   pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
   runOnload([pre], clipboardSpy().navigator)
-  assert(pre.childByClass('highlight__copy-button') !== null,
+  assert(copyButtonOf(pre) !== null,
          'pre.highlight.c gets a COPY button')
 }
 
@@ -183,7 +221,7 @@ async function settle() {
   const div = new FakeElement('div', 'highlight')
   const pre = new FakeElement('pre', '')
   runOnload([div, pre], clipboardSpy().navigator)
-  assert(div.childByClass('highlight__copy-button') === null,
+  assert(copyButtonOf(div) === null,
          'non-pre element does not get a COPY button')
 }
 
@@ -194,7 +232,7 @@ async function settle() {
   pre.appendChild(new FakeElement('span', 'caption', '例'))
   pre.appendChild(new FakeElement('code', '', 'p 42\n'))
   runOnload([pre], spy.navigator)
-  pre.childByClass('highlight__copy-button').onclick()
+  copyButtonOf(pre).onclick()
   await settle()
   assert(spy.written[0] === 'p 42\n',
          'caption text is excluded from the copied text')
@@ -207,25 +245,48 @@ async function settle() {
   const spy = clipboardSpy()
   const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
   runOnload([pre], spy.navigator)
-  pre.childByClass('highlight__copy-button').onclick()
+  copyButtonOf(pre).onclick()
   await settle()
   assert(spy.written[0] === 'puts 1\n',
          'copied text is trimmed (leading newlines dropped, trailing squeezed)')
 }
 
+// pre の直前に caption(タブ)があれば、ツールバーの左端に取り込まれる
+{
+  const caption = new FakeElement('span', 'caption', '例')
+  const pre = new FakeElement('pre', 'highlight ruby')
+  pre.appendChild(new FakeElement('code', '', 'p 42\n'))
+  const root = runOnload([caption, pre], clipboardSpy().navigator)
+  const toolbar = toolbarOf(pre)
+  assert(toolbar !== null && toolbar.firstChild === caption,
+         'a sibling caption is moved to the left edge of the toolbar')
+  assert(root.children.indexOf(caption) < 0,
+         'the caption is no longer a direct sibling of the pre')
+  assert(toolbar.childByClass('highlight__button-group') !== null,
+         'the button group sits in the same toolbar row as the caption')
+}
+
 // RUN 出力など、後から生成される pre にもボタンを付けられる公開フック。
 // getText はクリック時に評価されるので、内容が変わる要素にも使える
 {
   const spy = clipboardSpy()
   const pre = new FakeElement('pre', '')
-  runOnload([pre], spy.navigator)
+  const root = runOnload([pre], spy.navigator)
   assert(typeof globalThis.window.ruremaAddCopyButton === 'function',
          'window.ruremaAddCopyButton is exposed for dynamically created pre')
+  // 実際の run.js と同様、DOM に挿入してからフックを呼ぶ
   const output = new FakeElement('pre', 'highlight__run-output')
+  root.appendChild(output)
   let current = 'first output\n'
   const btn = globalThis.window.ruremaAddCopyButton(output, () => current)
-  assert(output.firstChild === btn,
-         'the hook prepends a COPY button to the given element')
+  assert(toolbarOf(output) !== null &&
+         buttonGroupOf(output).childByClass('highlight__copy-button') === btn,
+         'the hook inserts a toolbar with the COPY button before the element')
+  // 既にツールバーがある要素にもう一度呼んでも、ツールバーは1つのまま再利用される
+  globalThis.window.ruremaAddCopyButton(output, () => current)
+  assert(root.children.filter(
+           c => c.className === 'highlight__toolbar').length === 2,
+         'an existing toolbar is reused (one for the sample, one for the output)')
   btn.onclick()
   await settle()
   current = 'second output\n'
@@ -239,6 +300,8 @@ async function settle() {
 // Clipboard API が無い環境では textarea + execCommand にフォールバックする
 {
   const pre = new FakeElement('pre', '', 'fallback code\n')
+  const root = new FakeElement('div')
+  root.appendChild(pre)
   const elements = [pre]
   globalThis.document = makeDocument(elements)
   let captured = null
@@ -253,7 +316,7 @@ async function settle() {
   globalThis.navigator = {}   // clipboard なし
   ;(0, eval)(source)
   globalThis.window.onload()
-  const btn = pre.childByClass('highlight__copy-button')
+  const btn = copyButtonOf(pre)
   btn.onclick()
   await settle()
   assert(captured === 'fallback code\n',
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..c76d840 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