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.
- checksums.yaml +4 -4
- data/README.md +385 -55
- 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 +21 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 319ce95965918c4804c0ebbd9183e93d5ca2e53d50ada708706faef12507d344
|
|
4
|
+
data.tar.gz: d750c75771186f9b218530fcc29de061d3462efcd1cf81b0a524860b11b87b98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c86acda45e6c9aa7044ac2c2671854d3f43a340ab9b0269c0220342454340517c049148865ad657df47ed32b57261ea2cfa1470d5e35b014407041b7e6d075e5
|
|
7
|
+
data.tar.gz: c89ea288c80ebaaffc0071ed61ed376e8fa8656d1b88da83f1c04c05f2bd589fff901f54fb79dc6024f0aa4389e3c67c0a24a5964acf0cbcccc8388e731f447c
|
data/README.md
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
# hypercast
|
|
2
2
|
|
|
3
3
|
[](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml)
|
|
4
|
-
[](https://rubygems.org/gems/hypercast)
|
|
5
5
|
[](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
|
|
6
6
|
|
|
7
7
|
**Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
|
|
8
|
-
Symbol plus the exact
|
|
8
|
+
Symbol plus the exact span that offended. Three backends, one public surface: a Magnus
|
|
9
9
|
native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
|
|
10
|
-
else — selected automatically, zero compiles either way
|
|
10
|
+
else — selected automatically, zero compiles either way — and the same core as a
|
|
11
|
+
WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
|
|
11
12
|
|
|
12
|
-
Allocation-lean scalar casts — booleans, the full integer family, reals,
|
|
13
|
-
calling directly into the native `libhypercast` Rust core. Ruby 3.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
|
|
14
|
+
UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
|
|
15
|
+
floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
|
|
16
|
+
redefines the doors in place on the `HyperCast` module — no delegation layer, no second
|
|
17
|
+
surface, which is exactly what keeps the backends provably in agreement.
|
|
18
|
+
`HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1` forces Fiddle,
|
|
19
|
+
`HYPERCAST_WASM=1` the wasm module — see [Backends](#backends).
|
|
19
20
|
|
|
20
21
|
```ruby
|
|
21
22
|
case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
|
|
22
23
|
in HyperCast::Success(value:) then puts "got #{value}" # -1234
|
|
23
|
-
in HyperCast::Fault(reason:, offset:) then puts "#{reason} at
|
|
24
|
+
in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
|
|
24
25
|
end
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -28,8 +29,129 @@ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored
|
|
|
28
29
|
stated proudly — nothing the core parses is lost on the way out: `Integer` is
|
|
29
30
|
unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
|
|
30
31
|
across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
|
|
31
|
-
midnight,
|
|
32
|
-
±10,000-year window
|
|
32
|
+
midnight, durations come back as exact `Rational` seconds across the core's whole
|
|
33
|
+
±10,000-year window, and `decimal` returns an exact `HyperCast::Decimal` that never rounds
|
|
34
|
+
— no truncation anywhere, no wrapping.
|
|
35
|
+
|
|
36
|
+
## The doors
|
|
37
|
+
|
|
38
|
+
| Door | Value | Declares |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `bool(text)` | `true`/`false` | — |
|
|
41
|
+
| `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
|
|
42
|
+
| `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
|
|
43
|
+
| `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
|
|
44
|
+
| `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
|
|
45
|
+
| `timestamp(text)` | UTC `Time`, full nanoseconds | — |
|
|
46
|
+
| `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
|
|
47
|
+
| `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
|
|
48
|
+
| `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
|
|
49
|
+
| `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
|
|
50
|
+
| `time(text)` | `Integer` nanoseconds since midnight | — |
|
|
51
|
+
| `duration(text)` | exact `Rational` seconds | — |
|
|
52
|
+
|
|
53
|
+
Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
|
|
54
|
+
to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
55
|
+
`"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
|
|
56
|
+
[Is the native core there?](#is-the-native-core-there).
|
|
57
|
+
|
|
58
|
+
### Errors
|
|
59
|
+
|
|
60
|
+
Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
|
|
61
|
+
the same exception on every backend:
|
|
62
|
+
|
|
63
|
+
- `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
|
|
64
|
+
characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
|
|
65
|
+
whitespace.
|
|
66
|
+
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
67
|
+
that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
|
|
68
|
+
included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
|
|
69
|
+
door, exactly as if it were left off.
|
|
70
|
+
- `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
|
|
71
|
+
- `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
|
|
72
|
+
transcoded to UTF-8.
|
|
73
|
+
|
|
74
|
+
### Is the native core there?
|
|
75
|
+
|
|
76
|
+
`HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
|
|
77
|
+
binding was built against — probed once, cached, never raising — for a consumer with a
|
|
78
|
+
fallback of its own:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`HyperCast.native_version` reads the version out of the loaded core itself (its
|
|
85
|
+
`hypercast_version` export — the cheapest probe that the backend resolved at all), so a
|
|
86
|
+
mismatch against `HyperCast::VERSION` can be named before the first cast.
|
|
87
|
+
`HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
|
|
88
|
+
reads `:fiddle` on a platform with no library at all, because that is the backend whose
|
|
89
|
+
first call explains what is missing — a `LoadError` naming the path it looked for, or
|
|
90
|
+
`HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform.
|
|
91
|
+
|
|
92
|
+
### Fault spans index the String you passed
|
|
93
|
+
|
|
94
|
+
A `Fault`'s `offset`/`length` are in the units `String#[]` slices by on your own input:
|
|
95
|
+
character offsets for text — in any encoding; the core reads UTF-8, and the span is mapped
|
|
96
|
+
back, an identity for ASCII — and byte offsets for a binary (`ASCII-8BIT`) String, whose
|
|
97
|
+
characters are its bytes. Either way `text[offset, length]` is the offending text with no
|
|
98
|
+
arithmetic on your side:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
HyperCast.i32("1€", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 1)
|
|
102
|
+
HyperCast.i32("1€".b, HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 3)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The mapping happens only on the failure path, so a `Success` never pays for it.
|
|
106
|
+
|
|
107
|
+
### `decimal`: exact, never rounded
|
|
108
|
+
|
|
109
|
+
`HyperCast::Decimal` is a `Data` of `magnitude` (an unbounded `Integer`, at most 2⁹⁶ − 1),
|
|
110
|
+
`scale` (0–28: places the magnitude is shifted right) and `negative` — the shape .NET's
|
|
111
|
+
`decimal` stores and `BigDecimal` builds from directly. No float is ever formed, so `"0.1"`
|
|
112
|
+
is one tenth and `"50%"` is exactly `0.5`. The triple is canonical: exact trailing zeros in
|
|
113
|
+
the fraction are always trimmed, so the scale is minimal — `"1.10"`, `"1.1"` and `"1.1000"`
|
|
114
|
+
are all magnitude 11, scale 1, while `"100"` stays magnitude 100, scale 0 — and zero is
|
|
115
|
+
scale 0 and never negative. Nothing but a zero is ever dropped: text carrying more precision
|
|
116
|
+
than 96 bits and 28 places can hold is an `:out_of_range` `Fault`, not an approximation.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
value = HyperCast.decimal("-1,234.50", HyperCast::NumFormat::INVARIANT).value
|
|
120
|
+
value.to_s # => "-1234.5" (the canonical text, every binding renders the same)
|
|
121
|
+
value.to_r # => (-2469/2)
|
|
122
|
+
value.to_d # => BigDecimal("-1234.5")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
|
|
126
|
+
deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
|
|
127
|
+
|
|
128
|
+
### `NumFormat`: declared separators, and a declared currency
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
|
|
135
|
+
`CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
|
|
136
|
+
symbol is the one field a culture table would fill in: it is declared, never looked up, and
|
|
137
|
+
honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
|
|
138
|
+
(`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
|
|
139
|
+
accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
|
|
140
|
+
flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
|
|
141
|
+
(`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
|
|
142
|
+
symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
|
|
143
|
+
`ArgumentError` at construction, the same caller-bug treatment equal separators get.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
147
|
+
HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
|
|
148
|
+
HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
|
|
149
|
+
HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
|
|
153
|
+
flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
|
|
154
|
+
and memoized by identity on every backend, so declaring a currency costs a cast nothing.
|
|
33
155
|
|
|
34
156
|
## Why not `Integer()` / `Time.iso8601` / `Float()`?
|
|
35
157
|
|
|
@@ -39,19 +161,18 @@ midnight, and durations come back as exact `Rational` seconds across the core's
|
|
|
39
161
|
parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
|
|
40
162
|
`urn:uuid:` prefixes, protobuf JSON durations.
|
|
41
163
|
3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
|
|
42
|
-
held by the shared corpus (
|
|
43
|
-
replay;
|
|
44
|
-
subprocess boundary).
|
|
164
|
+
held by the shared corpus (the whole suite green on *all three* backends, full
|
|
165
|
+
corpus replay; cross-backend agreement specs compare Magnus against Fiddle
|
|
166
|
+
and wasm against Fiddle across a subprocess boundary).
|
|
45
167
|
4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
|
|
46
|
-
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-
|
|
47
|
-
2.
|
|
48
|
-
duration door. The Fiddle fallback lands at
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
with parsing: every format other than `INVARIANT` paid three method dispatches and two
|
|
168
|
+
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
|
|
169
|
+
i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
|
|
170
|
+
returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
|
|
171
|
+
3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
|
|
172
|
+
|
|
173
|
+
Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
174
|
+
at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
|
|
175
|
+
nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
|
|
55
176
|
`String` allocations per call to read its separators back out of the `Data`. `DETECT`
|
|
56
177
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
57
178
|
thread and memoized by identity — anchored in a thread-variable so the memo's key can
|
|
@@ -60,29 +181,129 @@ midnight, and durations come back as exact `Rational` seconds across the core's
|
|
|
60
181
|
cached the same way, so a fault or a declared option is a pointer compare too, never a
|
|
61
182
|
`Symbol#name` materialization.
|
|
62
183
|
|
|
63
|
-
**The honest trade-off
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
|
|
67
|
-
where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
184
|
+
**The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
|
|
185
|
+
level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
|
|
186
|
+
`Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
|
|
187
|
+
a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
|
|
188
|
+
where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
|
|
189
|
+
Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
|
|
190
|
+
as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
|
|
191
|
+
which is an artifact of the type, not a zone the parse assigned.
|
|
71
192
|
|
|
72
193
|
On the Fiddle fallback the doors are parity-at-best — Fiddle's
|
|
73
194
|
per-call floor is the mechanism's price, kept because it's the universal zero-compile
|
|
74
|
-
path.
|
|
75
|
-
|
|
76
|
-
doors interpolated and interned their `:cast_*` Symbol on every call, every door went
|
|
77
|
-
through a splat-and-resplat dispatcher, and every numeric call copied the format's 12
|
|
78
|
-
packed bytes into scratch — each format now owns one native pointer, memoized by identity,
|
|
79
|
-
passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
|
|
195
|
+
path. Its doors build nothing per call that does not change between calls: each format
|
|
196
|
+
owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
|
|
80
197
|
per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
|
|
81
198
|
receipts include their own archaeology.)
|
|
82
199
|
|
|
200
|
+
## Benchmarks
|
|
201
|
+
|
|
202
|
+
`ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
|
|
203
|
+
first line names the backend, core version and Ruby it measured, so run it again under
|
|
204
|
+
`HYPERCAST_PURE=1` or `HYPERCAST_WASM=1` for the other two backends. The stdlib comparisons
|
|
205
|
+
are in [the section above](#why-not-integer--timeiso8601--float); this is the three backends
|
|
206
|
+
against each other.
|
|
207
|
+
|
|
208
|
+
Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), wasmtime 48.0.1,
|
|
209
|
+
`benchmark-ips`, same session, all three backends:
|
|
210
|
+
|
|
211
|
+
| Door | Magnus | Fiddle | wasmtime |
|
|
212
|
+
|---|---:|---:|---:|
|
|
213
|
+
| `bool` | 112 ns | 2.35 µs | 1.75 µs |
|
|
214
|
+
| `i32` | 133 ns | 2.63 µs | 1.80 µs |
|
|
215
|
+
| `f64` | 166 ns | 2.70 µs | 1.84 µs |
|
|
216
|
+
| `uuid` | 223 ns | 3.47 µs | 2.65 µs |
|
|
217
|
+
| `timestamp` | 447 ns | 3.25 µs | 2.42 µs |
|
|
218
|
+
| `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs | 3.14 µs |
|
|
219
|
+
| `duration` (ISO) | 632 ns | 2.91 µs | 2.10 µs |
|
|
220
|
+
| `i32`, a fault | 225 ns | 3.18 µs | 2.50 µs |
|
|
221
|
+
|
|
222
|
+
A lean door on the Magnus backend is little more than the native call: the extension builds
|
|
223
|
+
the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
|
|
224
|
+
rather than through `Data.new`, whose keyword handling alone cost more than the cast.
|
|
225
|
+
|
|
226
|
+
## Backends
|
|
227
|
+
|
|
228
|
+
| `HyperCast::BACKEND` | What runs | Chosen when |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| `:native` | the core linked into a Magnus extension | a precompiled platform gem carries an extension for this Ruby's ABI — see [Install](#install) |
|
|
231
|
+
| `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads; also the answer when nothing loads at all |
|
|
232
|
+
| `:wasm` | the core as a `wasm32-wasip1` module inside the `wasmtime` gem | no extension and no library for this platform, and `wasmtime` is installed — see [WebAssembly (wasmtime)](#webassembly-wasmtime) |
|
|
233
|
+
|
|
234
|
+
Selection happens once, at `require`, in that order. Two environment variables override it:
|
|
235
|
+
|
|
236
|
+
| Variable | Effect |
|
|
237
|
+
|---|---|
|
|
238
|
+
| `HYPERCAST_WASM` | forces `:wasm`; `require` raises a `LoadError` naming the gem if `wasmtime` is missing |
|
|
239
|
+
| `HYPERCAST_PURE` | forces `:fiddle`, even where an extension would load |
|
|
240
|
+
|
|
241
|
+
Both are read for presence, not value — set to anything at all, `0` and the empty string
|
|
242
|
+
included, they force their backend — and `HYPERCAST_WASM` wins when both are set. A forced
|
|
243
|
+
backend that turns out to have nothing to load does not fall through to another one: the
|
|
244
|
+
first call raises, and `HyperCast.available?` answers `false`.
|
|
245
|
+
|
|
246
|
+
**Threads.** Every backend is safe to call from any number of threads. The extension runs
|
|
247
|
+
under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
|
|
248
|
+
backend where Ruby threads run the core truly in parallel — each through its own scratch
|
|
249
|
+
buffers; the core itself keeps no state between calls. The wasm backend serializes every call
|
|
250
|
+
on one Mutex around one instance. `spec/cast_spec.rb`'s concurrent-callers example runs under
|
|
251
|
+
all three.
|
|
252
|
+
|
|
253
|
+
**Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
|
|
254
|
+
`Ractor::UnsafeError` (`Ractor::IsolationError` under wasm).
|
|
255
|
+
|
|
256
|
+
## WebAssembly (wasmtime)
|
|
257
|
+
|
|
258
|
+
The Rust core also ships inside this gem as a `wasm32-wasip1` module
|
|
259
|
+
(`lib/hypercast/native/wasm32-wasip1/hypercast.wasm`), and the
|
|
260
|
+
[`wasmtime`](https://rubygems.org/gems/wasmtime) gem can run it in-process. This is the
|
|
261
|
+
inverse of ruby.wasm — not Ruby inside a wasm sandbox, but a wasm module inside Ruby — and
|
|
262
|
+
it is the one backend that needs no shared library for the platform it runs on: no
|
|
263
|
+
`dlopen`, no Magnus extension, nothing compiled against this Ruby's ABI. The Magnus
|
|
264
|
+
extension replaces every public door in place; this backend replaces only the four private
|
|
265
|
+
bodies underneath them (`plain`, `numeric`, `declared`, `packed_version` — the whole native
|
|
266
|
+
crossing), so every door, both verdict `Data` types, the Symbol tables and the
|
|
267
|
+
`Time`/`DateTime`/`Rational`/`Decimal` carriers are the exact code the other two backends
|
|
268
|
+
run. `spec/wasm_backend_spec.rb` pins that the outputs agree with the Fiddle backend byte for
|
|
269
|
+
byte.
|
|
270
|
+
|
|
271
|
+
`wasmtime` is deliberately **not** a dependency of this gem; a consumer who wants this path
|
|
272
|
+
installs it:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
gem install wasmtime
|
|
276
|
+
HYPERCAST_WASM=1 ruby -rhypercast -e 'p HyperCast::BACKEND' # => :wasm
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`HYPERCAST_WASM=1` forces the backend (and raises a `LoadError` naming the gem if it is
|
|
280
|
+
missing). Without it, the wasm backend is only ever chosen automatically when there is no
|
|
281
|
+
native library for this platform at all — no Magnus extension and no `libhypercast` for the
|
|
282
|
+
RID — and `wasmtime` happens to be installed. No supported platform's behavior changes just
|
|
283
|
+
because this backend exists.
|
|
284
|
+
|
|
285
|
+
Two things are different under the sandbox, both by necessity. A wasm guest only sees its own
|
|
286
|
+
linear memory, so the input is copied into a grow-only guest buffer and the out-value, fault
|
|
287
|
+
span and `NumFormat` live in guest allocations made once at load — all from the module's own
|
|
288
|
+
exported `malloc` (the same wasi-libc allocator Rust's std uses on that target), read back
|
|
289
|
+
with `Memory#read`; using the guest's allocator rather than a host-picked offset is what keeps
|
|
290
|
+
a buffer from being clobbered by the guest's next allocation. And a `Wasmtime::Store` is
|
|
291
|
+
single-threaded, so every call is serialized under one Mutex around one shared instance.
|
|
292
|
+
|
|
293
|
+
Measured against the other two backends in the same session — the `wasmtime` column of the
|
|
294
|
+
table under [Benchmarks](#benchmarks).
|
|
295
|
+
|
|
296
|
+
The finding worth stating: in Ruby the wasm backend is no slower than Fiddle, and on the
|
|
297
|
+
x64 box that table comes from it is the faster of the two on every door, by a fifth to a
|
|
298
|
+
third. Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest
|
|
299
|
+
crossing plus the guest memory copies; on the lean doors wasmtime sits eleven to sixteen
|
|
300
|
+
times behind the Magnus extension and Fiddle sixteen to twenty-one, narrowing to three to
|
|
301
|
+
five times where building the Ruby value dominates. So on a platform with no native build, the fallback costs a consumer nothing
|
|
302
|
+
they were not already paying on the universal gem.
|
|
303
|
+
|
|
83
304
|
## Verifying provenance
|
|
84
305
|
|
|
85
|
-
Every gem RubyGems.org serves — the universal fallback and each of the
|
|
306
|
+
Every gem RubyGems.org serves — the universal fallback and each of the five precompiled
|
|
86
307
|
platform gems — carries its own GitHub build-provenance attestation, signed directly by
|
|
87
308
|
this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
|
|
88
309
|
before the push), so plain `--repo` verifies any of them:
|
|
@@ -93,10 +314,11 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
|
|
|
93
314
|
```
|
|
94
315
|
|
|
95
316
|
That's the release's second layer of checking, not the only one: before any gem gets built,
|
|
96
|
-
the same job verifies every native binary it packs
|
|
97
|
-
|
|
98
|
-
`SkunkWerkx/.github` by `hyper-build-native.yml`, so
|
|
99
|
-
`--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
|
|
317
|
+
the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
|
|
318
|
+
extensions (one per Ruby ABI per platform) and the wasm module — against *their own*
|
|
319
|
+
attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
|
|
320
|
+
that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
|
|
321
|
+
unverified one.
|
|
100
322
|
RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
|
|
101
323
|
bad artifact is still reversible. The release run's job summary then re-fetches every gem
|
|
102
324
|
from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
|
|
@@ -110,10 +332,15 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
|
|
|
110
332
|
gem install hypercast
|
|
111
333
|
```
|
|
112
334
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
that `gem install` auto-
|
|
335
|
+
Six gems are published per release: one universal `ruby`-platform gem (Fiddle, with every
|
|
336
|
+
platform's native library and the wasm module bundled) plus five precompiled Magnus platform
|
|
337
|
+
gems (`x86_64-linux`, `aarch64-linux`, `arm64-darwin`, `x64-mingw-ucrt`,
|
|
338
|
+
`aarch64-mingw-ucrt`) that `gem install` and `bundle` auto-select when they match. There is
|
|
339
|
+
no `x86_64-darwin` platform gem: an Intel Mac installs the universal gem and runs on Fiddle
|
|
340
|
+
over the bundled `osx-x64` library, as Alpine does. A platform
|
|
341
|
+
gem carries the same libraries and module beside its extensions, so the Fiddle and wasm
|
|
342
|
+
backends are still there behind `HYPERCAST_PURE` and `HYPERCAST_WASM`. No extra configuration
|
|
343
|
+
needed either way.
|
|
117
344
|
|
|
118
345
|
Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
|
|
119
346
|
is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
|
|
@@ -121,11 +348,19 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
|
|
|
121
348
|
carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
|
|
122
349
|
one at `require` time:
|
|
123
350
|
|
|
124
|
-
| Ruby |
|
|
125
|
-
| --- | --- | --- |
|
|
126
|
-
| 4.0 (primary) | Magnus, `
|
|
127
|
-
| 3.4 (
|
|
128
|
-
| 3.
|
|
351
|
+
| Ruby | glibc Linux, macOS, Windows — x64 and arm64 | musl Linux (Alpine) — x64 and arm64 | anywhere else |
|
|
352
|
+
| --- | --- | --- | --- |
|
|
353
|
+
| 4.0 (primary) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
|
|
354
|
+
| 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
|
|
355
|
+
| 3.3 (the floor) | Fiddle | Fiddle | wasm, if `wasmtime` is installed |
|
|
356
|
+
|
|
357
|
+
What stands behind each cell: CI replays the whole suite, shared corpus included, for the
|
|
358
|
+
first column on every push — Magnus on Ruby 3.4 and 4.0, Fiddle and wasm on 4.0, on all five
|
|
359
|
+
platforms with a CI leg (Intel macOS has none) — and the Fiddle suite for the musl column inside an Alpine container. The 3.3 row
|
|
360
|
+
is the same Fiddle code path, run with the Docker command under [Development](#development)
|
|
361
|
+
(Ruby 3.3 with the Fiddle 1.1.2 it ships) rather than on every push. The last column — wasm
|
|
362
|
+
chosen automatically because nothing native exists — has no leg of its own: CI runs the wasm
|
|
363
|
+
backend's suite forced, on platforms that have native builds too.
|
|
129
364
|
|
|
130
365
|
The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
|
|
131
366
|
*declines* them outside that range and resolves the universal gem instead — a wrong-ABI
|
|
@@ -133,7 +368,27 @@ extension must never be installed in the first place. On Windows it would at lea
|
|
|
133
368
|
load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
|
|
134
369
|
`x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
|
|
135
370
|
link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
|
|
136
|
-
|
|
371
|
+
When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
|
|
372
|
+
exactly what the fallback is for.
|
|
373
|
+
|
|
374
|
+
**musl.** RubyGems does not tell musl from glibc for these gems: on Alpine, `gem install`
|
|
375
|
+
picks the `x86_64-linux` or `aarch64-linux` platform gem exactly as it does on any other
|
|
376
|
+
Linux (checked with RubyGems 4.0.20 on `x86_64-linux-musl`). The Magnus extension inside is
|
|
377
|
+
linked against glibc and cannot load there — `require` fails cleanly on the missing
|
|
378
|
+
`ld-linux` loader — so the gem falls back to Fiddle, which loads the musl build of the core
|
|
379
|
+
(`native/linux-musl-x64` or `native/linux-musl-arm64`, chosen from `RUBY_PLATFORM`) that
|
|
380
|
+
every gem carries. Alpine therefore works as installed, on the Fiddle backend and at Fiddle's
|
|
381
|
+
speed. Releases through 0.3.0 carried no musl library: there the fallback found only the
|
|
382
|
+
glibc one, `HyperCast.available?` answered `false`, and the first cast raised
|
|
383
|
+
`Fiddle::DLError`.
|
|
384
|
+
|
|
385
|
+
**Nothing in this gem is ever compiled, on any platform.** Its one dependency can be:
|
|
386
|
+
`fiddle` is a bundled gem on Ruby 4.0 and a default gem on 3.3 and 3.4, and `gem install` is
|
|
387
|
+
satisfied by the copy Ruby ships. Bundler resolves the newest `fiddle` on rubygems.org
|
|
388
|
+
instead, and when that is newer than the one your Ruby ships — true today on 3.3 and 3.4, not
|
|
389
|
+
on 4.0 — it builds Fiddle's own C extension, which takes a compiler and libffi's headers
|
|
390
|
+
(`apk add build-base libffi-dev` on Alpine). Holding `fiddle` at your Ruby's version in the
|
|
391
|
+
`Gemfile.lock` avoids that.
|
|
137
392
|
|
|
138
393
|
Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
|
|
139
394
|
was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
|
|
@@ -144,7 +399,82 @@ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby u
|
|
|
144
399
|
with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
|
|
145
400
|
the shipped extension small. The build script, and the two flags that are load-bearing on
|
|
146
401
|
the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
|
|
147
|
-
forge's `
|
|
402
|
+
forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
|
|
403
|
+
|
|
404
|
+
## Development
|
|
405
|
+
|
|
406
|
+
Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
|
|
407
|
+
is needed to use the gem. The Fiddle and wasm backends find the in-repo builds on their own
|
|
408
|
+
when nothing is staged under `lib/hypercast/native/`. The Magnus backend does not — an
|
|
409
|
+
extension has to be built for the Ruby you are running and put where `require` looks — and
|
|
410
|
+
that is what `rake native:dev` is for.
|
|
411
|
+
|
|
412
|
+
```sh
|
|
413
|
+
cd rust
|
|
414
|
+
cargo build --release # libhypercast, what the Fiddle backend loads
|
|
415
|
+
cargo build --release --target wasm32-wasip1 # hypercast.wasm, what the wasm backend loads
|
|
416
|
+
|
|
417
|
+
cd ../ruby
|
|
418
|
+
bundle install
|
|
419
|
+
bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
|
|
420
|
+
bundle exec rspec # BACKEND == :native
|
|
421
|
+
HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
|
|
422
|
+
HYPERCAST_WASM=1 bundle exec rspec # BACKEND == :wasm
|
|
423
|
+
bundle exec rake docs:check # every public object carries a doc comment
|
|
424
|
+
ruby benchmark/cast_benchmark.rb # prints the backend it measured
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
|
|
428
|
+
`ruby` feature into `rust/target/ruby/`, never over the plain library in
|
|
429
|
+
`rust/target/release/`) and copies the result to
|
|
430
|
+
`lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
|
|
431
|
+
first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
|
|
432
|
+
names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
|
|
433
|
+
file name it was asked to `require`. Three things worth knowing:
|
|
434
|
+
|
|
435
|
+
- **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
|
|
436
|
+
- **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
|
|
437
|
+
(`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
|
|
438
|
+
and stages beside the first, each Ruby loading its own.
|
|
439
|
+
- **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
|
|
440
|
+
backend" report themselves pending with `BACKEND=fiddle`; that line, or
|
|
441
|
+
`ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
|
|
442
|
+
exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
|
|
443
|
+
|
|
444
|
+
The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
|
|
445
|
+
flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
|
|
446
|
+
|
|
447
|
+
The musl build has no host to run on outside a container. With the core built for musl at
|
|
448
|
+
`rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
|
|
449
|
+
included — against it on Alpine, with the checkout mounted read-only:
|
|
450
|
+
|
|
451
|
+
```sh
|
|
452
|
+
docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
|
|
453
|
+
mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
|
|
454
|
+
mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
|
|
455
|
+
cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
|
|
456
|
+
cd /work/ruby && rm -f Gemfile.lock
|
|
457
|
+
BUNDLE_WITHOUT=wasm bundle install --quiet --prefer-local
|
|
458
|
+
HYPERCAST_PURE=1 BUNDLE_WITHOUT=wasm bundle exec rspec'
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
|
|
462
|
+
Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
|
|
463
|
+
Fiddle that Ruby 3.3 ships:
|
|
464
|
+
|
|
465
|
+
```sh
|
|
466
|
+
docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
|
|
467
|
+
mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
|
|
468
|
+
mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
|
|
469
|
+
cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
|
|
470
|
+
cd /work/ruby && rm -f Gemfile Gemfile.lock
|
|
471
|
+
gem install rspec -v "~> 3.13" --no-document --silent
|
|
472
|
+
HYPERCAST_PURE=1 rspec'
|
|
473
|
+
```
|
|
148
474
|
|
|
149
475
|
See [the repo root README](../README.md) for the full door table, the receipts, and the
|
|
150
476
|
state of every other language binding.
|
|
477
|
+
|
|
478
|
+
## License
|
|
479
|
+
|
|
480
|
+
[MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -4,18 +4,24 @@ module HyperCast
|
|
|
4
4
|
module NativePlatform
|
|
5
5
|
class UnsupportedPlatformError < StandardError; end
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# +platform+ is a parameter only so the specs can walk the whole table from one host;
|
|
8
|
+
# every real caller takes the default.
|
|
9
|
+
def self.rid_and_library_name(platform = RUBY_PLATFORM)
|
|
10
|
+
arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
|
|
9
11
|
|
|
10
|
-
case
|
|
12
|
+
case platform
|
|
11
13
|
when /mingw|mswin|windows/
|
|
12
|
-
|
|
14
|
+
["win-#{arch}", "hypercast.dll"]
|
|
13
15
|
when /darwin/
|
|
14
|
-
|
|
16
|
+
["osx-#{arch}", "libhypercast.dylib"]
|
|
15
17
|
when /linux/
|
|
16
|
-
|
|
18
|
+
# Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
|
|
19
|
+
# into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
|
|
20
|
+
# in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
|
|
21
|
+
os = platform.include?("musl") ? "linux-musl" : "linux"
|
|
22
|
+
["#{os}-#{arch}", "libhypercast.so"]
|
|
17
23
|
else
|
|
18
|
-
raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{
|
|
24
|
+
raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
|
|
19
25
|
end
|
|
20
26
|
end
|
|
21
27
|
end
|