hypercast 0.3.0 → 0.6.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 +253 -113
- 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/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 +66 -33
- data/lib/hypercast.rb +81 -63
- metadata +18 -13
- data/lib/hypercast/native/wasm32-wasip1/hypercast.wasm +0 -0
- data/lib/hypercast/wasm_runtime.rb +0 -169
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f482418099cb3b96492d839da3f627716b4b4ba6a1b083d66f47f337edb197b3
|
|
4
|
+
data.tar.gz: 92e9560c193bd102dbbff75ace2b3aaa8dba75527a217c2b7f730f227af3e27b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f24fc4d78125c73b521c631318b778367321438c1791ee8e6055805d8bd2c274f2dd358d30af309350a201faba1aba0772815974a1c187d64896ad4f3f92af0
|
|
7
|
+
data.tar.gz: f349625cd4fd7b6dfa1311484186c924705a4c480213ace67aa1a6dea6b68e78d464f9187fbf0da88129c6995cc5f40e99fc01ae001088751ffe66b6e288af14
|
data/README.md
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
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
|
|
9
|
-
native extension where a precompiled platform gem covers you, stdlib Fiddle
|
|
10
|
-
else — selected automatically, zero compiles either way
|
|
11
|
-
WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
|
|
8
|
+
Symbol plus the exact span that offended. Two backends, one public surface: a Magnus
|
|
9
|
+
native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
|
|
10
|
+
resort everywhere else — selected automatically, zero compiles either way.**
|
|
12
11
|
|
|
13
12
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
|
|
14
|
+
floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
|
|
15
|
+
redefines the doors in place on the `HyperCast` module — no delegation layer, no second
|
|
16
|
+
surface, which is exactly what keeps the backends provably in agreement.
|
|
17
|
+
The last-resort fallback, in the universal gem only, calls the native `libhypercast` shared
|
|
18
|
+
library via [`Fiddle`](https://docs.ruby-lang.org/en/master/Fiddle.html) — dlopen/dlsym plus
|
|
19
|
+
raw C-ABI calls, no runtime bridge. `HyperCast::BACKEND` reports which one is live;
|
|
20
|
+
`HYPERCAST_PURE=1` forces Fiddle for testing — see [Backends](#backends).
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
23
|
case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
|
|
24
24
|
in HyperCast::Success(value:) then puts "got #{value}" # -1234
|
|
25
|
-
in HyperCast::Fault(reason:, offset:) then puts "#{reason} at
|
|
25
|
+
in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
|
|
26
26
|
end
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -52,13 +52,45 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
|
|
|
52
52
|
| `duration(text)` | exact `Rational` seconds | — |
|
|
53
53
|
|
|
54
54
|
Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
|
|
55
|
-
to `nil`. Beside the doors, `
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
56
|
+
`"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
|
|
57
|
+
[Is the native core there?](#is-the-native-core-there).
|
|
58
|
+
|
|
59
|
+
### Errors
|
|
60
|
+
|
|
61
|
+
Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
|
|
62
|
+
the same exception on every backend:
|
|
63
|
+
|
|
64
|
+
- `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
|
|
65
|
+
characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
|
|
66
|
+
whitespace.
|
|
67
|
+
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
68
|
+
that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
|
|
69
|
+
included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
|
|
70
|
+
door, exactly as if it were left off.
|
|
71
|
+
- `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
|
|
72
|
+
- `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
|
|
73
|
+
transcoded to UTF-8.
|
|
74
|
+
|
|
75
|
+
### Is the native core there?
|
|
76
|
+
|
|
77
|
+
`HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
|
|
78
|
+
binding was built against — probed once, cached, never raising — for a consumer with a
|
|
79
|
+
fallback of its own:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`HyperCast.native_version` reads the version out of the loaded core itself (its
|
|
86
|
+
`hypercast_version` export — the cheapest probe that the backend resolved at all), so a
|
|
87
|
+
mismatch against `HyperCast::VERSION` can be named before the first cast.
|
|
88
|
+
`HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
|
|
89
|
+
reads `:fiddle` on a platform with no library at all, because that is the backend whose
|
|
90
|
+
first call explains what is missing — a `LoadError` naming the path it looked for, or
|
|
91
|
+
`HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform. Inside a platform
|
|
92
|
+
gem, which carries no Fiddle library, that `LoadError` names the universal gem instead (see
|
|
93
|
+
[Backends](#backends)).
|
|
62
94
|
|
|
63
95
|
### Fault spans index the String you passed
|
|
64
96
|
|
|
@@ -132,19 +164,18 @@ and memoized by identity on every backend, so declaring a currency costs a cast
|
|
|
132
164
|
parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
|
|
133
165
|
`urn:uuid:` prefixes, protobuf JSON durations.
|
|
134
166
|
3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
|
|
135
|
-
held by the shared corpus (the whole suite green on
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
held by the shared corpus (the whole suite green on both backends, full corpus replay;
|
|
168
|
+
cross-backend agreement specs compare Magnus against Fiddle across a subprocess
|
|
169
|
+
boundary).
|
|
138
170
|
4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
|
|
139
|
-
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-
|
|
140
|
-
2.
|
|
141
|
-
duration door. The Fiddle fallback lands at
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
with parsing: every format other than `INVARIANT` paid three method dispatches and two
|
|
171
|
+
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
|
|
172
|
+
i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
|
|
173
|
+
returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
|
|
174
|
+
3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
|
|
175
|
+
|
|
176
|
+
Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
177
|
+
at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
|
|
178
|
+
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
|
|
148
179
|
`String` allocations per call to read its separators back out of the `Data`. `DETECT`
|
|
149
180
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
150
181
|
thread and memoized by identity — anchored in a thread-variable so the memo's key can
|
|
@@ -153,86 +184,79 @@ and memoized by identity on every backend, so declaring a currency costs a cast
|
|
|
153
184
|
cached the same way, so a fault or a declared option is a pointer compare too, never a
|
|
154
185
|
`Symbol#name` materialization.
|
|
155
186
|
|
|
156
|
-
**The honest trade-off
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
|
|
160
|
-
where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
**The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
|
|
188
|
+
level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
|
|
189
|
+
`Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
|
|
190
|
+
a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
|
|
191
|
+
where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
|
|
192
|
+
Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
|
|
193
|
+
as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
|
|
194
|
+
which is an artifact of the type, not a zone the parse assigned.
|
|
164
195
|
|
|
165
196
|
On the Fiddle fallback the doors are parity-at-best — Fiddle's
|
|
166
197
|
per-call floor is the mechanism's price, kept because it's the universal zero-compile
|
|
167
|
-
path.
|
|
168
|
-
|
|
169
|
-
doors interpolated and interned their `:cast_*` Symbol on every call, every door went
|
|
170
|
-
through a splat-and-resplat dispatcher, and every numeric call copied the format's 12
|
|
171
|
-
packed bytes into scratch — each format now owns one native pointer, memoized by identity,
|
|
172
|
-
passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
|
|
198
|
+
path. Its doors build nothing per call that does not change between calls: each format
|
|
199
|
+
owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
|
|
173
200
|
per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
|
|
174
201
|
receipts include their own archaeology.)
|
|
175
202
|
|
|
176
|
-
##
|
|
203
|
+
## Benchmarks
|
|
177
204
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
`dlopen`, no Magnus extension, nothing compiled against this Ruby's ABI. The Magnus
|
|
184
|
-
extension replaces every public door in place; this backend replaces only the four private
|
|
185
|
-
bodies underneath them (`plain`, `numeric`, `declared`, `packed_version` — the whole native
|
|
186
|
-
crossing), so every door, both verdict `Data` types, the Symbol tables and the
|
|
187
|
-
`Time`/`DateTime`/`Rational`/`Decimal` carriers are the exact code the other two backends
|
|
188
|
-
run. `spec/wasm_backend_spec.rb` pins that the outputs agree with the Fiddle backend byte for
|
|
189
|
-
byte.
|
|
205
|
+
`ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
|
|
206
|
+
first line names the backend, core version and Ruby it measured, so run it again under
|
|
207
|
+
`HYPERCAST_PURE=1` for the other backend. The stdlib comparisons are in
|
|
208
|
+
[the section above](#why-not-integer--timeiso8601--float); this is the two backends against
|
|
209
|
+
each other.
|
|
190
210
|
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), `benchmark-ips`, same session,
|
|
212
|
+
both backends:
|
|
193
213
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
214
|
+
| Door | Magnus | Fiddle |
|
|
215
|
+
|---|---:|---:|
|
|
216
|
+
| `bool` | 112 ns | 2.35 µs |
|
|
217
|
+
| `i32` | 133 ns | 2.63 µs |
|
|
218
|
+
| `f64` | 166 ns | 2.70 µs |
|
|
219
|
+
| `uuid` | 223 ns | 3.47 µs |
|
|
220
|
+
| `timestamp` | 447 ns | 3.25 µs |
|
|
221
|
+
| `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
|
|
222
|
+
| `duration` (ISO) | 632 ns | 2.91 µs |
|
|
223
|
+
| `i32`, a fault | 225 ns | 3.18 µs |
|
|
224
|
+
|
|
225
|
+
A lean door on the Magnus backend is little more than the native call: the extension builds
|
|
226
|
+
the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
|
|
227
|
+
rather than through `Data.new`, whose keyword handling alone cost more than the cast.
|
|
198
228
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
The finding worth stating: in Ruby the wasm backend lands at Fiddle parity, door for door.
|
|
228
|
-
Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest crossing
|
|
229
|
-
plus the guest memory copies; on this box they cost the same, and both sit 4-5x behind the
|
|
230
|
-
Magnus extension. So on a platform with no native build, the fallback costs a consumer
|
|
231
|
-
nothing they were not already paying on the universal gem.
|
|
229
|
+
## Backends
|
|
230
|
+
|
|
231
|
+
| `HyperCast::BACKEND` | What runs | Chosen when |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| `:native` | the core linked into a Magnus extension | a precompiled platform gem is installed — every one carries an extension for each Ruby it installs on; see [Install](#install) |
|
|
234
|
+
| `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads: the universal gem, on a Ruby or platform no platform gem covers; also the answer when nothing loads at all |
|
|
235
|
+
|
|
236
|
+
Selection happens once, at `require`, in that order.
|
|
237
|
+
|
|
238
|
+
`HYPERCAST_PURE` forces `:fiddle`, even where an extension would load. It is a testing and
|
|
239
|
+
diagnostic switch — CI runs the whole suite through it, and it is how to rule an extension
|
|
240
|
+
problem in or out — not a setting a deployment needs. It is read for presence, not value: set
|
|
241
|
+
to anything at all, `0` and the empty string included, it forces Fiddle. A forced backend that
|
|
242
|
+
turns out to have nothing to load does not fall through to another one: the first call raises,
|
|
243
|
+
and `HyperCast.available?` answers `false`. That is what happens inside a platform gem, which
|
|
244
|
+
carries no Fiddle library: the `LoadError` names the universal gem
|
|
245
|
+
(`gem install hypercast --platform ruby`, or Bundler's `force_ruby_platform`), where the
|
|
246
|
+
Fiddle backend lives.
|
|
247
|
+
|
|
248
|
+
**Threads.** Every backend is safe to call from any number of threads. The extension runs
|
|
249
|
+
under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
|
|
250
|
+
backend where Ruby threads run the core truly in parallel — each through its own scratch
|
|
251
|
+
buffers; the core itself keeps no state between calls. `spec/cast_spec.rb`'s
|
|
252
|
+
concurrent-callers example runs under both.
|
|
253
|
+
|
|
254
|
+
**Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
|
|
255
|
+
`Ractor::UnsafeError`.
|
|
232
256
|
|
|
233
257
|
## Verifying provenance
|
|
234
258
|
|
|
235
|
-
Every gem RubyGems.org serves — the universal fallback and each of the
|
|
259
|
+
Every gem RubyGems.org serves — the universal fallback and each of the seven precompiled
|
|
236
260
|
platform gems — carries its own GitHub build-provenance attestation, signed directly by
|
|
237
261
|
this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
|
|
238
262
|
before the push), so plain `--repo` verifies any of them:
|
|
@@ -243,10 +267,11 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
|
|
|
243
267
|
```
|
|
244
268
|
|
|
245
269
|
That's the release's second layer of checking, not the only one: before any gem gets built,
|
|
246
|
-
the same job verifies every native binary it packs
|
|
247
|
-
|
|
248
|
-
`SkunkWerkx/.github` by `hyper-build-native.yml`, so
|
|
249
|
-
`--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
|
|
270
|
+
the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
|
|
271
|
+
extensions (one per Ruby ABI per platform) — against *their own*
|
|
272
|
+
attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
|
|
273
|
+
that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
|
|
274
|
+
unverified one.
|
|
250
275
|
RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
|
|
251
276
|
bad artifact is still reversible. The release run's job summary then re-fetches every gem
|
|
252
277
|
from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
|
|
@@ -260,10 +285,16 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
|
|
|
260
285
|
gem install hypercast
|
|
261
286
|
```
|
|
262
287
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
`aarch64-linux`, `x86_64-
|
|
266
|
-
|
|
288
|
+
Eight gems are published per release: seven precompiled Magnus platform gems that
|
|
289
|
+
`gem install` and `bundle` auto-select when they match — `x86_64-linux-gnu`,
|
|
290
|
+
`aarch64-linux-gnu`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`,
|
|
291
|
+
`x64-mingw-ucrt` and `aarch64-mingw-ucrt` — and one universal `ruby`-platform gem. A platform
|
|
292
|
+
gem carries its Magnus extensions and nothing else native: no Fiddle library at all. The
|
|
293
|
+
universal gem is the last resort, Fiddle with every platform's native library bundled, and it
|
|
294
|
+
is what RubyGems resolves for a Ruby the platform gems do not cover (3.3, or a Ruby newer than
|
|
295
|
+
the release, such as 4.1 before a release ships for it) and on a platform no platform gem is
|
|
296
|
+
built for — Intel macOS among them, which runs on Fiddle. No extra configuration needed either
|
|
297
|
+
way.
|
|
267
298
|
|
|
268
299
|
Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
|
|
269
300
|
is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
|
|
@@ -271,11 +302,21 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
|
|
|
271
302
|
carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
|
|
272
303
|
one at `require` time:
|
|
273
304
|
|
|
274
|
-
| Ruby |
|
|
305
|
+
| Ruby | Linux (glibc and musl), Apple silicon macOS, Windows — x64 and arm64 | Gem installed |
|
|
275
306
|
| --- | --- | --- |
|
|
276
|
-
|
|
|
277
|
-
|
|
|
278
|
-
| 3.
|
|
307
|
+
| a newer Ruby than the release covers (4.1+) | Fiddle | universal |
|
|
308
|
+
| 4.0 (primary) | Magnus, `BACKEND == :native` | platform |
|
|
309
|
+
| 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
|
|
310
|
+
| 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
|
|
311
|
+
|
|
312
|
+
What stands behind each cell: CI replays the whole suite, shared corpus included, through
|
|
313
|
+
each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
|
|
314
|
+
the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
|
|
315
|
+
every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
|
|
316
|
+
cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
|
|
317
|
+
over it. Anywhere else — a platform with no native build at all — the universal gem installs
|
|
318
|
+
but `HyperCast.available?` answers `false`, and the first call raises
|
|
319
|
+
`HyperCast::NativePlatform::UnsupportedPlatformError`.
|
|
279
320
|
|
|
280
321
|
The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
|
|
281
322
|
*declines* them outside that range and resolves the universal gem instead — a wrong-ABI
|
|
@@ -283,7 +324,28 @@ extension must never be installed in the first place. On Windows it would at lea
|
|
|
283
324
|
load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
|
|
284
325
|
`x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
|
|
285
326
|
link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
|
|
286
|
-
|
|
327
|
+
When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
|
|
328
|
+
exactly what the fallback is for.
|
|
329
|
+
|
|
330
|
+
**musl.** Alpine has platform gems of its own, `x86_64-linux-musl` and
|
|
331
|
+
`aarch64-linux-musl`, whose extensions are built inside each Ruby's official `ruby:*-alpine`
|
|
332
|
+
image and need nothing beyond musl's libc. The glibc gems name their libc too
|
|
333
|
+
(`x86_64-linux-gnu`, `aarch64-linux-gnu`), which is what makes both `gem install` and
|
|
334
|
+
Bundler pick the right one on every supported Ruby: next to a plain `x86_64-linux` gem,
|
|
335
|
+
RubyGems before 4.0 resolves that one on Alpine instead, even under
|
|
336
|
+
`--platform x86_64-linux-musl`.
|
|
337
|
+
|
|
338
|
+
**Nothing in this gem is ever compiled, on any platform.** The platform gems depend on nothing
|
|
339
|
+
at all. The universal gem depends on `fiddle`, which can be: `fiddle` is a bundled gem on Ruby
|
|
340
|
+
4.0 and a default gem on 3.3 and 3.4, and `gem install` is satisfied by the copy Ruby ships.
|
|
341
|
+
Bundler resolves the newest `fiddle` on rubygems.org instead, and when that is newer than the
|
|
342
|
+
one your Ruby ships — true today on 3.3 and 3.4, not on 4.0 — it builds Fiddle's own C
|
|
343
|
+
extension, which takes a compiler and libffi's headers (`apk add build-base libffi-dev` on
|
|
344
|
+
Alpine). That only reaches you where the universal gem installs: Ruby 3.3, Intel macOS, a
|
|
345
|
+
Ruby newer than the release, or a platform with no platform gem. `bundle install
|
|
346
|
+
--prefer-local` makes Bundler use the copy Ruby ships where it can (it did on Ruby 3.4's
|
|
347
|
+
Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
|
|
348
|
+
to your Ruby's version in the `Gemfile.lock` does not stop the build.
|
|
287
349
|
|
|
288
350
|
Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
|
|
289
351
|
was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
|
|
@@ -294,7 +356,85 @@ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby u
|
|
|
294
356
|
with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
|
|
295
357
|
the shipped extension small. The build script, and the two flags that are load-bearing on
|
|
296
358
|
the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
|
|
297
|
-
forge's `
|
|
359
|
+
forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
|
|
360
|
+
|
|
361
|
+
## Development
|
|
362
|
+
|
|
363
|
+
Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
|
|
364
|
+
is needed to use the gem. The Fiddle backend finds the in-repo build on its own when nothing
|
|
365
|
+
is staged under `lib/hypercast/native/`. The Magnus backend does not — an
|
|
366
|
+
extension has to be built for the Ruby you are running and put where `require` looks — and
|
|
367
|
+
that is what `rake native:dev` is for.
|
|
368
|
+
|
|
369
|
+
```sh
|
|
370
|
+
cd rust
|
|
371
|
+
cargo cdylib # libhypercast, what the Fiddle backend loads
|
|
372
|
+
|
|
373
|
+
cd ../ruby
|
|
374
|
+
bundle install
|
|
375
|
+
bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
|
|
376
|
+
bundle exec rspec # BACKEND == :native
|
|
377
|
+
HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
|
|
378
|
+
bundle exec rake docs:check # every public object carries a doc comment
|
|
379
|
+
ruby benchmark/cast_benchmark.rb # prints the backend it measured
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
|
|
383
|
+
`ruby` feature into `rust/target/ruby/`, never over the plain library in
|
|
384
|
+
`rust/target/release/`) and copies the result to
|
|
385
|
+
`lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
|
|
386
|
+
first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
|
|
387
|
+
names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
|
|
388
|
+
file name it was asked to `require`. Three things worth knowing:
|
|
389
|
+
|
|
390
|
+
- **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
|
|
391
|
+
- **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
|
|
392
|
+
(`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
|
|
393
|
+
and stages beside the first, each Ruby loading its own.
|
|
394
|
+
- **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
|
|
395
|
+
backend" report themselves pending with `BACKEND=fiddle`; that line, or
|
|
396
|
+
`ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
|
|
397
|
+
exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
|
|
398
|
+
|
|
399
|
+
The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
|
|
400
|
+
flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
|
|
401
|
+
|
|
402
|
+
The musl build has no host to run on outside a container. With the core built for musl at
|
|
403
|
+
`rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
|
|
404
|
+
included — against it on Alpine, with the checkout mounted read-only:
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
|
|
408
|
+
mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
|
|
409
|
+
mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
|
|
410
|
+
cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
|
|
411
|
+
cd /work/ruby && rm -f Gemfile.lock
|
|
412
|
+
bundle install --quiet --prefer-local
|
|
413
|
+
HYPERCAST_PURE=1 bundle exec rspec'
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
|
|
417
|
+
Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
|
|
418
|
+
Fiddle that Ruby 3.3 ships:
|
|
419
|
+
|
|
420
|
+
```sh
|
|
421
|
+
docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
|
|
422
|
+
mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
|
|
423
|
+
mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
|
|
424
|
+
cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
|
|
425
|
+
cd /work/ruby && rm -f Gemfile Gemfile.lock
|
|
426
|
+
gem install rspec -v "~> 3.13" --no-document --silent
|
|
427
|
+
HYPERCAST_PURE=1 rspec'
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The musl Magnus extension is built the way CI builds it by the forge's
|
|
431
|
+
`ruby-magnus-musl/build-magnus-musl.sh`, which runs on any machine with Docker: from the repo
|
|
432
|
+
root, `build-magnus-musl.sh hypercast linux-musl-x64 4.0 . <dir with the musl libhypercast.so> <out-dir>`
|
|
433
|
+
compiles it in `ruby:4.0-alpine`, then runs this suite through it on a bare copy of that image.
|
|
298
434
|
|
|
299
435
|
See [the repo root README](../README.md) for the full door table, the receipts, and the
|
|
300
436
|
state of every other language binding.
|
|
437
|
+
|
|
438
|
+
## License
|
|
439
|
+
|
|
440
|
+
[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
|
|
@@ -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
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
# Autoloaded, not required: the platform gems carry no library for Fiddle to open and do not
|
|
2
|
+
# declare fiddle, so loading it eagerly would make `require "hypercast"` fail under Bundler
|
|
3
|
+
# wherever fiddle is a bundled rather than a default gem (Ruby 4.0). It loads the first time
|
|
4
|
+
# the Fiddle backend runs, and every path there goes through `functions` first, so a platform
|
|
5
|
+
# gem forced onto Fiddle still gets missing_library_message rather than a bare LoadError.
|
|
6
|
+
autoload :Fiddle, "fiddle"
|
|
2
7
|
|
|
3
8
|
module HyperCast
|
|
4
9
|
# Fiddle plumbing for the native libhypercast shared library — dlopen/dlsym plus raw
|
|
5
10
|
# C-ABI calls, no runtime bridge. Fiddle ships with every Ruby install; it's a plain gem
|
|
6
|
-
# dependency
|
|
7
|
-
# files on disk once installed, so native/{rid}/{lib}
|
|
11
|
+
# dependency of the universal gem (see hypercast.gemspec) rather than a third-party one.
|
|
12
|
+
# A Ruby gem's files are already plain files on disk once installed, so native/{rid}/{lib}
|
|
13
|
+
# dlopen's directly — no extraction.
|
|
8
14
|
module Runtime
|
|
9
15
|
NATIVE_DIR = File.join(__dir__, "native")
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Fiddle::TYPE_VOIDP].freeze
|
|
16
|
-
|
|
17
|
+
# Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
|
|
18
|
+
# so nothing here touches Fiddle until the Fiddle backend actually runs: plain is
|
|
19
|
+
# (text, len, out, fault), numeric adds the packed NumFormat pointer before out, and
|
|
20
|
+
# declared a caller-declared u32 (precision, epoch, field order) in the same place.
|
|
17
21
|
DOORS = {
|
|
18
|
-
cast_bool:
|
|
19
|
-
cast_i8:
|
|
20
|
-
cast_u8:
|
|
21
|
-
cast_f32:
|
|
22
|
-
cast_uuid:
|
|
23
|
-
cast_timestamp:
|
|
24
|
-
cast_date:
|
|
25
|
-
cast_time:
|
|
22
|
+
cast_bool: :plain,
|
|
23
|
+
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
24
|
+
cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
|
|
25
|
+
cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
|
|
26
|
+
cast_uuid: :plain,
|
|
27
|
+
cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
|
|
28
|
+
cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
|
|
29
|
+
cast_time: :plain, cast_duration: :plain
|
|
26
30
|
}.freeze
|
|
27
31
|
|
|
28
32
|
# The one export that is not a door: the zero-argument version probe, returning the
|
|
@@ -41,14 +45,13 @@ module HyperCast
|
|
|
41
45
|
functions.fetch(symbol)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
false
|
|
48
|
+
# Every Fiddle allocation goes through here, and loads the library first: that is what
|
|
49
|
+
# keeps a missing library reported as missing_library_message instead of as whatever
|
|
50
|
+
# touching Fiddle raises first. The per-thread scratch buffer and each packed NumFormat
|
|
51
|
+
# (hypercast.rb) pay for it once apiece.
|
|
52
|
+
def buffer(size)
|
|
53
|
+
functions
|
|
54
|
+
Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
private
|
|
@@ -65,6 +68,35 @@ module HyperCast
|
|
|
65
68
|
File.exist?(repo_build) ? repo_build : nil
|
|
66
69
|
end
|
|
67
70
|
|
|
71
|
+
# Why Fiddle found nothing to load. A precompiled platform gem is the one install where
|
|
72
|
+
# that is by design rather than a gap: it carries only its Magnus extensions, and the
|
|
73
|
+
# Fiddle backend is reached there only by forcing it (HYPERCAST_PURE) or because none
|
|
74
|
+
# of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for,
|
|
75
|
+
# such as the glibc Linux gem that `gem install` on RubyGems 3.x picks on Alpine. So
|
|
76
|
+
# that case names its fix, the universal gem, which carries every platform's library,
|
|
77
|
+
# instead of a missing path that reads like a packaging bug. Both arguments are
|
|
78
|
+
# parameters only so the specs can ask for every wording.
|
|
79
|
+
def missing_library_message(gem_platform = Gem.loaded_specs["hypercast"]&.platform,
|
|
80
|
+
forced = ENV.key?("HYPERCAST_PURE"))
|
|
81
|
+
rid, lib_name = NativePlatform.rid_and_library_name
|
|
82
|
+
missing = File.join(NATIVE_DIR, rid, lib_name)
|
|
83
|
+
if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
|
|
84
|
+
reason =
|
|
85
|
+
if forced
|
|
86
|
+
"HYPERCAST_PURE forces the Fiddle backend (unset it to use the extension)"
|
|
87
|
+
else
|
|
88
|
+
"none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
|
|
89
|
+
end
|
|
90
|
+
"hypercast: this #{gem_platform} platform gem carries only Magnus extensions, no " \
|
|
91
|
+
"Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
|
|
92
|
+
"platform: `gem install hypercast --platform ruby`, or Bundler's force_ruby_platform " \
|
|
93
|
+
"(#{missing} not found)"
|
|
94
|
+
else
|
|
95
|
+
"hypercast: #{missing} not found (unsupported platform, or this gem was built " \
|
|
96
|
+
"without a native library for it)"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
68
100
|
# Loaded lazily and exactly once; the native library and its function pointers live
|
|
69
101
|
# for the process's lifetime, same as every other binding (never dlclose'd). The
|
|
70
102
|
# unsynchronized read is the fast path — a per-call mutex acquisition measured as a
|
|
@@ -75,16 +107,17 @@ module HyperCast
|
|
|
75
107
|
|
|
76
108
|
def load_functions
|
|
77
109
|
path = library_path
|
|
78
|
-
if path.nil?
|
|
79
|
-
rid, lib_name = NativePlatform.rid_and_library_name
|
|
80
|
-
raise LoadError,
|
|
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)"
|
|
83
|
-
end
|
|
110
|
+
raise LoadError, missing_library_message if path.nil?
|
|
84
111
|
|
|
85
112
|
handle = Fiddle.dlopen(path)
|
|
86
|
-
|
|
87
|
-
|
|
113
|
+
plain = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
114
|
+
signatures = {
|
|
115
|
+
plain: plain,
|
|
116
|
+
numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
|
|
117
|
+
declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T)
|
|
118
|
+
}
|
|
119
|
+
functions = DOORS.to_h do |name, shape|
|
|
120
|
+
[name, Fiddle::Function.new(handle[name.to_s], signatures.fetch(shape), Fiddle::TYPE_INT32_T)]
|
|
88
121
|
end
|
|
89
122
|
functions[VERSION_PROBE] =
|
|
90
123
|
Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
|
data/lib/hypercast.rb
CHANGED
|
@@ -3,16 +3,20 @@ require_relative "hypercast/native_platform"
|
|
|
3
3
|
require_relative "hypercast/runtime"
|
|
4
4
|
|
|
5
5
|
# Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# from one Rust core, reached through whichever of two backends this install can load:
|
|
7
|
+
# the core linked straight into a Magnus native extension (shipped precompiled in the
|
|
8
|
+
# platform gems), or the native libhypercast shared library called through Fiddle (the
|
|
9
|
+
# universal gem bundles one per supported platform). HyperCast::BACKEND names the one that
|
|
10
|
+
# loaded; the selection logic is at the bottom of this file. Every door returns a verdict: Success or
|
|
11
|
+
# Fault (a closed reason plus the offending span), never an exception for bad data — the
|
|
12
|
+
# only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
|
|
13
|
+
# that is not a String), never data, and the same exception on every backend.
|
|
10
14
|
#
|
|
11
15
|
# Consume with Ruby's own pattern matching over the two Data case types:
|
|
12
16
|
#
|
|
13
17
|
# case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
|
|
14
18
|
# in HyperCast::Success(value:) then puts "got #{value}" # -1234
|
|
15
|
-
# in HyperCast::Fault(reason:, offset:) then puts "#{reason} at
|
|
19
|
+
# in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
|
|
16
20
|
# end
|
|
17
21
|
#
|
|
18
22
|
# Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
|
|
@@ -25,7 +29,7 @@ require_relative "hypercast/runtime"
|
|
|
25
29
|
module HyperCast
|
|
26
30
|
# This gem's own version — kept in lockstep with hypercast.gemspec by the
|
|
27
31
|
# prepare-release workflow, so the two can never drift apart again.
|
|
28
|
-
VERSION = "0.
|
|
32
|
+
VERSION = "0.6.0"
|
|
29
33
|
|
|
30
34
|
# The success case of a verdict: a cast value.
|
|
31
35
|
Success = Data.define(:value)
|
|
@@ -50,25 +54,29 @@ module HyperCast
|
|
|
50
54
|
# 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
|
|
51
55
|
# (ArgumentError), never a verdict.
|
|
52
56
|
NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency) do
|
|
53
|
-
# The widest currency symbol the native ABI carries inline, in UTF-8 bytes.
|
|
54
|
-
CURRENCY_MAX_BYTES = 16
|
|
55
|
-
|
|
56
57
|
# Validates the declared separators up front — single characters, and distinct from
|
|
57
58
|
# each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
|
|
58
59
|
# ASCII digit or ASCII whitespace, since those would collide with the digit scan and
|
|
59
60
|
# the trimming around the symbol), so a malformed format fails loudly as the caller bug
|
|
60
|
-
# it is.
|
|
61
|
+
# it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
|
|
62
|
+
# separator handed over in another encoding (a no-break space read from a Latin-1
|
|
63
|
+
# file, say) is the same code point on every backend rather than whatever its bytes
|
|
64
|
+
# happened to spell in the encoding it arrived in.
|
|
61
65
|
def initialize(decimal_sep:, group_sep:, flags:, currency: "")
|
|
62
66
|
raise ArgumentError, "separators must be single characters" unless
|
|
63
67
|
decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
|
|
64
68
|
group_sep.is_a?(String) && group_sep.length == 1
|
|
69
|
+
|
|
70
|
+
decimal_sep = decimal_sep.encode(Encoding::UTF_8)
|
|
71
|
+
group_sep = group_sep.encode(Encoding::UTF_8)
|
|
65
72
|
raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
|
|
66
73
|
decimal_sep == group_sep
|
|
67
74
|
raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
|
|
68
75
|
|
|
69
76
|
symbol = currency.encode(Encoding::UTF_8)
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
limit = self.class::CURRENCY_MAX_BYTES
|
|
78
|
+
raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
|
|
79
|
+
symbol.bytesize > limit
|
|
72
80
|
raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
|
|
73
81
|
symbol.match?(/[0-9\t\n\f\r ]/)
|
|
74
82
|
|
|
@@ -83,6 +91,11 @@ module HyperCast
|
|
|
83
91
|
end
|
|
84
92
|
end
|
|
85
93
|
|
|
94
|
+
# The widest currency symbol the native ABI carries inline, in UTF-8 bytes. Assigned out
|
|
95
|
+
# here rather than inside the Data.define block above: a constant written in that block
|
|
96
|
+
# is scoped lexically, and landed on HyperCast instead of on NumFormat.
|
|
97
|
+
NumFormat::CURRENCY_MAX_BYTES = 16
|
|
98
|
+
|
|
86
99
|
# Permit the group separator between digits (sizes not validated — between digits is the rule).
|
|
87
100
|
GROUPING = 1
|
|
88
101
|
# Permit accounting parentheses as negation: (1,234) is -1234.
|
|
@@ -185,11 +198,10 @@ module HyperCast
|
|
|
185
198
|
# Whether a backend actually loaded and exports the ABI this binding was built against
|
|
186
199
|
# — what a consumer with a fallback of its own checks before committing to these doors.
|
|
187
200
|
# Probed once (a native_version round trip: the cheapest call the core has), cached,
|
|
188
|
-
# and never raises: a missing shared library, an
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# under HYPERCAST_WASM=1 without wasmtime; this only answers the question quietly.
|
|
201
|
+
# and never raises: a missing shared library, an unsupported platform, or an older core
|
|
202
|
+
# without the version export all answer false. The doors themselves keep their own
|
|
203
|
+
# behavior — the first call on an unavailable backend raises its precise LoadError —
|
|
204
|
+
# this only answers the question quietly.
|
|
193
205
|
def available?
|
|
194
206
|
return @available unless @available.nil?
|
|
195
207
|
|
|
@@ -275,7 +287,7 @@ module HyperCast
|
|
|
275
287
|
#
|
|
276
288
|
# The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
|
|
277
289
|
# deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
|
|
278
|
-
# the bug for file compatibility. It is :
|
|
290
|
+
# the bug for file compatibility. It is :out_of_range here — the same verdict .date gives
|
|
279
291
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
280
292
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
281
293
|
def excel_serial(text, epoch)
|
|
@@ -351,9 +363,19 @@ module HyperCast
|
|
|
351
363
|
BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
|
|
352
364
|
|
|
353
365
|
# Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
|
|
354
|
-
# passes a String's bytes for void*
|
|
355
|
-
#
|
|
366
|
+
# passes a String's own bytes for void* — no copy of them); only foreign encodings pay
|
|
367
|
+
# a transcode, and text that cannot be transcoded raises String#encode's own
|
|
368
|
+
# Encoding:: error, the same one the Magnus extension raises through the same call.
|
|
369
|
+
#
|
|
370
|
+
# Text that is not a String is a caller bug, and the same TypeError on every backend:
|
|
371
|
+
# the extension takes its argument through Ruby's implicit String conversion (to_str,
|
|
372
|
+
# or a TypeError), so this does too.
|
|
356
373
|
def utf8(text)
|
|
374
|
+
unless text.is_a?(String)
|
|
375
|
+
converted = String.try_convert(text) or
|
|
376
|
+
raise TypeError, "no implicit conversion of #{text.class} into String"
|
|
377
|
+
text = converted
|
|
378
|
+
end
|
|
357
379
|
BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
358
380
|
end
|
|
359
381
|
|
|
@@ -368,10 +390,11 @@ module HyperCast
|
|
|
368
390
|
# registering a GC finalizer — measured as the dominant per-call cost by an order of
|
|
369
391
|
# magnitude. The NumFormat no longer lives here: each format owns its own packed
|
|
370
392
|
# pointer (see packed_cache), so a numeric call copies nothing into scratch before
|
|
371
|
-
# crossing.
|
|
393
|
+
# crossing. Allocated through Runtime.buffer, which loads the library first, so a
|
|
394
|
+
# missing one is reported as such before Fiddle is touched.
|
|
372
395
|
def scratch
|
|
373
396
|
Thread.current[:hypercast_scratch] ||= begin
|
|
374
|
-
base =
|
|
397
|
+
base = Runtime.buffer(24)
|
|
375
398
|
[base, base + 16]
|
|
376
399
|
end
|
|
377
400
|
end
|
|
@@ -421,9 +444,7 @@ module HyperCast
|
|
|
421
444
|
|
|
422
445
|
# The shared body of the four doors that take a caller-declared u32 — a precision, an
|
|
423
446
|
# epoch, or a field order — already resolved from its Symbol by the door. These bodies
|
|
424
|
-
# (plain, numeric, declared, and packed_version below) are the whole
|
|
425
|
-
# the wasm backend (wasm_runtime.rb) redefines exactly these four in place and nothing
|
|
426
|
-
# above them.
|
|
447
|
+
# (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
|
|
427
448
|
def declared(symbol, text, code, out_size)
|
|
428
449
|
bytes = utf8(text)
|
|
429
450
|
out, fault = scratch
|
|
@@ -437,14 +458,26 @@ module HyperCast
|
|
|
437
458
|
Runtime.function(:hypercast_version).call
|
|
438
459
|
end
|
|
439
460
|
|
|
461
|
+
# How many formats packed_cache remembers before it starts forgetting the oldest. Far
|
|
462
|
+
# more than a process that reuses its formats ever holds; what it bounds is the one
|
|
463
|
+
# that builds a NumFormat per request.
|
|
464
|
+
PACKED_CACHE_LIMIT = 64
|
|
465
|
+
|
|
440
466
|
# Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
|
|
441
467
|
# #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
|
|
442
468
|
# object, filled once from NumFormat#packed. Formats are reused constants in practice,
|
|
443
469
|
# so the common call finds its pointer in one lookup and packs nothing. The Hash holds
|
|
444
470
|
# the format, so a key can never be a recycled address; the race is benign (idempotent).
|
|
471
|
+
#
|
|
472
|
+
# Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
|
|
473
|
+
# every format ever cast with and its 32-byte allocation for the life of the process,
|
|
474
|
+
# which a consumer building a format per request experienced as a leak. A format
|
|
475
|
+
# evicted and then used again is simply packed again, and an evicted pointer stays
|
|
476
|
+
# alive for any call already holding it — the caller's own reference outlives the cache's.
|
|
445
477
|
def packed_cache
|
|
446
478
|
@packed_cache ||= Hash.new do |cache, format|
|
|
447
|
-
|
|
479
|
+
cache.shift while cache.size >= PACKED_CACHE_LIMIT
|
|
480
|
+
pointer = Runtime.buffer(32)
|
|
448
481
|
pointer[0, 32] = format.packed
|
|
449
482
|
cache[format] = pointer
|
|
450
483
|
end.compare_by_identity
|
|
@@ -461,37 +494,36 @@ end
|
|
|
461
494
|
# --- backend selection: the Magnus extension, when present, replaces the doors above in
|
|
462
495
|
# place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
|
|
463
496
|
# drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
|
|
464
|
-
# zero-compile fallback
|
|
465
|
-
#
|
|
497
|
+
# zero-compile fallback — the last resort, for a Ruby or a platform no platform gem covers;
|
|
498
|
+
# precompiled platform gems are how the extension ships without ever making a consumer
|
|
499
|
+
# compile anything, and they carry no Fiddle library at all.
|
|
466
500
|
#
|
|
467
|
-
#
|
|
468
|
-
#
|
|
469
|
-
#
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
#
|
|
501
|
+
# HYPERCAST_PURE forces Fiddle. It is a testing and diagnostic switch — CI runs the whole
|
|
502
|
+
# suite through it, and it is how a suspected extension bug is ruled in or out — not a
|
|
503
|
+
# setting a deployment needs. It is read for presence, not value, so "0" and the empty
|
|
504
|
+
# string force it too. Inside a platform gem there is no library for it to load: BACKEND
|
|
505
|
+
# still reads :fiddle, HyperCast.available? answers false, and the first door call raises a
|
|
506
|
+
# LoadError naming the universal gem, which is where the Fiddle backend lives.
|
|
473
507
|
HyperCast::BACKEND =
|
|
474
|
-
if ENV["
|
|
475
|
-
begin
|
|
476
|
-
require "wasmtime"
|
|
477
|
-
rescue LoadError
|
|
478
|
-
raise LoadError,
|
|
479
|
-
"hypercast: HYPERCAST_WASM=1 needs the wasmtime gem — `gem install wasmtime` (or add it to your Gemfile)"
|
|
480
|
-
end
|
|
481
|
-
require_relative "hypercast/wasm_runtime"
|
|
482
|
-
:wasm
|
|
483
|
-
elsif ENV["HYPERCAST_PURE"]
|
|
508
|
+
if ENV["HYPERCAST_PURE"]
|
|
484
509
|
:fiddle
|
|
485
510
|
else
|
|
486
511
|
# Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
|
|
487
512
|
# extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
|
|
488
513
|
# native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
|
|
489
|
-
# equivalent)
|
|
490
|
-
#
|
|
491
|
-
#
|
|
514
|
+
# equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
|
|
515
|
+
# same versioned path. CI's in-job staging additionally drops a single extension flat at
|
|
516
|
+
# lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
|
|
517
|
+
# builds into rust/target/ruby/release/; nothing but that rake task copies it here).
|
|
518
|
+
# Trying the versioned path first and the flat one second means neither has to know the
|
|
519
|
+
# other exists.
|
|
492
520
|
#
|
|
493
521
|
# A miss on both is not an error: it means this Ruby/platform combination has no
|
|
494
|
-
# precompiled extension, which is precisely what the Fiddle backend is for.
|
|
522
|
+
# precompiled extension, which is precisely what the Fiddle backend is for. A platform
|
|
523
|
+
# with no shared library either still lands on Fiddle, so the first call raises its own
|
|
524
|
+
# precise error — a "not found" LoadError naming the path, or
|
|
525
|
+
# NativePlatform::UnsupportedPlatformError naming the platform. HyperCast.available? is
|
|
526
|
+
# the quiet way to ask.
|
|
495
527
|
begin
|
|
496
528
|
require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
|
|
497
529
|
:native
|
|
@@ -500,21 +532,7 @@ HyperCast::BACKEND =
|
|
|
500
532
|
require "hypercast_native"
|
|
501
533
|
:native
|
|
502
534
|
rescue LoadError
|
|
503
|
-
|
|
504
|
-
:fiddle
|
|
505
|
-
else
|
|
506
|
-
# No shared library for this platform either. wasmtime, if the consumer has it,
|
|
507
|
-
# is the only backend left that can run here; without it, stay on Fiddle so the
|
|
508
|
-
# first call raises its own precise "not found" LoadError rather than a vaguer one
|
|
509
|
-
# from here.
|
|
510
|
-
begin
|
|
511
|
-
require "wasmtime"
|
|
512
|
-
require_relative "hypercast/wasm_runtime"
|
|
513
|
-
:wasm
|
|
514
|
-
rescue LoadError
|
|
515
|
-
:fiddle
|
|
516
|
-
end
|
|
517
|
-
end
|
|
535
|
+
:fiddle
|
|
518
536
|
end
|
|
519
537
|
end
|
|
520
538
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hypercast
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -51,12 +51,13 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0.9'
|
|
54
|
-
description:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
description: |
|
|
55
|
+
Booleans, numerics, exact decimals, UUIDs, and temporals cast from untrusted text by a
|
|
56
|
+
native Rust core — every parse returns a verdict (the value, or a reason plus the
|
|
57
|
+
offending span), never an exception for bad data. Two backends behind one surface,
|
|
58
|
+
selected automatically and both shipped prebuilt: a Magnus extension where a precompiled
|
|
59
|
+
platform gem matches, and stdlib Fiddle everywhere else. No runtime bridge, no
|
|
60
|
+
dependencies beyond Fiddle.
|
|
60
61
|
executables: []
|
|
61
62
|
extensions: []
|
|
62
63
|
extra_rdoc_files: []
|
|
@@ -65,20 +66,24 @@ files:
|
|
|
65
66
|
- README.md
|
|
66
67
|
- lib/hypercast.rb
|
|
67
68
|
- lib/hypercast/native/linux-arm64/libhypercast.so
|
|
69
|
+
- lib/hypercast/native/linux-musl-arm64/libhypercast.so
|
|
70
|
+
- lib/hypercast/native/linux-musl-x64/libhypercast.so
|
|
68
71
|
- lib/hypercast/native/linux-x64/libhypercast.so
|
|
69
72
|
- lib/hypercast/native/osx-arm64/libhypercast.dylib
|
|
70
73
|
- lib/hypercast/native/osx-x64/libhypercast.dylib
|
|
71
|
-
- lib/hypercast/native/wasm32-wasip1/hypercast.wasm
|
|
72
74
|
- lib/hypercast/native/win-arm64/hypercast.dll
|
|
73
75
|
- lib/hypercast/native/win-x64/hypercast.dll
|
|
74
76
|
- lib/hypercast/native_platform.rb
|
|
75
77
|
- lib/hypercast/runtime.rb
|
|
76
|
-
- lib/hypercast/wasm_runtime.rb
|
|
77
78
|
homepage: https://github.com/SkunkWerkx/HyperCast
|
|
78
79
|
licenses:
|
|
79
80
|
- MIT
|
|
80
81
|
metadata:
|
|
81
82
|
source_code_uri: https://github.com/SkunkWerkx/HyperCast
|
|
83
|
+
changelog_uri: https://github.com/SkunkWerkx/HyperCast/blob/master/CHANGELOG.md
|
|
84
|
+
bug_tracker_uri: https://github.com/SkunkWerkx/HyperCast/issues
|
|
85
|
+
documentation_uri: https://github.com/SkunkWerkx/HyperCast/tree/master/ruby#readme
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
82
87
|
rdoc_options: []
|
|
83
88
|
require_paths:
|
|
84
89
|
- lib
|
|
@@ -86,15 +91,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
86
91
|
requirements:
|
|
87
92
|
- - ">="
|
|
88
93
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: '3.
|
|
94
|
+
version: '3.3'
|
|
90
95
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
96
|
requirements:
|
|
92
97
|
- - ">="
|
|
93
98
|
- !ruby/object:Gem::Version
|
|
94
99
|
version: '0'
|
|
95
100
|
requirements: []
|
|
96
|
-
rubygems_version: 4.0.
|
|
101
|
+
rubygems_version: 4.0.20
|
|
97
102
|
specification_version: 4
|
|
98
|
-
summary:
|
|
99
|
-
|
|
103
|
+
summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
|
|
104
|
+
prebuilt
|
|
100
105
|
test_files: []
|
|
Binary file
|
|
@@ -1,169 +0,0 @@
|
|
|
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
|