hypercast 0.2.0-x86_64-linux → 0.4.0-x86_64-linux
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.
- checksums.yaml +4 -4
- data/README.md +385 -55
- data/lib/hypercast/3.4/hypercast_native.so +0 -0
- data/lib/hypercast/4.0/hypercast_native.so +0 -0
- data/lib/hypercast/native/linux-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-x64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-x64/libhypercast.so +0 -0
- data/lib/hypercast/native/osx-arm64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/osx-x64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/wasm32-wasip1/hypercast.wasm +0 -0
- data/lib/hypercast/native/win-arm64/hypercast.dll +0 -0
- data/lib/hypercast/native/win-x64/hypercast.dll +0 -0
- data/lib/hypercast/native_platform.rb +13 -7
- data/lib/hypercast/runtime.rb +40 -15
- data/lib/hypercast/wasm_runtime.rb +169 -0
- data/lib/hypercast.rb +271 -67
- metadata +20 -10
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -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
|
|
33
|
-
# `call(symbol, *args)` here built and re-splatted an Array on
|
|
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
|
-
|
|
50
|
-
path
|
|
51
|
-
|
|
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: #{
|
|
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
|