Skip to content

Commit c5cc6e9

Browse files
znzclaude
andcommitted
実行可能サンプル(RUN)を Web Worker 化して停止・タイムアウトに対応
RUN ボタンは ruby.wasm をメインスレッドで同期実行しており、無限ループや 長い sleep を含むサンプルではタブの UI が完全に固まっていた(fix rurema#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 命名にした(rurema#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 <noreply@anthropic.com>
1 parent e3807d5 commit c5cc6e9

7 files changed

Lines changed: 427 additions & 87 deletions

File tree

lib/bitclust/subcommands/statichtml_command.rb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,18 +327,26 @@ def create_search_index(outputdir, db, fdb)
327327
:verbose => @verbose, :preserve => true)
328328
end
329329

330-
# Copy the RUN-button script (statichtml --run-ruby-wasm). A themedir
331-
# without js/run.js is tolerated: warn and skip instead of aborting the
332-
# whole build.
330+
# Scripts for the RUN-button feature (statichtml --run-ruby-wasm):
331+
# run.js sets up the button and compiles the wasm module on the main
332+
# thread; run-worker.js is the module Worker it spawns per execution
333+
# (see theme/default/js/run.js for why: STOP/timeout both just
334+
# Worker#terminate() it). Both are required for the feature to work.
335+
RUN_RUBY_WASM_JS_FILES = %w[run.js run-worker.js].freeze
336+
337+
# Copy the RUN-button scripts. A themedir missing one of them is
338+
# tolerated: warn and skip instead of aborting the whole build.
333339
def copy_run_ruby_wasm_script
334-
run_js = @manager_config[:themedir] + "js" + "run.js"
335-
unless run_js.file?
336-
$stderr.puts "warning: #{run_js} not found; RUN button script not copied"
337-
return
338-
end
339340
jsdir = @outputdir + "js"
340-
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
341-
FileUtils.cp(run_js.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
341+
RUN_RUBY_WASM_JS_FILES.each do |name|
342+
src = @manager_config[:themedir] + "js" + name
343+
unless src.file?
344+
$stderr.puts "warning: #{src} not found; RUN button script not copied"
345+
next
346+
end
347+
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
348+
FileUtils.cp(src.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
349+
end
342350
end
343351

344352
def create_html_file(entry, manager, outputdir, db)

sig/bitclust/subcommands/statichtml_command.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ module BitClust
121121

122122
def create_index_html: (Pathname outputdir) -> void
123123

124+
RUN_RUBY_WASM_JS_FILES: Array[String]
125+
124126
def copy_run_ruby_wasm_script: () -> void
125127

126128
SEARCH_JS_FILES: Array[String]

test/js/test_run.mjs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
// QuickJS-based tests for the pure logic in theme/default/js/run.js.
1+
// QuickJS-based tests for the pure logic in theme/default/js/run.js and
2+
// theme/default/js/run-worker.js.
23
// Run with: qjs test/js/test_run.mjs (see the "test:js" rake task)
3-
// Importing the module doubles as a syntax/eval smoke test: its DOM setup is
4-
// guarded by `typeof window !== 'undefined'`.
5-
import { createOnceLoader, PRELUDE, formatRunError, truncateOutput } from '../../theme/default/js/run.js'
4+
// Importing each module doubles as a syntax/eval smoke test: run.js's DOM
5+
// setup is guarded by `typeof window !== 'undefined'`, and run-worker.js's
6+
// Worker setup is guarded by `typeof self !== 'undefined' && typeof
7+
// WorkerGlobalScope !== 'undefined'` -- neither exists under QuickJS, so
8+
// importing either file here never touches a real DOM/Worker.
9+
import {
10+
createOnceLoader, formatRunError, truncateOutput,
11+
accumulateOutput, STOPPED_NOTE, timeoutNote,
12+
} from '../../theme/default/js/run.js'
13+
import { PRELUDE, formatRunError as formatWorkerRunError } from '../../theme/default/js/run-worker.js'
614

715
let failures = 0
816
function assert(cond, message) {
@@ -52,11 +60,20 @@ function assert(cond, message) {
5260
'next call after a rejection retries the load')
5361
}
5462

55-
// PRELUDE captures both streams into one StringIO
56-
assert(PRELUDE.includes('require "stringio"') &&
57-
PRELUDE.includes('$stdout = StringIO.new') &&
63+
// PRELUDE (run-worker.js) streams both channels to the main thread via the
64+
// JS bridge, instead of buffering into a StringIO like the old single-eval
65+
// PRELUDE did.
66+
assert(PRELUDE.includes('require "js"') &&
67+
PRELUDE.includes('JS.global.call(:postOutput, text)') &&
68+
PRELUDE.includes('$stdout = JSStreamIO.new') &&
5869
PRELUDE.includes('$stderr = $stdout'),
59-
'PRELUDE redirects $stdout and $stderr into one StringIO')
70+
'PRELUDE redirects $stdout and $stderr through postOutput()')
71+
72+
// run-worker.js's formatRunError is the same shape as run.js's copy (each
73+
// runs in its own realm -- main thread vs. worker -- so it is a small
74+
// intentional duplication rather than an import cycle across the two files)
75+
assert(formatWorkerRunError(new Error('boom')) === 'boom',
76+
'run-worker.js formatRunError matches run.js formatRunError')
6077

6178
// formatRunError
6279
assert(formatRunError(new Error('boom')) === 'boom',
@@ -78,6 +95,31 @@ assert(truncateOutput('short') === 'short', 'truncateOutput keeps short output')
7895
'truncateOutput cuts long output with a marker')
7996
}
8097

98+
// accumulateOutput: incremental version of truncateOutput, used to append
99+
// each Worker 'output' message to what is already on screen.
100+
assert(accumulateOutput('foo', 'bar') === 'foobar',
101+
'accumulateOutput appends a chunk to the existing text')
102+
assert(accumulateOutput('', 'first') === 'first',
103+
'accumulateOutput handles an empty starting value')
104+
{
105+
const grown = accumulateOutput('x'.repeat(8), 'y'.repeat(8), 10)
106+
assert(grown === 'x'.repeat(8) + 'y'.repeat(2) + '\n... (truncated)',
107+
'accumulateOutput truncates once the cap is crossed mid-chunk')
108+
}
109+
{
110+
// A chunk arriving after the cap was already hit must be a no-op, not a
111+
// second "... (truncated)" marker or renewed growth.
112+
const alreadyTruncated = truncateOutput('x'.repeat(20), 10)
113+
const next = accumulateOutput(alreadyTruncated, 'more output', 10)
114+
assert(next === alreadyTruncated,
115+
'accumulateOutput drops further chunks once truncated')
116+
}
117+
118+
// STOPPED_NOTE / timeoutNote: the notes appended to output on STOP / timeout
119+
assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice')
120+
assert(timeoutNote(30) === '(30秒でタイムアウトしました)',
121+
'timeoutNote formats the configured timeout in seconds')
122+
81123
if (failures > 0) {
82124
throw new Error(failures + ' JS test(s) failed')
83125
}

test/test_run_worker_prelude.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
require 'test/unit'
2+
3+
# theme/default/js/run-worker.js の PRELUDE(Ruby コード)の検証。
4+
# Kernel#puts/print/p は $stdout が IO でないとき #write 経由で書き込むが、
5+
# マニュアルのサンプルは $stderr.puts や $stdout.putc のように IO のメソッドを
6+
# 直接呼ぶこともある(旧実装の StringIO はそれらを備えていた)。JS ブリッジを
7+
# スタブした実 Ruby で PRELUDE を eval し、両方の経路の出力を確認する。
8+
class TestRunWorkerPrelude < Test::Unit::TestCase
9+
WORKER_JS = File.expand_path('../theme/default/js/run-worker.js', __dir__)
10+
11+
module JSStub
12+
OUTPUT = []
13+
def self.global
14+
self
15+
end
16+
17+
def self.call(name, text)
18+
raise ArgumentError, "unexpected JS call: #{name}" unless name == :postOutput
19+
OUTPUT << text.to_s
20+
end
21+
end
22+
23+
def setup
24+
src = File.read(WORKER_JS)
25+
prelude = src[/export const PRELUDE = `\n(.*?)\n`/m, 1]
26+
assert_not_nil(prelude, 'PRELUDE not found in run-worker.js')
27+
# ruby.wasm 上でだけ存在する js gem をスタブに差し替える
28+
@prelude = prelude.sub(/\Arequire "js"\n/, '')
29+
JSStub::OUTPUT.clear
30+
Object.const_set(:JS, JSStub)
31+
end
32+
33+
def teardown
34+
Object.send(:remove_const, :JS) if Object.const_defined?(:JS)
35+
Object.send(:remove_const, :JSStreamIO) if Object.const_defined?(:JSStreamIO)
36+
end
37+
38+
def run_with_prelude
39+
orig_stdout = $stdout
40+
orig_stderr = $stderr
41+
begin
42+
eval(@prelude, TOPLEVEL_BINDING)
43+
yield
44+
ensure
45+
$stdout = orig_stdout
46+
$stderr = orig_stderr
47+
end
48+
JSStub::OUTPUT.join
49+
end
50+
51+
def test_kernel_methods_stream_through_post_output
52+
out = run_with_prelude do
53+
puts 'hello'
54+
print 'a', 'b'
55+
p 123
56+
end
57+
assert_equal("hello\nab123\n", out)
58+
end
59+
60+
def test_direct_io_methods_match_old_stringio_behavior
61+
out = run_with_prelude do
62+
$stdout.puts 'direct'
63+
$stderr.puts 'err'
64+
$stdout.putc 'XY'
65+
$stdout.print 'pr'
66+
$stdout.printf('%05d', 42)
67+
$stdout << 'chained' << '!'
68+
$stdout.flush
69+
$stdout.puts ['a', ['b']]
70+
$stdout.puts
71+
end
72+
assert_equal("direct\nerr\nXpr00042chained!a\nb\n\n", out)
73+
end
74+
75+
def test_stream_reports_not_a_tty_and_accepts_sync
76+
run_with_prelude do
77+
assert_false($stdout.tty?)
78+
assert_false($stderr.isatty)
79+
assert_true($stdout.sync)
80+
$stdout.sync = true
81+
end
82+
end
83+
end

test/test_statichtml_command.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ def test_run_js_is_copied
5757
FileUtils.mkdir_p(File.join(themedir, 'js'))
5858
FileUtils.mkdir_p(outputdir)
5959
File.write(File.join(themedir, 'js', 'run.js'), "// run\n")
60+
File.write(File.join(themedir, 'js', 'run-worker.js'), "// worker\n")
6061
cmd = build_command(themedir, outputdir)
6162
cmd.send(:copy_run_ruby_wasm_script)
6263
assert_true(File.file?(File.join(outputdir, 'js', 'run.js')))
64+
assert_true(File.file?(File.join(outputdir, 'js', 'run-worker.js')))
6365
end
6466
end
6567

@@ -74,10 +76,35 @@ def test_theme_without_run_js_is_tolerated
7476
begin
7577
assert_nothing_raised { cmd.send(:copy_run_ruby_wasm_script) }
7678
assert_match(/run\.js not found/, $stderr.string)
79+
assert_match(/run-worker\.js not found/, $stderr.string)
7780
ensure
7881
$stderr = orig_stderr
7982
end
8083
assert_false(File.exist?(File.join(outputdir, 'js', 'run.js')))
84+
assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js')))
85+
end
86+
end
87+
88+
# A themedir with run.js but not yet upgraded with run-worker.js (or vice
89+
# versa) should still copy whichever file it does have, rather than
90+
# aborting or silently skipping both.
91+
def test_partial_theme_copies_what_it_has
92+
Dir.mktmpdir do |dir|
93+
themedir = File.join(dir, 'theme')
94+
outputdir = File.join(dir, 'out')
95+
FileUtils.mkdir_p(File.join(themedir, 'js'))
96+
FileUtils.mkdir_p(outputdir)
97+
File.write(File.join(themedir, 'js', 'run.js'), "// run\n")
98+
cmd = build_command(themedir, outputdir)
99+
orig_stderr, $stderr = $stderr, StringIO.new
100+
begin
101+
cmd.send(:copy_run_ruby_wasm_script)
102+
assert_match(/run-worker\.js not found/, $stderr.string)
103+
ensure
104+
$stderr = orig_stderr
105+
end
106+
assert_true(File.file?(File.join(outputdir, 'js', 'run.js')))
107+
assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js')))
81108
end
82109
end
83110
end

theme/default/js/run-worker.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Module Worker that runs one RUN-button sample. Loaded by theme/default/js/run.js
2+
// via `new Worker(url, { type: 'module' })`; one Worker instance per execution
3+
// (see run.js for why: a fresh VM per run needs no cross-run state, and it
4+
// makes terminate()-to-stop trivial). Named .js rather than .mjs for the same
5+
// MIME-serving reason as run.js (see the comment at the top of that file).
6+
//
7+
// Protocol:
8+
// main -> worker: { module: WebAssembly.Module, code: string }
9+
// worker -> main: { type: 'output', text: string } (zero or more, in order)
10+
// worker -> main: { type: 'done' } (exactly one, on success)
11+
// worker -> main: { type: 'error', message: string } (exactly one, on failure)
12+
// Exactly one of 'done'/'error' is posted, always last. The main thread may
13+
// also just call worker.terminate() (STOP button / timeout); the worker does
14+
// not need to post anything in that case.
15+
16+
const VM_ESM_URL = 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser/+esm'
17+
18+
// Same idea as run.js's old (pre-Worker) PRELUDE -- redirect $stdout/$stderr
19+
// -- but instead of buffering into a StringIO for one bulk read at the end,
20+
// each write is forwarded to the main thread immediately via postOutput()
21+
// so long-running or infinite-output samples show progress instead of
22+
// growing an in-wasm string forever. $stderr shares the same sink so
23+
// interleaving still reads naturally, matching the previous StringIO-based
24+
// behavior. Ruby only ever hands the JS bridge a plain string, so
25+
// $stdout.write calls self.postOutput(), a small helper defined below
26+
// (rather than calling self.postMessage directly from Ruby) so that
27+
// postMessage itself stays the single place that emits the { type: ... }
28+
// envelope; main.onmessage never has to disambiguate a bare string from a
29+
// control message.
30+
//
31+
// Kernel#puts/print/p reach a non-IO $stdout through its #write, but the
32+
// manual's samples also call IO methods on $stdout/$stderr *directly*
33+
// ($stderr.puts, $stdout.putc, $stdout.print, $stdout.flush, $stderr.tty?,
34+
// ...). The old StringIO provided those for free, so the common ones are
35+
// implemented here; their semantics are covered by test/test_run_worker_prelude.rb,
36+
// which evals this prelude in a real Ruby with the JS bridge stubbed.
37+
export const PRELUDE = `
38+
require "js"
39+
class JSStreamIO
40+
def write(*parts)
41+
text = parts.join
42+
JS.global.call(:postOutput, text)
43+
text.bytesize
44+
end
45+
def <<(obj)
46+
write(obj)
47+
self
48+
end
49+
def puts(*args)
50+
if args.empty?
51+
write("\n")
52+
else
53+
args.flatten.each do |arg|
54+
s = arg.to_s
55+
s = s + "\n" unless s.end_with?("\n")
56+
write(s)
57+
end
58+
end
59+
nil
60+
end
61+
def print(*args)
62+
write(args.join)
63+
nil
64+
end
65+
def printf(*args)
66+
write(sprintf(*args))
67+
nil
68+
end
69+
def putc(ch)
70+
write(ch.is_a?(String) ? ch[0] : ch.chr)
71+
ch
72+
end
73+
def flush; self; end
74+
def sync; true; end
75+
def sync=(value); value; end
76+
def tty?; false; end
77+
alias isatty tty?
78+
end
79+
$stdout = JSStreamIO.new
80+
$stderr = $stdout
81+
`
82+
83+
export function formatRunError(error) {
84+
const text = String((error && error.message) || error)
85+
return text.split('\n').slice(0, 20).join('\n')
86+
}
87+
88+
// Guarded the same way run.js guards its init(): this file is imported by
89+
// test/js/test_run.mjs under QuickJS, which has neither `self` (as the
90+
// Worker global) nor a real postMessage/onmessage.
91+
if (typeof self !== 'undefined' && typeof WorkerGlobalScope !== 'undefined') {
92+
self.postOutput = (text) => self.postMessage({ type: 'output', text })
93+
94+
self.onmessage = async (event) => {
95+
const { module, code } = event.data
96+
try {
97+
const { DefaultRubyVM } = await import(VM_ESM_URL)
98+
const { vm } = await DefaultRubyVM(module, { consolePrint: false })
99+
vm.eval(PRELUDE)
100+
try {
101+
vm.eval(code)
102+
} catch (e) {
103+
self.postMessage({ type: 'error', message: formatRunError(e) })
104+
return
105+
}
106+
self.postMessage({ type: 'done' })
107+
} catch (e) {
108+
self.postMessage({ type: 'error', message: formatRunError(e) })
109+
}
110+
}
111+
}

0 commit comments

Comments
 (0)