hypercast 0.4.0 → 0.6.1
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 +158 -143
- 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/runtime.rb +66 -33
- data/lib/hypercast.rb +31 -57
- metadata +5 -8
- 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: 53866363cee28bda9e47267c85d4c835708fb9aa20635c3968cb32985d12c264
|
|
4
|
+
data.tar.gz: 654ae196935c0fb5cb8b0393ad11c1a5c5f52845321d1ac6468dfc4dc3159663
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fb20b6b023d88e80fc8ea334925310b5436c3aaca0ebcf86606f8794da375823019cc0efbf60445472bdcf6cee4cefd8f76eeb1475d87e3cbead7e26988d8e3
|
|
7
|
+
data.tar.gz: f51ff3c89feaaa99f38fec564f779364b358f1ba7f76c783e24202890e751183172df49352c7eeef6caf8bf54edf10b0493c8d453981e3136dd40791d969d31a
|
data/README.md
CHANGED
|
@@ -5,18 +5,19 @@
|
|
|
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 span that offended.
|
|
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
13
|
UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
|
|
15
14
|
floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
|
|
16
15
|
redefines the doors in place on the `HyperCast` module — no delegation layer, no second
|
|
17
16
|
surface, which is exactly what keeps the backends provably in agreement.
|
|
18
|
-
|
|
19
|
-
`
|
|
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).
|
|
20
21
|
|
|
21
22
|
```ruby
|
|
22
23
|
case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
|
|
@@ -87,7 +88,9 @@ mismatch against `HyperCast::VERSION` can be named before the first cast.
|
|
|
87
88
|
`HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
|
|
88
89
|
reads `:fiddle` on a platform with no library at all, because that is the backend whose
|
|
89
90
|
first call explains what is missing — a `LoadError` naming the path it looked for, or
|
|
90
|
-
`HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform.
|
|
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)).
|
|
91
94
|
|
|
92
95
|
### Fault spans index the String you passed
|
|
93
96
|
|
|
@@ -161,9 +164,9 @@ and memoized by identity on every backend, so declaring a currency costs a cast
|
|
|
161
164
|
parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
|
|
162
165
|
`urn:uuid:` prefixes, protobuf JSON durations.
|
|
163
166
|
3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
|
|
164
|
-
held by the shared corpus (the whole suite green on
|
|
165
|
-
|
|
166
|
-
|
|
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).
|
|
167
170
|
4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
|
|
168
171
|
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
|
|
169
172
|
i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
|
|
@@ -201,23 +204,23 @@ receipts include their own archaeology.)
|
|
|
201
204
|
|
|
202
205
|
`ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
|
|
203
206
|
first line names the backend, core version and Ruby it measured, so run it again under
|
|
204
|
-
`HYPERCAST_PURE=1`
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H),
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
| Door | Magnus | Fiddle |
|
|
212
|
-
|
|
213
|
-
| `bool` | 112 ns | 2.35 µs |
|
|
214
|
-
| `i32` | 133 ns | 2.63 µs |
|
|
215
|
-
| `f64` | 166 ns | 2.70 µs |
|
|
216
|
-
| `uuid` | 223 ns | 3.47 µs |
|
|
217
|
-
| `timestamp` | 447 ns | 3.25 µs |
|
|
218
|
-
| `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
|
|
219
|
-
| `duration` (ISO) | 632 ns | 2.91 µs |
|
|
220
|
-
| `i32`, a fault | 225 ns | 3.18 µs |
|
|
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.
|
|
210
|
+
|
|
211
|
+
Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), `benchmark-ips`, same session,
|
|
212
|
+
both backends:
|
|
213
|
+
|
|
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 |
|
|
221
224
|
|
|
222
225
|
A lean door on the Magnus backend is little more than the native call: the extension builds
|
|
223
226
|
the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
|
|
@@ -227,84 +230,34 @@ rather than through `Data.new`, whose keyword handling alone cost more than the
|
|
|
227
230
|
|
|
228
231
|
| `HyperCast::BACKEND` | What runs | Chosen when |
|
|
229
232
|
|---|---|---|
|
|
230
|
-
| `:native` | the core linked into a Magnus extension | a precompiled platform gem carries an extension for
|
|
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
|
+
| `: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) — or the `hypercast-wasm` gem is linked into a ruby.wasm interpreter; see [Ruby in the browser](#ruby-in-the-browser) |
|
|
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 |
|
|
233
235
|
|
|
234
|
-
Selection happens once, at `require`, in that order.
|
|
236
|
+
Selection happens once, at `require`, in that order.
|
|
235
237
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
245
247
|
|
|
246
248
|
**Threads.** Every backend is safe to call from any number of threads. The extension runs
|
|
247
249
|
under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
|
|
248
250
|
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.
|
|
250
|
-
|
|
251
|
-
all three.
|
|
251
|
+
buffers; the core itself keeps no state between calls. `spec/cast_spec.rb`'s
|
|
252
|
+
concurrent-callers example runs under both.
|
|
252
253
|
|
|
253
254
|
**Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
|
|
254
|
-
`Ractor::UnsafeError
|
|
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.
|
|
255
|
+
`Ractor::UnsafeError`.
|
|
303
256
|
|
|
304
257
|
## Verifying provenance
|
|
305
258
|
|
|
306
|
-
Every gem RubyGems.org serves — the universal fallback
|
|
307
|
-
platform gems — carries its own GitHub build-provenance attestation, signed directly by
|
|
259
|
+
Every gem RubyGems.org serves — the universal fallback, each of the seven precompiled
|
|
260
|
+
platform gems and `hypercast-wasm` — carries its own GitHub build-provenance attestation, signed directly by
|
|
308
261
|
this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
|
|
309
262
|
before the push), so plain `--repo` verifies any of them:
|
|
310
263
|
|
|
@@ -315,8 +268,9 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
|
|
|
315
268
|
|
|
316
269
|
That's the release's second layer of checking, not the only one: before any gem gets built,
|
|
317
270
|
the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
|
|
318
|
-
extensions (one per Ruby ABI per platform)
|
|
319
|
-
attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml
|
|
271
|
+
extensions (one per Ruby ABI per platform), the `wasm32-wasip1` archives — against *their own*
|
|
272
|
+
attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml` and
|
|
273
|
+
`hyper-build-wasm.yml`, so
|
|
320
274
|
that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
|
|
321
275
|
unverified one.
|
|
322
276
|
RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
|
|
@@ -332,15 +286,16 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
|
|
|
332
286
|
gem install hypercast
|
|
333
287
|
```
|
|
334
288
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
`
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
needed either
|
|
289
|
+
Nine gems are published per release. This section is about eight of them: seven precompiled Magnus platform gems that
|
|
290
|
+
`gem install` and `bundle` auto-select when they match — `x86_64-linux-gnu`,
|
|
291
|
+
`aarch64-linux-gnu`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`,
|
|
292
|
+
`x64-mingw-ucrt` and `aarch64-mingw-ucrt` — and one universal `ruby`-platform gem. A platform
|
|
293
|
+
gem carries its Magnus extensions and nothing else native: no Fiddle library at all. The
|
|
294
|
+
universal gem is the last resort, Fiddle with every platform's native library bundled, and it
|
|
295
|
+
is what RubyGems resolves for a Ruby the platform gems do not cover (3.3, or a Ruby newer than
|
|
296
|
+
the release, such as 4.1 before a release ships for it) and on a platform no platform gem is
|
|
297
|
+
built for — Intel macOS among them, which runs on Fiddle. No extra configuration needed either
|
|
298
|
+
way. The ninth, `hypercast-wasm`, is only for ruby.wasm; see [Ruby in the browser](#ruby-in-the-browser).
|
|
344
299
|
|
|
345
300
|
Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
|
|
346
301
|
is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
|
|
@@ -348,19 +303,21 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
|
|
|
348
303
|
carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
|
|
349
304
|
one at `require` time:
|
|
350
305
|
|
|
351
|
-
| Ruby |
|
|
352
|
-
| --- | --- | --- |
|
|
353
|
-
|
|
|
354
|
-
|
|
|
355
|
-
| 3.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
306
|
+
| Ruby | Linux (glibc and musl), Apple silicon macOS, Windows — x64 and arm64 | Gem installed |
|
|
307
|
+
| --- | --- | --- |
|
|
308
|
+
| a newer Ruby than the release covers (4.1+) | Fiddle | universal |
|
|
309
|
+
| 4.0 (primary) | Magnus, `BACKEND == :native` | platform |
|
|
310
|
+
| 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
|
|
311
|
+
| 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
|
|
312
|
+
|
|
313
|
+
What stands behind each cell: CI replays the whole suite, shared corpus included, through
|
|
314
|
+
each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
|
|
315
|
+
the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
|
|
316
|
+
every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
|
|
317
|
+
cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
|
|
318
|
+
over it. Anywhere else — a platform with no native build at all — the universal gem installs
|
|
319
|
+
but `HyperCast.available?` answers `false`, and the first call raises
|
|
320
|
+
`HyperCast::NativePlatform::UnsupportedPlatformError`.
|
|
364
321
|
|
|
365
322
|
The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
|
|
366
323
|
*declines* them outside that range and resolves the universal gem instead — a wrong-ABI
|
|
@@ -371,24 +328,25 @@ link libruby at all, so one can load successfully against the wrong ABI and misb
|
|
|
371
328
|
When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
|
|
372
329
|
exactly what the fallback is for.
|
|
373
330
|
|
|
374
|
-
**musl.**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
`
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
331
|
+
**musl.** Alpine has platform gems of its own, `x86_64-linux-musl` and
|
|
332
|
+
`aarch64-linux-musl`, whose extensions are built inside each Ruby's official `ruby:*-alpine`
|
|
333
|
+
image and need nothing beyond musl's libc. The glibc gems name their libc too
|
|
334
|
+
(`x86_64-linux-gnu`, `aarch64-linux-gnu`), which is what makes both `gem install` and
|
|
335
|
+
Bundler pick the right one on every supported Ruby: next to a plain `x86_64-linux` gem,
|
|
336
|
+
RubyGems before 4.0 resolves that one on Alpine instead, even under
|
|
337
|
+
`--platform x86_64-linux-musl`.
|
|
338
|
+
|
|
339
|
+
**Nothing in this gem is ever compiled, on any platform.** The platform gems depend on nothing
|
|
340
|
+
at all. The universal gem depends on `fiddle`, which can be: `fiddle` is a bundled gem on Ruby
|
|
341
|
+
4.0 and a default gem on 3.3 and 3.4, and `gem install` is satisfied by the copy Ruby ships.
|
|
342
|
+
Bundler resolves the newest `fiddle` on rubygems.org instead, and when that is newer than the
|
|
343
|
+
one your Ruby ships — true today on 3.3 and 3.4, not on 4.0 — it builds Fiddle's own C
|
|
344
|
+
extension, which takes a compiler and libffi's headers (`apk add build-base libffi-dev` on
|
|
345
|
+
Alpine). That only reaches you where the universal gem installs: Ruby 3.3, Intel macOS, a
|
|
346
|
+
Ruby newer than the release, or a platform with no platform gem. `bundle install
|
|
347
|
+
--prefer-local` makes Bundler use the copy Ruby ships where it can (it did on Ruby 3.4's
|
|
348
|
+
Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
|
|
349
|
+
to your Ruby's version in the `Gemfile.lock` does not stop the build.
|
|
392
350
|
|
|
393
351
|
Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
|
|
394
352
|
was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
|
|
@@ -401,25 +359,77 @@ the shipped extension small. The build script, and the two flags that are load-b
|
|
|
401
359
|
the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
|
|
402
360
|
forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
|
|
403
361
|
|
|
362
|
+
## Ruby in the browser
|
|
363
|
+
|
|
364
|
+
ruby.wasm cannot load an extension at runtime. `rbwasm build` cross-compiles the extension of
|
|
365
|
+
every gem in a Gemfile and links them all into the one interpreter it builds, so the browser
|
|
366
|
+
gets a gem of its own: `hypercast-wasm`, the same library and the same Magnus extension,
|
|
367
|
+
prebuilt for `wasm32-wasip1`. List it **instead of** `hypercast` in the Gemfile you build the
|
|
368
|
+
interpreter from (the two carry the same `lib/` files):
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
source "https://rubygems.org"
|
|
372
|
+
|
|
373
|
+
gem "hypercast-wasm"
|
|
374
|
+
gem "js" # JavaScript interop, which a browser app almost always wants
|
|
375
|
+
gem "bigdecimal" # only for Decimal#to_d; see below
|
|
376
|
+
|
|
377
|
+
group :development do
|
|
378
|
+
gem "ruby_wasm"
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```sh
|
|
383
|
+
bundle install
|
|
384
|
+
bundle exec rbwasm build --ruby-version 4.0 -o ruby.wasm
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wasm-wasi)
|
|
388
|
+
(`DefaultRubyVM` in a browser, `RubyVM.instantiateModule` under Node), then
|
|
389
|
+
`require "/bundle/setup"` and `require "hypercast"` as anywhere else. `HyperCast::BACKEND` is
|
|
390
|
+
`:native`, and every door works as it does on a host Ruby.
|
|
391
|
+
|
|
392
|
+
- **Ruby 3.4 and 4.0**, one archive each, the same minors the platform gems cover, picked by
|
|
393
|
+
`--ruby-version`. Any other minor stops the build with a message naming the ones the gem
|
|
394
|
+
carries. Built and tested against ruby_wasm 2.10.
|
|
395
|
+
- **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive, and
|
|
396
|
+
rbwasm downloads its own wasi-sdk. The first `rbwasm build` compiles Ruby itself and takes
|
|
397
|
+
15–20 minutes; later builds reuse it.
|
|
398
|
+
- **`Decimal#to_d` needs `gem "bigdecimal"` in that Gemfile.** bigdecimal is a bundled gem
|
|
399
|
+
with a C extension, and ruby.wasm builds a bundled gem's extension only when the Gemfile
|
|
400
|
+
names it; without it, `to_d` raises `LoadError`. `to_s` and `to_r` need nothing.
|
|
401
|
+
- **Static linking only,** into ruby.wasm's default `wasm32-unknown-wasip1` interpreter.
|
|
402
|
+
rbwasm's dynamic-linking build (a pic target, for the component model) is not supported.
|
|
403
|
+
- **Size.** rbwasm packs every gem's files into the interpreter, the archives included, so the
|
|
404
|
+
gem adds about 2 MB to `ruby.wasm` (both archives are gzipped).
|
|
405
|
+
- **With HyperUuid.** `hyperuuid-wasm` links into the same interpreter. Every Rust extension
|
|
406
|
+
that carries std defines a few of the same symbols (`rust_eh_personality`, which ruby.wasm's
|
|
407
|
+
own wasi-vfs defines too, rb-sys's `ruby_abi_version`, and one of std's), and both gems
|
|
408
|
+
rename them to names of their own when CI builds the archive, which also fails if a newer
|
|
409
|
+
Rust starts exporting another.
|
|
410
|
+
|
|
411
|
+
What stands behind it: CI builds each minor's archive from the commit, packs the gem the way
|
|
412
|
+
it ships, links it into a fresh interpreter and runs [`wasm-smoke/test.rb`](wasm-smoke/test.rb)
|
|
413
|
+
under Node and in headless Chrome (the forge's `hyper-build-wasm.yml`). The published gem is
|
|
414
|
+
packed around those attested archives.
|
|
415
|
+
|
|
404
416
|
## Development
|
|
405
417
|
|
|
406
418
|
Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
|
|
407
|
-
is needed to use the gem. The Fiddle
|
|
408
|
-
|
|
419
|
+
is needed to use the gem. The Fiddle backend finds the in-repo build on its own when nothing
|
|
420
|
+
is staged under `lib/hypercast/native/`. The Magnus backend does not — an
|
|
409
421
|
extension has to be built for the Ruby you are running and put where `require` looks — and
|
|
410
422
|
that is what `rake native:dev` is for.
|
|
411
423
|
|
|
412
424
|
```sh
|
|
413
425
|
cd rust
|
|
414
|
-
cargo
|
|
415
|
-
cargo build --release --target wasm32-wasip1 # hypercast.wasm, what the wasm backend loads
|
|
426
|
+
cargo cdylib # libhypercast, what the Fiddle backend loads
|
|
416
427
|
|
|
417
428
|
cd ../ruby
|
|
418
429
|
bundle install
|
|
419
430
|
bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
|
|
420
431
|
bundle exec rspec # BACKEND == :native
|
|
421
432
|
HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
|
|
422
|
-
HYPERCAST_WASM=1 bundle exec rspec # BACKEND == :wasm
|
|
423
433
|
bundle exec rake docs:check # every public object carries a doc comment
|
|
424
434
|
ruby benchmark/cast_benchmark.rb # prints the backend it measured
|
|
425
435
|
```
|
|
@@ -454,8 +464,8 @@ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
|
|
|
454
464
|
mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
|
|
455
465
|
cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
|
|
456
466
|
cd /work/ruby && rm -f Gemfile.lock
|
|
457
|
-
|
|
458
|
-
HYPERCAST_PURE=1
|
|
467
|
+
bundle install --quiet --prefer-local
|
|
468
|
+
HYPERCAST_PURE=1 bundle exec rspec'
|
|
459
469
|
```
|
|
460
470
|
|
|
461
471
|
The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
|
|
@@ -472,6 +482,11 @@ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
|
|
|
472
482
|
HYPERCAST_PURE=1 rspec'
|
|
473
483
|
```
|
|
474
484
|
|
|
485
|
+
The musl Magnus extension is built the way CI builds it by the forge's
|
|
486
|
+
`ruby-magnus-musl/build-magnus-musl.sh`, which runs on any machine with Docker: from the repo
|
|
487
|
+
root, `build-magnus-musl.sh hypercast linux-musl-x64 4.0 . <dir with the musl libhypercast.so> <out-dir>`
|
|
488
|
+
compiles it in `ruby:4.0-alpine`, then runs this suite through it on a bare copy of that image.
|
|
489
|
+
|
|
475
490
|
See [the repo root README](../README.md) for the full door table, the receipts, and the
|
|
476
491
|
state of every other language binding.
|
|
477
492
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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,12 +3,11 @@ 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
|
-
# from one Rust core, reached through whichever of
|
|
6
|
+
# from one Rust core, reached through whichever of two backends this install can load:
|
|
7
7
|
# the core linked straight into a Magnus native extension (shipped precompiled in the
|
|
8
|
-
# platform gems), the native libhypercast shared library called through Fiddle (the
|
|
9
|
-
# universal gem bundles one per supported platform)
|
|
10
|
-
#
|
|
11
|
-
# selection logic is at the bottom of this file. Every door returns a verdict: Success or
|
|
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
|
|
12
11
|
# Fault (a closed reason plus the offending span), never an exception for bad data — the
|
|
13
12
|
# only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
|
|
14
13
|
# that is not a String), never data, and the same exception on every backend.
|
|
@@ -30,7 +29,7 @@ require_relative "hypercast/runtime"
|
|
|
30
29
|
module HyperCast
|
|
31
30
|
# This gem's own version — kept in lockstep with hypercast.gemspec by the
|
|
32
31
|
# prepare-release workflow, so the two can never drift apart again.
|
|
33
|
-
VERSION = "0.
|
|
32
|
+
VERSION = "0.6.1"
|
|
34
33
|
|
|
35
34
|
# The success case of a verdict: a cast value.
|
|
36
35
|
Success = Data.define(:value)
|
|
@@ -199,11 +198,10 @@ module HyperCast
|
|
|
199
198
|
# Whether a backend actually loaded and exports the ABI this binding was built against
|
|
200
199
|
# — what a consumer with a fallback of its own checks before committing to these doors.
|
|
201
200
|
# Probed once (a native_version round trip: the cheapest call the core has), cached,
|
|
202
|
-
# and never raises: a missing shared library, an
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
# 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.
|
|
207
205
|
def available?
|
|
208
206
|
return @available unless @available.nil?
|
|
209
207
|
|
|
@@ -289,7 +287,7 @@ module HyperCast
|
|
|
289
287
|
#
|
|
290
288
|
# The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
|
|
291
289
|
# deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
|
|
292
|
-
# the bug for file compatibility. It is :
|
|
290
|
+
# the bug for file compatibility. It is :out_of_range here — the same verdict .date gives
|
|
293
291
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
294
292
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
295
293
|
def excel_serial(text, epoch)
|
|
@@ -392,10 +390,11 @@ module HyperCast
|
|
|
392
390
|
# registering a GC finalizer — measured as the dominant per-call cost by an order of
|
|
393
391
|
# magnitude. The NumFormat no longer lives here: each format owns its own packed
|
|
394
392
|
# pointer (see packed_cache), so a numeric call copies nothing into scratch before
|
|
395
|
-
# 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.
|
|
396
395
|
def scratch
|
|
397
396
|
Thread.current[:hypercast_scratch] ||= begin
|
|
398
|
-
base =
|
|
397
|
+
base = Runtime.buffer(24)
|
|
399
398
|
[base, base + 16]
|
|
400
399
|
end
|
|
401
400
|
end
|
|
@@ -445,9 +444,7 @@ module HyperCast
|
|
|
445
444
|
|
|
446
445
|
# The shared body of the four doors that take a caller-declared u32 — a precision, an
|
|
447
446
|
# epoch, or a field order — already resolved from its Symbol by the door. These bodies
|
|
448
|
-
# (plain, numeric, declared, and packed_version below) are the whole
|
|
449
|
-
# the wasm backend (wasm_runtime.rb) redefines exactly these four in place and nothing
|
|
450
|
-
# above them.
|
|
447
|
+
# (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
|
|
451
448
|
def declared(symbol, text, code, out_size)
|
|
452
449
|
bytes = utf8(text)
|
|
453
450
|
out, fault = scratch
|
|
@@ -480,7 +477,7 @@ module HyperCast
|
|
|
480
477
|
def packed_cache
|
|
481
478
|
@packed_cache ||= Hash.new do |cache, format|
|
|
482
479
|
cache.shift while cache.size >= PACKED_CACHE_LIMIT
|
|
483
|
-
pointer =
|
|
480
|
+
pointer = Runtime.buffer(32)
|
|
484
481
|
pointer[0, 32] = format.packed
|
|
485
482
|
cache[format] = pointer
|
|
486
483
|
end.compare_by_identity
|
|
@@ -497,30 +494,18 @@ end
|
|
|
497
494
|
# --- backend selection: the Magnus extension, when present, replaces the doors above in
|
|
498
495
|
# place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
|
|
499
496
|
# drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
|
|
500
|
-
# zero-compile fallback
|
|
501
|
-
#
|
|
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.
|
|
502
500
|
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
#
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
# Both variables are read for presence, not value — set to anything at all, "0" and the
|
|
511
|
-
# empty string included, they force their backend — and HYPERCAST_WASM wins when both are
|
|
512
|
-
# set.
|
|
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.
|
|
513
507
|
HyperCast::BACKEND =
|
|
514
|
-
if ENV["
|
|
515
|
-
begin
|
|
516
|
-
require "wasmtime"
|
|
517
|
-
rescue LoadError
|
|
518
|
-
raise LoadError,
|
|
519
|
-
"hypercast: HYPERCAST_WASM=1 needs the wasmtime gem — `gem install wasmtime` (or add it to your Gemfile)"
|
|
520
|
-
end
|
|
521
|
-
require_relative "hypercast/wasm_runtime"
|
|
522
|
-
:wasm
|
|
523
|
-
elsif ENV["HYPERCAST_PURE"]
|
|
508
|
+
if ENV["HYPERCAST_PURE"]
|
|
524
509
|
:fiddle
|
|
525
510
|
else
|
|
526
511
|
# Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
|
|
@@ -534,7 +519,11 @@ HyperCast::BACKEND =
|
|
|
534
519
|
# other exists.
|
|
535
520
|
#
|
|
536
521
|
# A miss on both is not an error: it means this Ruby/platform combination has no
|
|
537
|
-
# 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.
|
|
538
527
|
begin
|
|
539
528
|
require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
|
|
540
529
|
:native
|
|
@@ -543,22 +532,7 @@ HyperCast::BACKEND =
|
|
|
543
532
|
require "hypercast_native"
|
|
544
533
|
:native
|
|
545
534
|
rescue LoadError
|
|
546
|
-
|
|
547
|
-
:fiddle
|
|
548
|
-
else
|
|
549
|
-
# No shared library for this platform either. wasmtime, if the consumer has it,
|
|
550
|
-
# is the only backend left that can run here; without it, stay on Fiddle so the
|
|
551
|
-
# first call raises its own precise error — a "not found" LoadError naming the
|
|
552
|
-
# path, or NativePlatform::UnsupportedPlatformError naming the platform — rather
|
|
553
|
-
# than a vaguer one from here. HyperCast.available? is the quiet way to ask.
|
|
554
|
-
begin
|
|
555
|
-
require "wasmtime"
|
|
556
|
-
require_relative "hypercast/wasm_runtime"
|
|
557
|
-
:wasm
|
|
558
|
-
rescue LoadError
|
|
559
|
-
:fiddle
|
|
560
|
-
end
|
|
561
|
-
end
|
|
535
|
+
:fiddle
|
|
562
536
|
end
|
|
563
537
|
end
|
|
564
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -54,11 +54,10 @@ dependencies:
|
|
|
54
54
|
description: |
|
|
55
55
|
Booleans, numerics, exact decimals, UUIDs, and temporals cast from untrusted text by a
|
|
56
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.
|
|
58
|
-
selected automatically and
|
|
59
|
-
platform gem matches, stdlib Fiddle everywhere else
|
|
60
|
-
|
|
61
|
-
beyond Fiddle.
|
|
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.
|
|
62
61
|
executables: []
|
|
63
62
|
extensions: []
|
|
64
63
|
extra_rdoc_files: []
|
|
@@ -72,12 +71,10 @@ files:
|
|
|
72
71
|
- lib/hypercast/native/linux-x64/libhypercast.so
|
|
73
72
|
- lib/hypercast/native/osx-arm64/libhypercast.dylib
|
|
74
73
|
- lib/hypercast/native/osx-x64/libhypercast.dylib
|
|
75
|
-
- lib/hypercast/native/wasm32-wasip1/hypercast.wasm
|
|
76
74
|
- lib/hypercast/native/win-arm64/hypercast.dll
|
|
77
75
|
- lib/hypercast/native/win-x64/hypercast.dll
|
|
78
76
|
- lib/hypercast/native_platform.rb
|
|
79
77
|
- lib/hypercast/runtime.rb
|
|
80
|
-
- lib/hypercast/wasm_runtime.rb
|
|
81
78
|
homepage: https://github.com/SkunkWerkx/HyperCast
|
|
82
79
|
licenses:
|
|
83
80
|
- MIT
|
|
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
|