diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf514dd..842ce6a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,26 @@ on: - push jobs: + test-truffleruby: + name: Test TruffleRuby + runs-on: ubuntu-20.04 + env: + TRUFFLERUBYOPT: "--jvm --polyglot" + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: truffleruby+graalvm-head + - name: Install GraalVM js component + run: if ! gu list | grep '^js '; then gu install js; fi + - name: Bundle + run: bundle install + - name: Compile + run: bundle exec rake compile + - name: Test + run: bundle exec rake test test-darwin: strategy: fail-fast: false diff --git a/Rakefile b/Rakefile index d7d888c8..3a44cb04 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,5 @@ require "bundler/gem_tasks" require "rake/testtask" -require "rake/extensiontask" Rake::TestTask.new(:test) do |t| t.libs << "test" @@ -11,8 +10,21 @@ end task :default => [:compile, :test] gem = Gem::Specification.load( File.dirname(__FILE__) + '/mini_racer.gemspec' ) -Rake::ExtensionTask.new( 'mini_racer_loader', gem ) -Rake::ExtensionTask.new( 'mini_racer_extension', gem ) + +if RUBY_ENGINE == "truffleruby" + task :compile do + # noop + end + + task :clean do + # noop + end +else + require 'rake/extensiontask' + Rake::ExtensionTask.new( 'mini_racer_loader', gem ) + Rake::ExtensionTask.new( 'mini_racer_extension', gem ) +end + # via http://blog.flavorjon.es/2009/06/easily-valgrind-gdb-your-ruby-c.html diff --git a/ext/mini_racer_extension/extconf.rb b/ext/mini_racer_extension/extconf.rb index 6a24e002..1ae6f474 100644 --- a/ext/mini_racer_extension/extconf.rb +++ b/ext/mini_racer_extension/extconf.rb @@ -1,4 +1,10 @@ require 'mkmf' + +if RUBY_ENGINE == "truffleruby" + File.write("Makefile", dummy_makefile($srcdir).join("")) + return +end + require_relative '../../lib/mini_racer/version' gem 'libv8-node', MiniRacer::LIBV8_NODE_VERSION require 'libv8-node' diff --git a/ext/mini_racer_loader/extconf.rb b/ext/mini_racer_loader/extconf.rb index 15d8ddf0..209340ca 100644 --- a/ext/mini_racer_loader/extconf.rb +++ b/ext/mini_racer_loader/extconf.rb @@ -1,5 +1,10 @@ require 'mkmf' +if RUBY_ENGINE == "truffleruby" + File.write("Makefile", dummy_makefile($srcdir).join("")) + return +end + extension_name = 'mini_racer_loader' dir_config extension_name diff --git a/lib/mini_racer.rb b/lib/mini_racer.rb index ad57c5a2..7f4a0d7c 100644 --- a/lib/mini_racer.rb +++ b/lib/mini_racer.rb @@ -1,14 +1,18 @@ require "mini_racer/version" -require "mini_racer_loader" require "pathname" -ext_filename = "mini_racer_extension.#{RbConfig::CONFIG['DLEXT']}" -ext_path = Gem.loaded_specs['mini_racer'].require_paths - .map { |p| (p = Pathname.new(p)).absolute? ? p : Pathname.new(__dir__).parent + p } -ext_found = ext_path.map { |p| p + ext_filename }.find { |p| p.file? } - -raise LoadError, "Could not find #{ext_filename} in #{ext_path.map(&:to_s)}" unless ext_found -MiniRacer::Loader.load(ext_found.to_s) +if RUBY_ENGINE == "truffleruby" + require "mini_racer/truffleruby" +else + require "mini_racer_loader" + ext_filename = "mini_racer_extension.#{RbConfig::CONFIG['DLEXT']}" + ext_path = Gem.loaded_specs['mini_racer'].require_paths + .map { |p| (p = Pathname.new(p)).absolute? ? p : Pathname.new(__dir__).parent + p } + ext_found = ext_path.map { |p| p + ext_filename }.find { |p| p.file? } + + raise LoadError, "Could not find #{ext_filename} in #{ext_path.map(&:to_s)}" unless ext_found + MiniRacer::Loader.load(ext_found.to_s) +end require "thread" require "json" diff --git a/lib/mini_racer/truffleruby.rb b/lib/mini_racer/truffleruby.rb new file mode 100644 index 00000000..6281a036 --- /dev/null +++ b/lib/mini_racer/truffleruby.rb @@ -0,0 +1,368 @@ +# frozen_string_literal: true + +module MiniRacer + + class Context + + class ExternalFunction + private + + def notify_v8 + name = @name.encode(::Encoding::UTF_8) + wrapped = lambda do |*args| + converted = @parent.send(:convert_js_to_ruby, args) + begin + result = @callback.call(*converted) + rescue Polyglot::ForeignException => e + e = RuntimeError.new(e.message) + e.set_backtrace(e.backtrace) + @parent.instance_variable_set(:@current_exception, e) + raise e + rescue => e + @parent.instance_variable_set(:@current_exception, e) + raise e + end + @parent.send(:convert_ruby_to_js, result) + end + + if @parent_object.nil? + # set global name to proc + result = @parent.eval_in_context('this') + result[name] = wrapped + else + parent_object_eval = @parent_object_eval.encode(::Encoding::UTF_8) + begin + result = @parent.eval_in_context(parent_object_eval) + rescue Polyglot::ForeignException, StandardError => e + raise ParseError, "Was expecting #{@parent_object} to be an object", e.backtrace + end + result[name] = wrapped + # set evaluated object results name to proc + end + end + end + + def heap_stats + { + total_physical_size: 0, + total_heap_size_executable: 0, + total_heap_size: 0, + used_heap_size: 0, + heap_size_limit: 0, + } + end + + def stop + if @entered + @context.stop + @stopped = true + stop_attached + end + end + + private + + @context_initialized = false + @use_strict = false + + def init_unsafe(isolate, snapshot) + unless defined?(Polyglot::InnerContext) + raise "TruffleRuby #{RUBY_ENGINE_VERSION} does not have support for inner contexts, use a more recent version" + end + + unless Polyglot.languages.include? "js" + warn "You also need to install the 'js' component with 'gu install js' on GraalVM 22.2+", uplevel: 0 if $VERBOSE + end + + @context = Polyglot::InnerContext.new(on_cancelled: -> { + raise ScriptTerminatedError, 'JavaScript was terminated (either by timeout or explicitly)' + }) + Context.instance_variable_set(:@context_initialized, true) + @js_object = @context.eval('js', 'Object') + @isolate_mutex = Mutex.new + @stopped = false + @entered = false + @has_entered = false + @current_exception = nil + if isolate && snapshot + isolate.instance_variable_set(:@snapshot, snapshot) + end + if snapshot + @snapshot = snapshot + elsif isolate + @snapshot = isolate.instance_variable_get(:@snapshot) + else + @snapshot = nil + end + @json_stringify_func, @is_object_or_array_func, @is_time_func, @js_date_to_time_func, @is_symbol_func, @js_symbol_to_symbol_func, @js_new_date_func, @js_new_array_func = eval_in_context <<-CODE + [ + (x) => { return JSON.stringify(x) }, + (x) => { return (x instanceof Object || x instanceof Array) && !(x instanceof Date) && !(x instanceof Function) }, + (x) => { return x instanceof Date }, + (x) => { return x.getTime(x) }, + (x) => { return typeof x === 'symbol' }, + (x) => { var r = x.description; return r === undefined ? 'undefined' : r }, + (x) => { return new Date(x) }, + (x) => { return new Array(x) }, + ] + CODE + end + + def dispose_unsafe + @context.close + end + + def eval_unsafe(str, filename) + @entered = true + if !@has_entered && @snapshot + snapshot_src = encode(@snapshot.instance_variable_get(:@source)) + begin + eval_in_context(snapshot_src, filename) + rescue Polyglot::ForeignException => e + raise RuntimeError, e.message, e.backtrace + end + end + @has_entered = true + raise RuntimeError, "TruffleRuby does not support eval after stop" if @stopped + Truffle::Type.rb_check_type(str, String) + Truffle::Type.rb_check_type(filename, String) unless filename.nil? + + str = encode(str) + begin + translate do + eval_in_context(str, filename) + end + rescue Polyglot::ForeignException => e + raise RuntimeError, e.message, e.backtrace + rescue ::RuntimeError => e + if @current_exception + e = @current_exception + @current_exception = nil + raise e + else + raise e, e.message + end + end + ensure + @entered = false + end + + def call_unsafe(function_name, *arguments) + @entered = true + if !@has_entered && @snapshot + src = encode(@snapshot.instance_variable_get(:source)) + begin + eval_in_context(src) + rescue Polyglot::ForeignException => e + raise RuntimeError, e.message, e.backtrace + end + end + @has_entered = true + raise RuntimeError, "TruffleRuby does not support call after stop" if @stopped + begin + translate do + function = eval_in_context(function_name) + function.call(*convert_ruby_to_js(arguments)) + end + rescue Polyglot::ForeignException => e + raise RuntimeError, e.message, e.backtrace + end + ensure + @entered = false + end + + def create_isolate_value + # Returning a dummy object since TruffleRuby does not have a 1-1 concept with isolate. + # However, code and ASTs are shared between contexts. + Isolate.new + end + + def isolate_mutex + @isolate_mutex + end + + def translate + convert_result_to_ruby yield + rescue Object => e + message = e.message + if @current_exception + raise @current_exception + elsif e.message.start_with?('SyntaxError:') + error_class = MiniRacer::ParseError + elsif e.is_a?(MiniRacer::ScriptTerminatedError) + error_class = MiniRacer::ScriptTerminatedError + else + error_class = MiniRacer::RuntimeError + end + + if error_class == MiniRacer::RuntimeError + bls = e.backtrace_locations&.select { |bl| bl.is_a?(Truffle::Interop::BacktraceLocation) && bl.source_location.language == 'js' } + if bls && !bls.empty? + if '(eval)' != bls[0].path + message = "#{e.message}\n at #{bls[0]}\n" + bls[1..].map(&:to_s).join("\n") + else + message = "#{e.message}\n" + bls.map(&:to_s).join("\n") + end + end + raise error_class, message + else + raise error_class, message, e.backtrace + end + end + + def convert_result_to_ruby(value) + if object_or_array?(value) + JSON.parse(js_json_stringify(value)) + else + convert_js_to_ruby(value) + end + end + + def convert_js_to_ruby(value) + case value + when true, false, Integer, Float + value + else + if value.nil? + nil + elsif value.respond_to?(:call) + MiniRacer::JavaScriptFunction.new + elsif value.respond_to?(:to_str) + value.to_str + elsif value.respond_to?(:to_ary) + value.to_ary.map do |e| + if e.respond_to?(:call) + nil + else + convert_js_to_ruby(e) + end + end + elsif time?(value) + js_date_to_time(value) + elsif symbol?(value) + js_symbol_to_symbol(value) + else + object = value + h = {} + object.instance_variables.each do |member| + v = object[member] + unless v.respond_to?(:call) + h[member.to_s] = convert_js_to_ruby(v) + end + end + h + end + end + end + + def js_json_stringify(val) + @json_stringify_func.call(val).to_s + end + + def object_or_array?(val) + @is_object_or_array_func.call(val) + end + + def time?(value) + @is_time_func.call(value) + end + + def js_date_to_time(value) + millis = @js_date_to_time_func.call(value) + Time.at(Rational(millis, 1000)) + end + + def symbol?(value) + @is_symbol_func.call(value) + end + + def js_symbol_to_symbol(value) + @js_symbol_to_symbol_func.call(value).to_s.to_sym + end + + def js_new_date(value) + @js_new_date_func.call(value) + end + + def js_new_array(size) + @js_new_array_func.call(size) + end + + def convert_ruby_to_js(value) + case value + when nil, true, false, Integer, Float, String + value + when Array + ary = js_new_array(value.size) + value.each_with_index do |v, i| + ary[i] = convert_ruby_to_js(v) + end + ary + when Hash + h = @js_object.new + value.each_pair do |k, v| + h[convert_ruby_to_js(k.to_s)] = convert_ruby_to_js(v) + end + h + when String + Truffle::Interop.as_truffle_string value + when Symbol + value.to_s + when Time + js_new_date(value.to_f * 1000) + when DateTime + js_new_date(value.to_time.to_f * 1000) + else + "Undefined Conversion" + end + end + + def encode(string) + raise ArgumentError unless string + string.encode(::Encoding::UTF_8) + end + + class_eval <<-'RUBY', "(mini_racer)", 1 + def eval_in_context(code, file = nil); code = ('"use strict";' + code) if Context.instance_variable_get(:@use_strict); @context.eval('js', code, file || '(mini_racer)'); end + RUBY + + end + + class Isolate + def init_with_snapshot(snapshot) + # TruffleRuby does not have a 1-1 concept with isolate. + # However, isolate can hold a snapshot, and code and ASTs are shared between contexts. + @snapshot = snapshot + end + + def low_memory_notification + GC.start + end + + def idle_notification(idle_time) + true + end + end + + class Platform + def self.set_flag_as_str!(flag) + Truffle::Type.rb_check_type(flag, String) + raise MiniRacer::PlatformAlreadyInitialized, "The platform is already initialized." if Context.instance_variable_get(:@context_initialized) + Context.instance_variable_set(:@use_strict, true) if "--use_strict" == flag + end + end + + class Snapshot + def load(str) + Truffle::Type.rb_check_type(str, String) + # Intentionally noop since TruffleRuby mocks the snapshot API + end + + def warmup_unsafe!(src) + Truffle::Type.rb_check_type(src, String) + # Intentionally noop since TruffleRuby mocks the snapshot API + # by replaying snapshot source before the first eval/call + self + end + end +end diff --git a/test/function_test.rb b/test/function_test.rb index 84e1a074..3aa13cf6 100644 --- a/test/function_test.rb +++ b/test/function_test.rb @@ -35,7 +35,8 @@ def test_throwing_function context.call('f', 1) end assert_equal err.message, 'Error: foo bar' - assert_match(/1:23/, err.backtrace[0]) + assert_match(/1:23/, err.backtrace[0]) unless RUBY_ENGINE == "truffleruby" + assert_match(/1:/, err.backtrace[0]) if RUBY_ENGINE == "truffleruby" end def test_args_types diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 9a7e84a3..fd46bf84 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -10,6 +10,7 @@ class MiniRacerTest < Minitest::Test def test_locale + skip "TruffleRuby does not have all js timezone by default" if RUBY_ENGINE == "truffleruby" val = MiniRacer::Context.new.eval("new Date('April 28 2021').toLocaleDateString('es-MX');") assert_equal '28/4/2021', val @@ -88,7 +89,7 @@ def test_it_can_stop begin Thread.new do - sleep 0.001 + sleep 0.01 context.stop end context.eval('while(true){}') @@ -102,6 +103,7 @@ def test_it_can_stop end def test_it_can_timeout_during_serialization + skip "TruffleRuby needs a fix for timing out during translation" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(timeout: 500) assert_raises(MiniRacer::ScriptTerminatedError) do @@ -302,12 +304,14 @@ def test_return_unknown end def test_max_memory + skip "TruffleRuby does not yet implement max_memory" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(max_memory: 200_000_000) assert_raises(MiniRacer::V8OutOfMemoryError) { context.eval('let s = 1000; var a = new Array(s); a.fill(0); while(true) {s *= 1.1; let n = new Array(Math.floor(s)); n.fill(0); a = a.concat(n); };') } end def test_max_memory_for_call + skip "TruffleRuby does not yet implement max_memory" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(max_memory: 100_000_000) context.eval(<<~JS) let s; @@ -399,6 +403,7 @@ def test_it_can_use_snapshots end def test_snapshot_size + skip "TruffleRuby does not yet implement snapshots" if RUBY_ENGINE == "truffleruby" snapshot = MiniRacer::Snapshot.new('var foo = "bar";') # for some reason sizes seem to change across runs, so we just @@ -407,6 +412,7 @@ def test_snapshot_size end def test_snapshot_dump + skip "TruffleRuby does not yet implement snapshots" if RUBY_ENGINE == "truffleruby" snapshot = MiniRacer::Snapshot.new('var foo = "bar";') dump = snapshot.dump @@ -581,13 +587,11 @@ def test_concurrent_access_over_the_same_isolate_1 def test_concurrent_access_over_the_same_isolate_2 isolate = MiniRacer::Isolate.new - equals_after_sleep = {} - # workaround Rubies prior to commit 475c8701d74ebebe # (Make SecureRandom support Ractor, 2020-09-04) SecureRandom.hex - (1..10).map do |i| + equals_after_sleep = (1..10).map do |i| Thread.new { random = SecureRandom.hex context = MiniRacer::Context.new(isolate: isolate) @@ -598,12 +602,12 @@ def test_concurrent_access_over_the_same_isolate_2 # cruby hashes are thread safe as long as you don't mess with the # same key in different threads - equals_after_sleep[i] = context.eval('a') == random + context.eval('a') == random } - end.each(&:join) + end.map(&:value) assert_equal 10, equals_after_sleep.size - assert equals_after_sleep.values.all? + assert equals_after_sleep.all? end def test_platform_set_flags_raises_an_exception_if_already_initialized @@ -711,6 +715,7 @@ def test_can_dispose_context end def test_estimated_size + skip "TruffleRuby does not yet implement heap_stats" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(timeout: 5) context.eval("let a='testing';") @@ -780,7 +785,7 @@ def test_eval_with_filename def test_estimated_size_when_disposed - context = MiniRacer::Context.new(timeout: 5) + context = MiniRacer::Context.new(timeout: 50) context.eval("let a='testing';") context.dispose @@ -805,7 +810,7 @@ def junk_it_up end def test_attached_recursion - context = MiniRacer::Context.new(timeout: 20) + context = MiniRacer::Context.new(timeout: 200) context.attach("a", proc{|a| a}) context.attach("b", proc{|a| a}) @@ -842,6 +847,7 @@ def test_isolate_is_nil_after_disposal end def test_heap_dump + skip "TruffleRuby does not yet implement heap_dump" if RUBY_ENGINE == "truffleruby" f = Tempfile.new("heap") path = f.path f.unlink @@ -873,6 +879,7 @@ def test_symbol_support end def test_cyclical_object_js + skip "TruffleRuby does not yet implement marshal_stack_depth" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(marshal_stack_depth: 5) context.attach("a", proc{|a| a}) @@ -880,6 +887,7 @@ def test_cyclical_object_js end def test_cyclical_array_js + skip "TruffleRuby does not yet implement marshal_stack_depth" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(marshal_stack_depth: 5) context.attach("a", proc{|a| a}) @@ -887,6 +895,7 @@ def test_cyclical_array_js end def test_cyclical_elem_in_array_js + skip "TruffleRuby does not yet implement marshal_stack_depth" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(marshal_stack_depth: 5) context.attach("a", proc{|a| a}) @@ -894,6 +903,7 @@ def test_cyclical_elem_in_array_js end def test_infinite_object_js + skip "TruffleRuby does not yet implement marshal_stack_depth" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(marshal_stack_depth: 5) context.attach("a", proc{|a| a}) @@ -911,6 +921,7 @@ def test_infinite_object_js end def test_deep_object_js + skip "TruffleRuby does not yet implement marshal_stack_depth" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new(marshal_stack_depth: 5) context.attach("a", proc{|a| a}) @@ -966,6 +977,7 @@ def test_promise end def test_webassembly + skip "TruffleRuby does not enable WebAssembly by default" if RUBY_ENGINE == "truffleruby" context = MiniRacer::Context.new() context.eval("let instance = null;") filename = File.expand_path("../support/add.wasm", __FILE__) diff --git a/test/test_forking.rb b/test/test_forking.rb index c849f0ed..8490b03c 100644 --- a/test/test_forking.rb +++ b/test/test_forking.rb @@ -23,6 +23,6 @@ def trigger_gc MiniRacer::Context.new.dispose -Process.wait fork { puts @ctx.eval("a"); @ctx.dispose; puts Process.pid; trigger_gc; puts "done #{Process.pid}" } - - +if Process.respond_to?(:fork) + Process.wait fork { puts @ctx.eval("a"); @ctx.dispose; puts Process.pid; trigger_gc; puts "done #{Process.pid}" } +end