hypercast 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,25 +18,53 @@ module HyperCast
18
18
  cast_bool: PLAIN,
19
19
  cast_i8: NUMERIC, cast_i16: NUMERIC, cast_i32: NUMERIC, cast_i64: NUMERIC,
20
20
  cast_u8: NUMERIC, cast_u16: NUMERIC, cast_u32: NUMERIC, cast_u64: NUMERIC,
21
- cast_f32: NUMERIC, cast_f64: NUMERIC,
21
+ cast_f32: NUMERIC, cast_f64: NUMERIC, cast_decimal: NUMERIC,
22
22
  cast_uuid: PLAIN,
23
23
  cast_timestamp: PLAIN, cast_unix: UNIX, cast_excel_serial: UNIX,
24
24
  cast_date: PLAIN, cast_date_ordered: UNIX, cast_datetime: UNIX,
25
25
  cast_time: PLAIN, cast_duration: PLAIN
26
26
  }.freeze
27
27
 
28
+ # The one export that is not a door: the zero-argument version probe, returning the
29
+ # core's packed version word (major << 16 | minor << 8 | patch) rather than a verdict
30
+ # code — so it gets its own Fiddle signature beside DOORS instead of a row in it.
31
+ VERSION_PROBE = :hypercast_version
32
+
28
33
  @mutex = Mutex.new
29
34
  @functions = nil
30
35
 
31
36
  class << self
32
- # The door's Fiddle::Function, for the caller to invoke directly — a splat-through
33
- # `call(symbol, *args)` here built and re-splatted an Array on every cast.
37
+ # The door's (or VERSION_PROBE's) Fiddle::Function, for the caller to invoke directly
38
+ # — a splat-through `call(symbol, *args)` here built and re-splatted an Array on
39
+ # every cast.
34
40
  def function(symbol)
35
41
  functions.fetch(symbol)
36
42
  end
37
43
 
44
+ # Whether this platform has a shared library for Fiddle to dlopen at all: a known RID
45
+ # and the file actually present — in this install, or in the in-repo cargo build the
46
+ # dev loop falls back to. Backend selection (hypercast.rb) asks this before falling
47
+ # back to the WebAssembly backend, which needs neither.
48
+ def fiddle_library_available?
49
+ !library_path.nil?
50
+ rescue NativePlatform::UnsupportedPlatformError
51
+ false
52
+ end
53
+
38
54
  private
39
55
 
56
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
57
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
58
+ # staging does. Nil when neither exists.
59
+ def library_path
60
+ rid, lib_name = NativePlatform.rid_and_library_name
61
+ path = File.join(NATIVE_DIR, rid, lib_name)
62
+ return path if File.exist?(path)
63
+
64
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
65
+ File.exist?(repo_build) ? repo_build : nil
66
+ end
67
+
40
68
  # Loaded lazily and exactly once; the native library and its function pointers live
41
69
  # for the process's lifetime, same as every other binding (never dlclose'd). The
42
70
  # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
@@ -46,24 +74,21 @@ module HyperCast
46
74
  end
47
75
 
48
76
  def load_functions
49
- rid, lib_name = NativePlatform.rid_and_library_name
50
- path = File.join(NATIVE_DIR, rid, lib_name)
51
- unless File.exist?(path)
52
- # Development loop: fall back to the in-repo cargo build, exactly what the other
53
- # bindings' local staging does.
54
- repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
55
- path = repo_build if File.exist?(repo_build)
56
- end
57
- unless File.exist?(path)
77
+ path = library_path
78
+ if path.nil?
79
+ rid, lib_name = NativePlatform.rid_and_library_name
58
80
  raise LoadError,
59
- "hypercast: #{path} not found (unsupported platform, or this gem was built " \
60
- "without a native library for it)"
81
+ "hypercast: #{File.join(NATIVE_DIR, rid, lib_name)} not found (unsupported " \
82
+ "platform, or this gem was built without a native library for it)"
61
83
  end
62
84
 
63
85
  handle = Fiddle.dlopen(path)
64
- DOORS.to_h do |name, signature|
86
+ functions = DOORS.to_h do |name, signature|
65
87
  [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
66
88
  end
89
+ functions[VERSION_PROBE] =
90
+ Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
91
+ functions
67
92
  end
68
93
  end
69
94
  end
@@ -0,0 +1,169 @@
1
+ require "wasmtime"
2
+
3
+ module HyperCast
4
+ # The WebAssembly backend: the same Rust core, compiled once to a `wasm32-wasip1` module
5
+ # (lib/hypercast/native/wasm32-wasip1/hypercast.wasm) and run inside this process by the
6
+ # `wasmtime` gem — no per-platform shared library needed, and nothing dlopen'd. This is the
7
+ # inverse of ruby.wasm: not Ruby running inside a wasm sandbox, but a wasm module running
8
+ # inside Ruby.
9
+ #
10
+ # Same integration shape as the Magnus extension: on require (after hypercast.rb has
11
+ # defined the pure-Fiddle module) this redefines methods **in place** on HyperCast — no
12
+ # delegation layer, no second surface. The Magnus extension replaces every public door;
13
+ # this file replaces only the four private bodies underneath them (`plain`, `numeric`,
14
+ # `declared`, `packed_version` — the whole native crossing), so every door, every verdict
15
+ # type, `utf8`, `verdict`, `instant` and the Symbol tables stay the exact code the other
16
+ # two backends run, byte for byte.
17
+ #
18
+ # Two things differ from the native backends, both forced by the sandbox:
19
+ #
20
+ # * A wasm guest only sees its own linear memory, so no Fiddle::Pointer can cross. The
21
+ # input text is copied into a grow-only guest buffer, and the three out-params the doors
22
+ # fill — the 16-byte out-value, the 8-byte fault span, the 32-byte NumFormat — are guest
23
+ # allocations made once at load, all from the module's own exported `malloc` (the
24
+ # wasi-libc allocator Rust's std already uses on this target) and read back with
25
+ # `Memory#read`. Using the guest's allocator rather than a host-picked offset is
26
+ # load-bearing: dlmalloc claims the tail of the initial memory on first use, and
27
+ # HyperUuid observed a buffer written at a guessed offset corrupted by the guest's next
28
+ # allocation.
29
+ # * A `Wasmtime::Store` is single-threaded, so every call is serialized under one Mutex
30
+ # around one shared instance per process.
31
+ module WasmRuntime
32
+ MODULE_PATH = File.join(Runtime::NATIVE_DIR, "wasm32-wasip1", "hypercast.wasm")
33
+ # Development loop: the in-repo cargo build, the same fallback runtime.rb takes for the
34
+ # native library.
35
+ REPO_BUILD_PATH = File.expand_path(
36
+ File.join(__dir__, "../../../rust/target/wasm32-wasip1/release/hypercast.wasm")
37
+ )
38
+
39
+ DOORS = Runtime::DOORS.keys.freeze
40
+
41
+ # One instantiated module: the exported functions, the exported memory, and the guest
42
+ # buffers every door crosses through for the life of the process.
43
+ class Instance
44
+ attr_reader :memory, :out, :fault, :format
45
+
46
+ def initialize
47
+ path = File.exist?(MODULE_PATH) ? MODULE_PATH : REPO_BUILD_PATH
48
+ unless File.exist?(path)
49
+ raise LoadError,
50
+ "hypercast: #{MODULE_PATH} not found (this gem was built without its WebAssembly module)"
51
+ end
52
+
53
+ engine = Wasmtime::Engine.new
54
+ mod = Wasmtime::Module.from_file(engine, path)
55
+ linker = Wasmtime::Linker.new(engine)
56
+ # The module imports four WASI preview1 functions — wasi-libc's startup and panic
57
+ # plumbing (environ_*, fd_write, proc_exit); the core itself needs no clock and no
58
+ # entropy. A default WasiConfig — no stdio, no filesystem, no environment — is all
59
+ # any of them need.
60
+ Wasmtime::WASI::P1.add_to_linker_sync(linker)
61
+ store = Wasmtime::Store.new(engine, wasi_p1_config: Wasmtime::WasiConfig.new)
62
+ instance = linker.instantiate(store, mod)
63
+
64
+ @memory = instance.export("memory").to_memory
65
+ @fn = (DOORS + [Runtime::VERSION_PROBE] + %i[malloc free]).to_h do |name|
66
+ export = instance.export(name.to_s) or
67
+ raise LoadError, "hypercast: #{path} does not export #{name} (a module older than this binding)"
68
+ [name, export.to_func]
69
+ end
70
+ # dlmalloc hands back 8-byte-aligned (in fact 16) blocks, which covers the
71
+ # decimal out-value's 8-byte alignment.
72
+ @out = malloc(16)
73
+ @fault = malloc(8)
74
+ @format = malloc(32)
75
+ # Grow-only buffer for the input text, so a steady stream of ordinary scalars never
76
+ # touches the guest allocator again.
77
+ @in = 0
78
+ @in_cap = 0
79
+ # The format currently written at @format, memoized by identity: formats are reused
80
+ # constants in practice, and NumFormat is a frozen Data, so one `equal?` skips the
81
+ # 32-byte write on the overwhelming majority of numeric calls. The Instance holds
82
+ # the format it memoized, so the key can never be a recycled object.
83
+ @last_format = nil
84
+ end
85
+
86
+ def fn(name)
87
+ @fn.fetch(name)
88
+ end
89
+
90
+ def malloc(size)
91
+ ptr = @fn[:malloc].call(size)
92
+ raise NoMemoryError, "hypercast: wasm malloc(#{size}) failed" if ptr.zero?
93
+
94
+ ptr
95
+ end
96
+
97
+ # Copies the input into the guest and returns its address — 0 (the ABI's NULL) for
98
+ # empty input, which the core never dereferences.
99
+ def stage_input(bytes)
100
+ size = bytes.bytesize
101
+ return 0 if size.zero?
102
+
103
+ if size > @in_cap
104
+ @fn[:free].call(@in) unless @in.zero?
105
+ @in = malloc(size)
106
+ @in_cap = size
107
+ end
108
+ @memory.write(@in, bytes)
109
+ @in
110
+ end
111
+
112
+ def stage_format(format)
113
+ unless format.equal?(@last_format)
114
+ @memory.write(@format, format.packed)
115
+ @last_format = format
116
+ end
117
+ @format
118
+ end
119
+ end
120
+
121
+ @mutex = Mutex.new
122
+ @instance = nil
123
+
124
+ # Every call holds the one lock: the Store is single-threaded, and the lazily created
125
+ # instance is shared by every thread for the life of the process (never torn down,
126
+ # same as the dlopen'd library natively).
127
+ def self.with_instance
128
+ @mutex.synchronize do
129
+ @instance ||= Instance.new
130
+ yield @instance
131
+ end
132
+ end
133
+ end
134
+
135
+ class << self
136
+ private
137
+
138
+ # The four crossing bodies, redefined over the guest. Each door body reads the
139
+ # out-value back only on success and the fault span only on a failure code, so
140
+ # `verdict` sees exactly what the Fiddle path hands it.
141
+ def plain(symbol, text, out_size)
142
+ bytes = utf8(text)
143
+ WasmRuntime.with_instance do |w|
144
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, w.out, w.fault)
145
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
146
+ end
147
+ end
148
+
149
+ def numeric(symbol, text, format, out_size)
150
+ bytes = utf8(text)
151
+ WasmRuntime.with_instance do |w|
152
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, w.stage_format(format), w.out, w.fault)
153
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
154
+ end
155
+ end
156
+
157
+ def declared(symbol, text, code, out_size)
158
+ bytes = utf8(text)
159
+ WasmRuntime.with_instance do |w|
160
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, code, w.out, w.fault)
161
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
162
+ end
163
+ end
164
+
165
+ def packed_version
166
+ WasmRuntime.with_instance { |w| w.fn(Runtime::VERSION_PROBE).call }
167
+ end
168
+ end
169
+ end