crystalruby 0.1.12 → 0.1.13

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41e8370627d23725b55bb1916254b88690e93b384c94692d59d73b0cf5ceda03
4
- data.tar.gz: 7d7192ea627331a30f6cd9ff9dcc7482ed2b1e373f115cc0e3cfa19edd5db506
3
+ metadata.gz: 49ff0856e24d9a62e6dda014115652431074d4a45e1cb4270c92beeae2baa7ea
4
+ data.tar.gz: adaa86c18d254bf07704997050133db592dd60dda5ca9aed9f4c2bc34edfd7a9
5
5
  SHA512:
6
- metadata.gz: 56d644c482b04bb8f5b39b6626dda93079578f3f95809294a441e0fd1707aeb71eb253da7a549c351fe448750f6dd2acfdbda3782b37e5aaebb4019332c64d0c
7
- data.tar.gz: c529c61b9564f6d23dc551209ac7ce426d56a02424a970b57825f5e993953c376fa57afb3880db0647d581391e7d88881775cd0e2284408997fbaf4e8b37a690
6
+ metadata.gz: 599c397e9710bb7dfe528e3ec49b64cfbf61f0d73089dc3476d85587317c894367b09f8b47e062d5cc98646575512421f28860aadeceac62f05588a80830726b
7
+ data.tar.gz: 60b9896f92cf41389933c80bb21e5c4e899e54648575ca778dcea2ba881876ef3f2539bdc46ab1c22b3f234f1237019454f8726957f5858c83ba59a2065e406f
data/README.md CHANGED
@@ -43,33 +43,49 @@ E.g.
43
43
  require 'crystalruby'
44
44
  require 'benchmark'
45
45
 
46
- module Fibonnaci
46
+ module PrimeCounter
47
47
  crystalize [n: :int32] => :int32
48
- def fib_cr(n)
49
- a = 0
50
- b = 1
51
- n.times { a, b = b, a + b }
52
- a
48
+ def count_primes_upto_cr(n)
49
+ primes = 0
50
+ (2..n).each do |i|
51
+ is_prime = true
52
+ (2..Math.sqrt(i).to_i).each do |j|
53
+ if i % j == 0
54
+ is_prime = false
55
+ break
56
+ end
57
+ end
58
+ primes += 1
59
+ end
60
+ primes
53
61
  end
54
62
 
55
63
  module_function
56
64
 
57
- def fib_rb(n)
58
- a = 0
59
- b = 1
60
- n.times { a, b = b, a + b }
61
- a
65
+ def count_primes_upto_rb(n)
66
+ primes = 0
67
+ (2..n).each do |i|
68
+ is_prime = true
69
+ (2..Math.sqrt(i).to_i).each do |j|
70
+ if i % j == 0
71
+ is_prime = false
72
+ break
73
+ end
74
+ end
75
+ primes += 1
76
+ end
77
+ primes
62
78
  end
63
79
  end
64
80
 
65
- puts(Benchmark.realtime { 1_000_000.times { Fibonnaci.fib_rb(30) } })
66
- puts(Benchmark.realtime { 1_000_000.times { Fibonnaci.fib_cr(30) } })
67
-
81
+ include PrimeCounter
82
+ puts(Benchmark.realtime { count_primes_upto_rb(1000_000) })
83
+ puts(Benchmark.realtime { count_primes_upto_cr(1000_000) })
68
84
  ```
69
85
 
70
86
  ```bash
71
- 3.193121999996947 # Ruby
72
- 0.29086600001028273 # Crystal
87
+ 2.8195170001126826 # Ruby
88
+ 0.3402599999681115 # Crystal
73
89
  ```
74
90
 
75
91
  _Note_: The first run of the Crystal code will be slower, as it needs to compile the code first. The subsequent runs will be much faster.
@@ -121,10 +137,10 @@ end
121
137
  ### Crystal Compatible
122
138
 
123
139
  Some Crystal syntax is not valid Ruby, for methods of this form, we need to
124
- define our functions using a :raw parameter.
140
+ define our functions using a raw: true option
125
141
 
126
142
  ```ruby
127
- crystalize :raw, [a: :int, b: :int] => :int
143
+ crystalize [a: :int, b: :int] => :int, raw: true
128
144
  def add(a, b)
129
145
  <<~CRYSTAL
130
146
  c = 0_u64
@@ -453,6 +469,51 @@ CrystalRuby.compile!
453
469
 
454
470
  Then you can run this file as part of your build step, to ensure all Crystal code is compiled ahead of time.
455
471
 
472
+ ## Concurrency
473
+
474
+ While Ruby programs allow multi-threading, Crystal by default uses only a single thread, while utilising Fiber based cooperative-multitasking to allow for concurrent execution. This means that by default, Crystal libraries can not safely be invoked in parallel across multiple Ruby threads.
475
+
476
+ To safely expose this behaviour, `crystalruby` implements a Reactor, which multiplexes all Ruby calls to Crystal across a single thread. This way you can safely use `crystalruby` in a multi-threaded Ruby environment.
477
+
478
+ By default `crystalruby` methods are blocking/synchronous, this means that for blocking operations, a single crystalruby call can block the entire reactor.
479
+
480
+ To allow you to benefit from Crystal's fiber based concurrency, you can use use the `async` option on crystalized ruby methods. This allows several Ruby threads to invoke Crystal code simultaneously.
481
+
482
+ E.g.
483
+
484
+ ```ruby
485
+ module Sleeper
486
+ crystalize [] => :void
487
+ def sleep_sync
488
+ sleep 2
489
+ end
490
+
491
+ crystalize [] => :void, async: true
492
+ def sleep_async
493
+ sleep 2
494
+ end
495
+ end
496
+ ```
497
+
498
+ ```ruby
499
+ 5.times.map{ Thread.new{ Sleeper.sleep_sync } }.each(&:join) # Will take 10 seconds
500
+ 5.times.map{ Thread.new{ Sleeper.sleep_async } }.each(&:join) # Will take 2 seconds (the sleeps are processed concurrently)
501
+ ```
502
+
503
+ ### Reactor performance
504
+
505
+ There is a small amount of overhead to multiplexing calls across a single thread. Ad-hoc testing amounts this to be around 10 nanoseconds per call.
506
+ For most use-cases this overhead is negligible, especially if the bulk of your CPU heavy task occurs exclusively in Crystal code. However, if you are invoking very fast Crystal code from Ruby in a tight loop (e.g. a simple 1 + 2)
507
+ then the overhead of the reactor can become significant.
508
+
509
+ In this case you can use the `crystalruby` in a single-threaded mode to avoid the reactor overhead and greatly increase performance, with the caveat that _all_ calls to Crystal must occur from a single thread. If you Ruby program is already single-threaded this is not a problem.
510
+
511
+ ```ruby
512
+ CrystalRuby.configure do |config|
513
+ config.single_thread_mode = true
514
+ end
515
+ ```
516
+
456
517
  ## Troubleshooting
457
518
 
458
519
  The logic to detect when to JIT recompile is not robust and can end up in an inconsistent state. To remedy this it is useful to clear out all generated assets and build from scratch.
@@ -517,7 +578,7 @@ crystal_codegen_dir: "generated"
517
578
  debug: true
518
579
  ```
519
580
 
520
- Alternatively, these can be set programmatically:
581
+ Alternatively, these can be set programmatically, e.g:
521
582
 
522
583
  ```ruby
523
584
  CrystalRuby.configure do |config|
@@ -527,6 +588,9 @@ CrystalRuby.configure do |config|
527
588
  config.crystal_lib_name = "crlib"
528
589
  config.crystal_codegen_dir = "generated"
529
590
  config.debug = true
591
+ config.verbose = false
592
+ config.colorize_log_output = false
593
+ config.log_level = :info
530
594
  end
531
595
  ```
532
596
 
data/exe/crystalruby CHANGED
@@ -14,6 +14,8 @@ def init
14
14
  crystal_main_file: "main.cr"
15
15
  crystal_lib_name: "crlib"
16
16
  crystal_codegen_dir: "generated"
17
+ log_level: "info"
18
+ single_thread_mode: false
17
19
  debug: true
18
20
  YAML
19
21
 
@@ -0,0 +1,105 @@
1
+ module CrystalRuby
2
+ module Adapter
3
+ # Define a method to set the @crystalize proc if it doesn't already exist
4
+ def crystalize(raw: false, async: false, **options, &block)
5
+ (args,), returns = options.first
6
+ args ||= {}
7
+ raise "Arguments should be of the form name: :type. Got #{args}" unless args.is_a?(Hash)
8
+
9
+ @crystalize_next = { raw: raw, async: async, args: args, returns: returns, block: block }
10
+ end
11
+
12
+ def crystal(raw: false, &block)
13
+ inline_crystal_body = Template::InlineChunk.render(
14
+ {
15
+ module_name: name,
16
+ body: block.source.lines[
17
+ raw ? 2...-2 : 1...-1
18
+ ].join("\n")
19
+ }
20
+ )
21
+ CrystalRuby.write_chunk(self, body: inline_crystal_body)
22
+ end
23
+
24
+ def crtype(&block)
25
+ TypeBuilder.with_injected_type_dsl(self) do
26
+ TypeBuilder.build(&block)
27
+ end
28
+ end
29
+
30
+ def json(&block)
31
+ crtype(&block).serialize_as(:json)
32
+ end
33
+
34
+ def method_added(method_name)
35
+ if @crystalize_next
36
+ define_crystalized_method(method_name, instance_method(method_name))
37
+ @crystalize_next = nil
38
+ end
39
+ super
40
+ end
41
+
42
+ def singleton_method_added(method_name)
43
+ if @crystalize_next
44
+ define_crystalized_method(method_name, singleton_method(method_name))
45
+ @crystalize_next = nil
46
+ end
47
+ super
48
+ end
49
+
50
+ def define_crystalized_method(method_name, method)
51
+ CrystalRuby.log_debug("Defining crystalized method #{name}.#{method_name}")
52
+ CrystalRuby.instantiate_crystal_ruby! unless CrystalRuby.instantiated?
53
+
54
+ function_body = method.source.lines[
55
+ @crystalize_next[:raw] ? 2...-2 : 1...-1
56
+ ].join("\n")
57
+
58
+ MethodSource.instance_variable_get(:@lines_for_file).delete(method.source_location[0])
59
+ lib_fname = "#{name.downcase}_#{method_name}_#{Digest::MD5.hexdigest(function_body)}"
60
+ args, returns, block, async = @crystalize_next.values_at(:args, :returns, :block, :async)
61
+ args ||= {}
62
+ @crystalize_next = nil
63
+ function = CrystalRuby.build_function(self, lib_fname, method_name, args, returns, function_body)
64
+ CrystalRuby.write_chunk(self, name: function[:name], body: function[:body]) do
65
+ CrystalRuby.log_debug("attaching #{lib_fname} to #{name}")
66
+ extend FFI::Library
67
+ ffi_lib CrystalRuby.config.crystal_lib_dir / CrystalRuby.config.crystal_lib_name
68
+ if async
69
+ attach_function lib_fname, "#{lib_fname}_async", function[:ffi_types] + %i[int pointer], :void,
70
+ blocking: true
71
+ else
72
+ attach_function lib_fname, function[:ffi_types], function[:ffi_ret_type], blocking: true
73
+ end
74
+ if block
75
+ [self, singleton_class].each do |receiver|
76
+ receiver.prepend(Module.new do
77
+ define_method(method_name, &block)
78
+ end)
79
+ end
80
+ end
81
+ end
82
+
83
+ [self, singleton_class].each do |receiver|
84
+ receiver.define_method(method_name) do |*args|
85
+ CrystalRuby.build! unless CrystalRuby.compiled?
86
+ unless CrystalRuby.attached?
87
+ CrystalRuby.attach!
88
+ return send(method_name, *args) if block
89
+ end
90
+ args.each_with_index do |arg, i|
91
+ args[i] = function[:arg_maps][i][arg] if function[:arg_maps][i]
92
+ end
93
+
94
+ result = Reactor.schedule_work!(self, lib_fname, *args, function[:ffi_ret_type], async: async)
95
+
96
+ if function[:retval_map]
97
+ function[:retval_map][result]
98
+ else
99
+ result
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,29 +1,31 @@
1
1
  require "open3"
2
2
  require "tmpdir"
3
+ require "shellwords"
3
4
 
4
5
  module CrystalRuby
5
6
  module Compilation
6
7
  def self.compile!(
7
- src: config.crystal_src_dir_abs / config.crystal_main_file,
8
- lib: config.crystal_lib_dir_abs / config.crystal_lib_name,
9
- verbose: config.verbose,
10
- debug: config.debug
8
+ src: CrystalRuby.config.crystal_src_dir_abs / CrystalRuby.config.crystal_main_file,
9
+ lib: CrystalRuby.config.crystal_lib_dir_abs / CrystalRuby.config.crystal_lib_name,
10
+ verbose: CrystalRuby.config.verbose,
11
+ debug: CrystalRuby.config.debug
11
12
  )
12
- Dir.chdir(config.crystal_src_dir_abs) do
13
+ Dir.chdir(CrystalRuby.config.crystal_src_dir_abs) do
13
14
  compile_command = compile_command!(verbose: verbose, debug: debug, lib: lib, src: src)
14
15
  link_command = link_cmd!(verbose: verbose, lib: lib, src: src)
15
16
 
16
- puts "[crystalruby] Compiling Crystal code: #{compile_command}" if verbose
17
+ CrystalRuby.log_debug "Compiling Crystal code: #{compile_command}"
17
18
  unless system(compile_command)
18
- puts "Failed to build Crystal object file."
19
+ CrystalRuby.log_error "Failed to build Crystal object file."
19
20
  return false
20
21
  end
21
22
 
22
- puts "[crystalruby] Linking Crystal code: #{link_command}" if verbose
23
+ CrystalRuby.log_debug "Linking Crystal code: #{link_command}"
23
24
  unless system(link_command)
24
- puts "Failed to link Crystal library."
25
+ CrystalRuby.log_error "Failed to link Crystal library."
25
26
  return false
26
27
  end
28
+ CrystalRuby.log_info "Compilation successful"
27
29
  end
28
30
 
29
31
  true
@@ -35,6 +37,9 @@ module CrystalRuby
35
37
  debug_flag = debug ? "" : "--release --no-debug"
36
38
  redirect_output = " > /dev/null " unless verbose
37
39
 
40
+ src = Shellwords.escape(src)
41
+ lib = Shellwords.escape(lib)
42
+
38
43
  %(crystal build #{verbose_flag} #{debug_flag} --cross-compile -o #{lib} #{src}#{redirect_output})
39
44
  end
40
45
  end
@@ -48,12 +53,27 @@ module CrystalRuby
48
53
  result = nil
49
54
 
50
55
  Dir.mktmpdir do |tmp|
51
- output, status = Open3.capture2("crystal build --verbose #{src} -o #{Pathname.new(tmp) / "main"}")
52
- unless status.success?
53
- puts "Failed to compile the Crystal code."
54
- exit 1
56
+ CrystalRuby.log_debug "Building link command"
57
+ src = Shellwords.escape(src)
58
+ lib_dir = Shellwords.escape(CrystalRuby.config.crystal_src_dir_abs / "lib")
59
+ escaped_output_path = Shellwords.escape(Pathname.new(tmp) / "main")
60
+
61
+ command = "timeout -k 2s 2s bash -c \"export CRYSTAL_PATH=$(crystal env CRYSTAL_PATH):#{lib_dir} && crystal build --verbose #{src} -o #{escaped_output_path} \""
62
+
63
+ output = ""
64
+ pid = nil
65
+
66
+ CrystalRuby.log_debug "Running command: #{command}"
67
+
68
+ Open3.popen2e(command) do |_stdin, stdout_and_stderr, _wait_thr|
69
+ while line = stdout_and_stderr.gets
70
+ puts line if verbose
71
+ output += line # Capture the output
72
+ end
55
73
  end
56
74
 
75
+ CrystalRuby.log_debug "Parsing link command"
76
+
57
77
  # Parse the output to find the last invocation of the C compiler, which is likely the linking stage
58
78
  # and strip off the targets that the crystal compiler added.
59
79
  link_command_suffix = output.lines.select { |line| line.strip.start_with?("cc") }.last.strip[/.*(-o.*)/, 1]
@@ -70,6 +90,8 @@ module CrystalRuby
70
90
 
71
91
  result
72
92
  end
93
+ rescue StandardError
94
+ ""
73
95
  end
74
96
  end
75
97
  end
@@ -1,15 +1,24 @@
1
1
  require "singleton"
2
2
  require "yaml"
3
+ require "logger"
3
4
 
4
5
  module CrystalRuby
5
6
  def self.config
6
7
  Config.instance
7
8
  end
8
9
 
10
+ %w[debug info warn error].each do |level|
11
+ define_singleton_method("log_#{level}") do |*msg|
12
+ prefix = config.colorize_log_output ? "\e[33mcrystalruby\e[0m\e[90m [#{Thread.current.object_id}]\e[0m" : "[crystalruby] #{Thread.current.object_id}"
13
+
14
+ config.logger.send(level, "#{prefix} #{msg.join(", ")}")
15
+ end
16
+ end
17
+
9
18
  # Define a nested Config class
10
19
  class Config
11
20
  include Singleton
12
- attr_accessor :debug, :verbose
21
+ attr_accessor :debug, :verbose, :logger, :colorize_log_output, :single_thread_mode
13
22
 
14
23
  def initialize
15
24
  @debug = true
@@ -27,6 +36,11 @@ module CrystalRuby
27
36
  @crystal_project_root = config.fetch("crystal_project_root", Pathname.pwd)
28
37
  @debug = config.fetch("debug", true)
29
38
  @verbose = config.fetch("verbose", false)
39
+ @single_thread_mode = config.fetch("single_thread_mode", false)
40
+ @colorize_log_output = config.fetch("colorize_log_output", false)
41
+ @log_level = config.fetch("log_level", ENV.fetch("CRYSTALRUBY_LOG_LEVEL", "info"))
42
+ @logger = Logger.new(STDOUT)
43
+ @logger.level = Logger.const_get(@log_level.to_s.upcase)
30
44
  end
31
45
 
32
46
  %w[crystal_main_file crystal_lib_name crystal_project_root].each do |method_name|
@@ -56,6 +70,11 @@ module CrystalRuby
56
70
  @paths_cache[method_name] ||= Pathname.new instance_variable_get(:"@#{method_name}")
57
71
  end
58
72
  end
73
+
74
+ def log_level=(level)
75
+ @log_level = level
76
+ @logger.level = Logger.const_get(level.to_s.upcase)
77
+ end
59
78
  end
60
79
 
61
80
  def self.configure
@@ -0,0 +1,184 @@
1
+ module CrystalRuby
2
+ module Reactor
3
+ module_function
4
+
5
+ class ReactorStoppedException < StandardError; end
6
+ class SingleThreadViolation < StandardError; end
7
+
8
+ REACTOR_QUEUE = Queue.new
9
+
10
+ # We maintain a map of threads, each with a mutex, condition variable, and result
11
+ THREAD_MAP = Hash.new do |h, tid_or_thread, tid = tid_or_thread|
12
+ if tid_or_thread.is_a?(Thread)
13
+ ObjectSpace.define_finalizer(tid_or_thread) do
14
+ THREAD_MAP.delete(tid_or_thread)
15
+ THREAD_MAP.delete(tid_or_thread.object_id)
16
+ end
17
+ tid = tid_or_thread.object_id
18
+ end
19
+
20
+ h[tid] = {
21
+ mux: Mutex.new,
22
+ cond: ConditionVariable.new,
23
+ result: nil,
24
+ thread_id: tid
25
+ }
26
+ h[tid_or_thread] = h[tid] if tid_or_thread.is_a?(Thread)
27
+ end
28
+
29
+ # We memoize callbacks, once per return type
30
+ CALLBACKS_MAP = Hash.new do |h, rt|
31
+ h[rt] = FFI::Function.new(:void, [:int, *(rt == :void ? [] : [rt])]) do |tid, ret|
32
+ THREAD_MAP[tid][:error] = nil
33
+ THREAD_MAP[tid][:result] = ret
34
+ THREAD_MAP[tid][:cond].signal
35
+ end
36
+ end
37
+
38
+ ERROR_CALLBACK = FFI::Function.new(:void, %i[string string int]) do |error_type, message, tid|
39
+ error_type = error_type.to_sym
40
+ is_exception_type = Object.const_defined?(error_type) && Object.const_get(error_type).ancestors.include?(Exception)
41
+ error_type = is_exception_type ? Object.const_get(error_type) : RuntimeError
42
+ tid = tid.zero? ? Reactor.current_thread_id : tid
43
+ THREAD_MAP[tid][:error] = error_type.new(message)
44
+ THREAD_MAP[tid][:result] = nil
45
+ THREAD_MAP[tid][:cond].signal
46
+ end
47
+
48
+ def thread_conditions
49
+ THREAD_MAP[Thread.current]
50
+ end
51
+
52
+ def await_result!
53
+ mux, cond = thread_conditions.values_at(:mux, :cond)
54
+ cond.wait(mux)
55
+ raise THREAD_MAP[thread_id][:error] if THREAD_MAP[thread_id][:error]
56
+
57
+ THREAD_MAP[thread_id][:result]
58
+ end
59
+
60
+ def thread_id
61
+ Thread.current.object_id
62
+ end
63
+
64
+ def yield!(time: 0)
65
+ Thread.new do
66
+ sleep time
67
+ schedule_work!(Reactor, :yield, nil, async: false, blocking: false)
68
+ end
69
+ end
70
+
71
+ def current_thread_id=(val)
72
+ @current_thread_id = val
73
+ end
74
+
75
+ def current_thread_id
76
+ @current_thread_id
77
+ end
78
+
79
+ def schedule_work!(receiver, op_name, *args, return_type, blocking: true, async: true)
80
+ raise ReactorStoppedException, "Reactor has been terminated, no new work can be scheduled" if @stopped
81
+
82
+ if @single_thread_mode
83
+ unless Thread.current.object_id == @main_thread_id
84
+ raise SingleThreadViolation,
85
+ "Single thread mode is enabled, cannot run in multi-threaded mode. " \
86
+ "Reactor was started from: #{@main_thread_id}, then called from #{Thread.current.object_id}"
87
+ end
88
+
89
+ return receiver.send(op_name, *args)
90
+ end
91
+
92
+ tvars = thread_conditions
93
+ tvars[:mux].synchronize do
94
+ REACTOR_QUEUE.push(
95
+ case true
96
+ when async
97
+ lambda {
98
+ receiver.send(
99
+ op_name, *args, tvars[:thread_id],
100
+ CALLBACKS_MAP[return_type]
101
+ )
102
+ yield!(time: 0)
103
+ }
104
+ when blocking
105
+ lambda {
106
+ tvars[:error] = nil
107
+ Reactor.current_thread_id = tvars[:thread_id]
108
+ begin
109
+ result = receiver.send(op_name, *args)
110
+ rescue StandardError => e
111
+ tvars[:error] = e
112
+ end
113
+ tvars[:result] = result unless tvars[:error]
114
+ tvars[:cond].signal
115
+ }
116
+ else
117
+ lambda {
118
+ outstanding_jobs = receiver.send(op_name, *args)
119
+ yield!(time: 0.01) unless outstanding_jobs.zero?
120
+ }
121
+ end
122
+ )
123
+ return await_result! if blocking
124
+ end
125
+ end
126
+
127
+ def init_single_thread_mode!
128
+ @single_thread_mode = true
129
+ @main_thread_id = Thread.current.object_id
130
+ init_crystal_ruby!
131
+ end
132
+
133
+ def init_crystal_ruby!
134
+ attach_lib!
135
+ init(ERROR_CALLBACK)
136
+ end
137
+
138
+ def attach_lib!
139
+ CrystalRuby.log_debug("Attaching lib")
140
+ extend FFI::Library
141
+ ffi_lib CrystalRuby.config.crystal_lib_dir / CrystalRuby.config.crystal_lib_name
142
+ attach_function :init, [:pointer], :void
143
+ attach_function :stop, [], :void
144
+ attach_function :yield, %i[], :int
145
+ end
146
+
147
+ def stop!
148
+ CrystalRuby.log_debug("Stopping reactor")
149
+ @stopped = true
150
+ sleep 1
151
+ @main_loop&.kill
152
+ @main_loop = nil
153
+ CrystalRuby.log_debug("Reactor stopped")
154
+ end
155
+
156
+ def running?
157
+ @main_loop&.alive?
158
+ end
159
+
160
+ def start!
161
+ @main_loop ||= begin
162
+ attach_lib!
163
+ Thread.new do
164
+ CrystalRuby.log_debug("Starting reactor")
165
+ init(ERROR_CALLBACK)
166
+ CrystalRuby.log_debug("CrystalRuby initialized")
167
+ loop do
168
+ REACTOR_QUEUE.pop[]
169
+ break if @stopped
170
+ end
171
+ stop
172
+ CrystalRuby.log_debug("Stopping reactor")
173
+ rescue StandardError => e
174
+ puts "Error: #{e}"
175
+ puts e.backtrace
176
+ end
177
+ end
178
+ end
179
+
180
+ at_exit do
181
+ @stopped = true
182
+ end
183
+ end
184
+ end
@@ -2,11 +2,12 @@ module CrystalRuby
2
2
  module Template
3
3
  Dir[File.join(File.dirname(__FILE__), "templates", "*.cr")].each do |file|
4
4
  template_name = File.basename(file, File.extname(file)).split("_").map(&:capitalize).join
5
- const_set(template_name, File.read(file))
6
- end
7
-
8
- def self.render(template, context)
9
- template % context
5
+ template_value = File.read(file)
6
+ template_value.define_singleton_method(:render) do |context|
7
+ CrystalRuby.log_debug("Template.render: #{template_name}")
8
+ self % context
9
+ end
10
+ const_set(template_name, template_value)
10
11
  end
11
12
  end
12
13
  end
@@ -12,7 +12,6 @@ end
12
12
  # This function is the entry point for the CrystalRuby code, exposed through FFI.
13
13
  # We apply some basic error handling here, and convert the arguments and return values
14
14
  # to ensure that we are using Crystal native types.
15
-
16
15
  fun %{lib_fn_name}(%{lib_fn_args}): %{lib_fn_ret_type}
17
16
  begin
18
17
  %{convert_lib_args}
@@ -20,10 +19,44 @@ fun %{lib_fn_name}(%{lib_fn_args}): %{lib_fn_ret_type}
20
19
  return_value = %{module_name}.%{fn_name}(%{arg_names})
21
20
  return %{convert_return_type}
22
21
  rescue ex
23
- CrystalRuby.report_error("RuntimeError", ex.message.to_s)
22
+ CrystalRuby.report_error("RuntimeError", ex.message.to_s, 0)
24
23
  end
25
24
  rescue ex
26
- CrystalRuby.report_error("ArgumentError", ex.message.to_s)
25
+ CrystalRuby.report_error("ArgumentError", ex.message.to_s, 0)
27
26
  end
28
27
  return %{error_value}
29
28
  end
29
+
30
+
31
+ # This function is the async entry point for the CrystalRuby code, exposed through FFI.
32
+ # We apply some basic error handling here, and convert the arguments and return values
33
+ # to ensure that we are using Crystal native types.
34
+ fun %{lib_fn_name}_async(%{lib_fn_args} thread_id: UInt32, callback : %{callback_type}): Void
35
+ begin
36
+ %{convert_lib_args}
37
+ CrystalRuby.increment_task_counter
38
+ spawn do
39
+ begin
40
+ return_value = %{module_name}.%{fn_name}(%{arg_names})
41
+ converted = %{convert_return_type}
42
+ CrystalRuby.queue_callback(->{
43
+ %{callback_call}
44
+ CrystalRuby.decrement_task_counter
45
+ })
46
+ rescue ex
47
+ exception = ex.message.to_s
48
+ CrystalRuby.queue_callback(->{
49
+ CrystalRuby.error_callback.call("RuntimeError".to_unsafe, exception.to_unsafe, thread_id)
50
+ CrystalRuby.decrement_task_counter
51
+ })
52
+ end
53
+ end
54
+ rescue ex
55
+
56
+ exception = ex.message.to_s
57
+ CrystalRuby.queue_callback(->{
58
+ CrystalRuby.error_callback.call("RuntimeError".to_unsafe, ex.message.to_s.to_unsafe, thread_id)
59
+ CrystalRuby.decrement_task_counter
60
+ })
61
+ end
62
+ end
@@ -1,16 +1,24 @@
1
- FAKE_ARG = "crystal"
1
+ alias ErrorCallback = (Pointer(UInt8), Pointer(UInt8), UInt32 -> Void)
2
2
 
3
- alias ErrorCallback = (Pointer(UInt8), Pointer(UInt8) -> Void)
3
+ ARGV1 = "crystalruby"
4
+ CALLBACK_MUX = Mutex.new
4
5
 
5
6
  module CrystalRuby
6
7
  # Initializing Crystal Ruby invokes init on the Crystal garbage collector.
7
8
  # We need to be sure to only do this once.
8
9
  @@initialized = false
9
10
 
10
- # We won't natively handle Crystal Exceptions in Ruby
11
- # Instead, we'll catch them in Crystal, and explicitly expose them to Ruby via
12
- # the error_callback.
13
- @@error_callback
11
+ # Our Ruby <-> Crystal Reactor uses Fibers, with callbacks to allow
12
+ # multiple concurrent Crystal operations to be queued
13
+ @@callbacks = [] of Proc(Nil)
14
+
15
+ # We only continue to yield to the Crystal scheduler from Ruby
16
+ # while there are outstanding tasks.
17
+ @@task_counter : Atomic(Int32) = Atomic.new(0)
18
+
19
+ # We can override the error callback to catch errors in Crystal,
20
+ # and explicitly expose them to Ruby.
21
+ @@error_callback : ErrorCallback = ->(t : UInt8* , s : UInt8*, tid : UInt32){ puts "Error: #{t}:#{s}" }
14
22
 
15
23
  # This is the entry point for instantiating CrystalRuby
16
24
  # We:
@@ -19,25 +27,91 @@ module CrystalRuby
19
27
  # 3. Call the Crystal main function
20
28
  def self.init(error_callback : ErrorCallback)
21
29
  return if @@initialized
30
+ @@initialized = true
22
31
  GC.init
23
- @@initialized = true
32
+ argv_ptr = ARGV1.to_unsafe
33
+ Crystal.main(0, pointerof(argv_ptr))
24
34
  @@error_callback = error_callback
25
- ptr = FAKE_ARG.to_unsafe
26
- LibCrystalMain.__crystal_main(1, pointerof(ptr))
27
35
  end
28
36
 
29
37
  # Explicit error handling (triggers exception within Ruby on the same thread)
30
- def self.report_error(error_type : String, str : String)
31
- if handler = @@error_callback
32
- handler.call(error_type.to_unsafe, str.to_unsafe)
38
+ def self.report_error(error_type : String, str : String, thread_id : UInt32, )
39
+ @@error_callback.call(error_type.to_unsafe, str.to_unsafe, thread_id)
40
+ end
41
+
42
+ def self.error_callback : ErrorCallback
43
+ @@error_callback
44
+ end
45
+
46
+ # New async task started
47
+ def self.increment_task_counter
48
+ @@task_counter.add(1)
49
+ end
50
+
51
+ # Async task finished
52
+ def self.decrement_task_counter
53
+ @@task_counter.sub(1)
54
+ end
55
+
56
+ # Get number of outstanding tasks
57
+ def self.get_task_counter : Int32
58
+ @@task_counter.get()
59
+ end
60
+
61
+ # Queue a callback for an async task
62
+ def self.queue_callback(callback : Proc(Nil))
63
+ CALLBACK_MUX.synchronize do
64
+ @@callbacks << callback
65
+ end
66
+ end
67
+
68
+ # Get number of queued callbacks
69
+ def self.count_callbacks : Int32
70
+ @@callbacks.size
71
+ end
72
+
73
+ # Flush all callbacks
74
+ def self.flush_callbacks : Int32
75
+ CALLBACK_MUX.synchronize do
76
+ count = @@callbacks.size
77
+ @@callbacks.each do |callback|
78
+ result = callback.call()
79
+ end
80
+ @@callbacks.clear
33
81
  end
82
+ get_task_counter
34
83
  end
35
84
  end
36
85
 
86
+ # Initialize CrystalRuby
37
87
  fun init(cb : ErrorCallback): Void
38
88
  CrystalRuby.init(cb)
39
89
  end
40
90
 
91
+ fun stop(): Void
92
+ GC.disable
93
+ end
94
+
95
+ # Yield to the Crystal scheduler from Ruby
96
+ # If there's callbacks to process, we flush them
97
+ # Otherwise, we yield to the Crystal scheduler and let Ruby know
98
+ # how many outstanding tasks still remain (it will stop yielding to Crystal
99
+ # once this figure reaches 0).
100
+ fun yield() : Int32
101
+ if CrystalRuby.count_callbacks == 0
102
+
103
+ Fiber.yield
104
+
105
+ # TODO: We should apply backpressure here to prevent busy waiting if the number of outstanding tasks is not decreasing.
106
+ # Use a simple exponential backoff strategy, to increase the time between each yield up to a maximum of 1 second.
107
+
108
+ CrystalRuby.get_task_counter
109
+ else
110
+ CrystalRuby.flush_callbacks()
111
+ end
112
+ end
113
+
114
+
41
115
  # This is where we define all our Crystal modules and types
42
116
  # derived from their Ruby counterparts.
43
117
  %{type_modules}
@@ -52,14 +52,19 @@ module CrystalRuby
52
52
  string: '"".to_unsafe' # String type
53
53
  }
54
54
 
55
- C_TYPE_MAP = CRYSTAL_TYPE_MAP.merge({
56
- string: "UInt8*"
57
- })
55
+ C_TYPE_MAP = CRYSTAL_TYPE_MAP.merge(
56
+ {
57
+ string: "Pointer(UInt8)"
58
+ }
59
+ )
58
60
 
59
61
  C_TYPE_CONVERSIONS = {
60
62
  string: {
61
63
  from: "String.new(%s)",
62
64
  to: "%s.to_unsafe"
65
+ },
66
+ void: {
67
+ to: "nil"
63
68
  }
64
69
  }
65
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crystalruby
4
- VERSION = "0.1.12"
4
+ VERSION = "0.1.13"
5
5
  end
data/lib/crystalruby.rb CHANGED
@@ -13,115 +13,37 @@ require_relative "crystalruby/types"
13
13
  require_relative "crystalruby/typebuilder"
14
14
  require_relative "crystalruby/template"
15
15
  require_relative "crystalruby/compilation"
16
+ require_relative "crystalruby/adapter"
17
+ require_relative "crystalruby/reactor"
16
18
 
17
19
  module CrystalRuby
18
20
  CR_SRC_FILES_PATTERN = "./**/*.cr"
19
- # Define a method to set the @crystalize proc if it doesn't already exist
20
- def crystalize(type = :src, **options, &block)
21
- (args,), returns = options.first
22
- args ||= {}
23
- raise "Arguments should be of the form name: :type. Got #{args}" unless args.is_a?(Hash)
24
-
25
- @crystalize_next = { raw: type.to_sym == :raw, args: args, returns: returns, block: block }
26
- end
27
-
28
- def crystal(type = :src, &block)
29
- inline_crystal_body = Template.render(
30
- Template::InlineChunk,
31
- {
32
- module_name: name,
33
- body: block.source.lines[
34
- type == :raw ? 2...-2 : 1...-1
35
- ].join("\n")
36
- }
37
- )
38
- CrystalRuby.write_chunk(self, body: inline_crystal_body)
39
- end
40
-
41
- def crtype(&block)
42
- TypeBuilder.with_injected_type_dsl(self) do
43
- TypeBuilder.build(&block)
44
- end
45
- end
46
-
47
- def json(&block)
48
- crtype(&block).serialize_as(:json)
49
- end
50
-
51
- def method_added(method_name)
52
- if @crystalize_next
53
- attach_crystalized_method(method_name)
54
- @crystalize_next = nil
55
- end
56
- super
57
- end
58
-
59
- def config
60
- CrystalRuby.config
61
- end
62
-
63
- def attach_crystalized_method(method_name)
64
- CrystalRuby.instantiate_crystal_ruby! unless CrystalRuby.instantiated?
65
-
66
- function_body = instance_method(method_name).source.lines[
67
- @crystalize_next[:raw] ? 2...-2 : 1...-1
68
- ].join("\n")
69
-
70
- fname = "#{name.downcase}_#{method_name}"
71
- args, returns, block = @crystalize_next.values_at(:args, :returns, :block)
72
- args ||= {}
73
- @crystalize_next = nil
74
- function = build_function(self, method_name, args, returns, function_body)
75
- CrystalRuby.write_chunk(self, name: function[:name], body: function[:body]) do
76
- extend FFI::Library
77
- ffi_lib config.crystal_lib_dir / config.crystal_lib_name
78
- attach_function method_name, fname, function[:ffi_types], function[:return_ffi_type]
79
- if block
80
- [singleton_class, self].each do |receiver|
81
- receiver.prepend(Module.new do
82
- define_method(method_name, &block)
83
- end)
84
- end
85
- end
86
- end
87
-
88
- [singleton_class, self].each do |receiver|
89
- receiver.prepend(Module.new do
90
- define_method(method_name) do |*args|
91
- CrystalRuby.build! unless CrystalRuby.compiled?
92
- unless CrystalRuby.attached?
93
- CrystalRuby.attach!
94
- return send(method_name, *args) if block
95
- end
96
- args.each_with_index do |arg, i|
97
- args[i] = function[:arg_maps][i][arg] if function[:arg_maps][i]
98
- end
99
- result = super(*args)
100
- if function[:retval_map]
101
- function[:retval_map][result]
102
- else
103
- result
104
- end
105
- end
106
- end)
107
- end
108
- end
21
+ CR_COMPILE_MUX = Mutex.new
109
22
 
110
23
  module_function
111
24
 
112
- def build_function(owner, name, args, returns, body)
25
+ def build_function(owner, lib_fn_name, name, args, returns, body)
26
+ log_debug(".build_function #{{ owner: owner, name: name, args: args, returns: returns, body: body[0..50] }}")
27
+
113
28
  arg_types = args.transform_values(&method(:build_type_map))
114
29
  return_type = build_type_map(returns)
115
- function_body = Template.render(
116
- Template::Function,
30
+ lib_fn_args = arg_types.map { |k, arg_type| "_#{k} : #{arg_type[:lib_type]}" }.join(",")
31
+ lib_fn_args += ", " unless lib_fn_args.empty?
32
+ lib_fn_arg_names = arg_types.map { |k, _arg_type| "_#{k}" }.join(",")
33
+ lib_fn_arg_names += ", " unless lib_fn_args.empty?
34
+
35
+ function_body = Template::Function.render(
117
36
  {
118
37
  module_name: owner.name,
119
- lib_fn_name: "#{owner.name.downcase}_#{name}",
38
+ lib_fn_name: lib_fn_name,
120
39
  fn_name: name,
121
40
  fn_body: body,
41
+ callback_call: returns == :void ? "callback.call(thread_id)" : "callback.call(thread_id, converted)",
42
+ callback_type: return_type[:ffi_type] == :void ? "UInt32 -> Void" : " UInt32, #{return_type[:lib_type]} -> Void",
122
43
  fn_args: arg_types.map { |k, arg_type| "#{k} : #{arg_type[:crystal_type]}" }.join(","),
123
44
  fn_ret_type: return_type[:crystal_type],
124
- lib_fn_args: arg_types.map { |k, arg_type| "_#{k}: #{arg_type[:lib_type]}" }.join(","),
45
+ lib_fn_args: lib_fn_args,
46
+ lib_fn_arg_names: lib_fn_arg_names,
125
47
  lib_fn_ret_type: return_type[:lib_type],
126
48
  convert_lib_args: arg_types.map do |k, arg_type|
127
49
  "#{k} = #{arg_type[:convert_lib_to_crystal_type]["_#{k}"]}"
@@ -137,7 +59,7 @@ module CrystalRuby
137
59
  retval_map: returns.is_a?(Types::TypeSerializer) ? ->(rv) { returns.prepare_retval(rv) } : nil,
138
60
  ffi_types: arg_types.map { |_k, arg_type| arg_type[:ffi_type] },
139
61
  arg_maps: arg_types.map { |_k, arg_type| arg_type[:mapper] },
140
- return_ffi_type: return_type[:return_ffi_type]
62
+ ffi_ret_type: return_type[:ffi_ret_type]
141
63
  }
142
64
  end
143
65
 
@@ -148,7 +70,7 @@ module CrystalRuby
148
70
 
149
71
  {
150
72
  ffi_type: ffi_type(crystalruby_type),
151
- return_ffi_type: ffi_type(crystalruby_type),
73
+ ffi_ret_type: ffi_type(crystalruby_type),
152
74
  crystal_type: crystal_type(crystalruby_type),
153
75
  lib_type: lib_type(crystalruby_type),
154
76
  error_value: error_value(crystalruby_type),
@@ -232,8 +154,6 @@ module CrystalRuby
232
154
  )
233
155
  end
234
156
 
235
- attach_crystal_ruby_lib! if compiled?
236
-
237
157
  return if File.exist?(config.crystal_src_dir / "shard.yml")
238
158
 
239
159
  IO.write("#{config.crystal_src_dir}/shard.yml", <<~YAML)
@@ -242,20 +162,6 @@ module CrystalRuby
242
162
  YAML
243
163
  end
244
164
 
245
- def attach_crystal_ruby_lib!
246
- extend FFI::Library
247
- ffi_lib config.crystal_lib_dir / config.crystal_lib_name
248
- attach_function "init!", :init, [:pointer], :void
249
- send(:remove_const, :ErrorCallback) if defined?(ErrorCallback)
250
- const_set(:ErrorCallback, FFI::Function.new(:void, %i[string string]) do |error_type, message|
251
- error_type = error_type.to_sym
252
- is_exception_type = Object.const_defined?(error_type) && Object.const_get(error_type).ancestors.include?(Exception)
253
- error_type = is_exception_type ? Object.const_get(error_type) : RuntimeError
254
- raise error_type.new(message)
255
- end)
256
- init!(ErrorCallback)
257
- end
258
-
259
165
  def self.instantiated?
260
166
  @instantiated
261
167
  end
@@ -297,27 +203,38 @@ module CrystalRuby
297
203
  end
298
204
 
299
205
  def self.build!
300
- File.write config.crystal_codegen_dir_abs / "index.cr", Template.render(
301
- Template::Index,
302
- type_modules: type_modules,
303
- requires: requires
304
- )
305
- if @compiled = CrystalRuby::Compilation.compile!(
306
- verbose: config.verbose,
307
- debug: config.debug
308
- )
309
- IO.write(digest_file_name, get_cr_src_files_digest)
310
- attach_crystal_ruby_lib!
311
- else
312
- File.delete(digest_file_name) if File.exist?(digest_file_name)
313
- raise "Error compiling crystal code"
206
+ log_debug(".build!")
207
+
208
+ CR_COMPILE_MUX.synchronize do
209
+ return if @compiled
210
+
211
+ File.write config.crystal_codegen_dir_abs / "index.cr", Template::Index.render(
212
+ type_modules: type_modules,
213
+ requires: requires
214
+ )
215
+ if @compiled = CrystalRuby::Compilation.compile!(
216
+ verbose: config.verbose,
217
+ debug: config.debug
218
+ )
219
+ IO.write(digest_file_name, get_cr_src_files_digest)
220
+ else
221
+ File.delete(digest_file_name) if File.exist?(digest_file_name)
222
+ raise "Error compiling crystal code"
223
+ end
314
224
  end
315
225
  end
316
226
 
317
227
  def self.attach!
228
+ log_debug(".attach!")
318
229
  @chunk_store.each do |function|
319
230
  function[:compile_callback]&.call
320
231
  end
232
+ log_debug(".attach_crystal_ruby_lib. Single thread mode: #{config.single_thread_mode}")
233
+ if config.single_thread_mode
234
+ Reactor.init_single_thread_mode!
235
+ else
236
+ Reactor.start!
237
+ end
321
238
  @attached = true
322
239
  end
323
240
 
@@ -342,19 +259,24 @@ module CrystalRuby
342
259
  end
343
260
 
344
261
  def self.write_chunk(owner, body:, name: Digest::MD5.hexdigest(body), &compile_callback)
262
+ log_debug(".write_chunk!")
263
+ chunk_store.delete_if { |chnk| chnk[:owner].name == owner.name && chnk[:name] == name }
345
264
  chunk_store << { owner: owner, name: name, body: body, compile_callback: compile_callback }
346
265
  FileUtils.mkdir_p(config.crystal_codegen_dir_abs)
347
266
  existing = Dir.glob("#{config.crystal_codegen_dir_abs}/**/*.cr")
348
267
  chunk_store.each do |function|
268
+ log_debug(".processing_chunk", function[0..60])
349
269
  owner_name = function[:owner].name
350
270
  FileUtils.mkdir_p(config.crystal_codegen_dir_abs / owner_name)
351
271
  function_data = function[:body]
352
272
  fname = function[:name]
353
273
  file_digest = Digest::MD5.hexdigest function_data
354
274
  filename = config.crystal_codegen_dir_abs / owner_name / "#{fname}_#{file_digest}.cr"
275
+
355
276
  unless existing.delete(filename.to_s)
356
- @compiled = false
277
+ log_debug("Chunk invalidated", filename.to_s)
357
278
  @attached = false
279
+ @compiled = false
358
280
  File.write(filename, function_data)
359
281
  end
360
282
  existing.select do |f|
data/lib/module.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Module
2
- prepend CrystalRuby
2
+ prepend CrystalRuby::Adapter
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crystalruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wouter Coppieters
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-12 00:00:00.000000000 Z
11
+ date: 2024-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: digest
@@ -85,8 +85,10 @@ files:
85
85
  - examples/adder/adder.rb
86
86
  - exe/crystalruby
87
87
  - lib/crystalruby.rb
88
+ - lib/crystalruby/adapter.rb
88
89
  - lib/crystalruby/compilation.rb
89
90
  - lib/crystalruby/config.rb
91
+ - lib/crystalruby/reactor.rb
90
92
  - lib/crystalruby/template.rb
91
93
  - lib/crystalruby/templates/function.cr
92
94
  - lib/crystalruby/templates/index.cr