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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 319ce95965918c4804c0ebbd9183e93d5ca2e53d50ada708706faef12507d344
4
- data.tar.gz: d750c75771186f9b218530fcc29de061d3462efcd1cf81b0a524860b11b87b98
3
+ metadata.gz: 53866363cee28bda9e47267c85d4c835708fb9aa20635c3968cb32985d12c264
4
+ data.tar.gz: 654ae196935c0fb5cb8b0393ad11c1a5c5f52845321d1ac6468dfc4dc3159663
5
5
  SHA512:
6
- metadata.gz: c86acda45e6c9aa7044ac2c2671854d3f43a340ab9b0269c0220342454340517c049148865ad657df47ed32b57261ea2cfa1470d5e35b014407041b7e6d075e5
7
- data.tar.gz: c89ea288c80ebaaffc0071ed61ed376e8fa8656d1b88da83f1c04c05f2bd589fff901f54fb79dc6024f0aa4389e3c67c0a24a5964acf0cbcccc8388e731f447c
6
+ metadata.gz: 5fb20b6b023d88e80fc8ea334925310b5436c3aaca0ebcf86606f8794da375823019cc0efbf60445472bdcf6cee4cefd8f76eeb1475d87e3cbead7e26988d8e3
7
+ data.tar.gz: f51ff3c89feaaa99f38fec564f779364b358f1ba7f76c783e24202890e751183172df49352c7eeef6caf8bf54edf10b0493c8d453981e3136dd40791d969d31a
data/README.md CHANGED
@@ -5,18 +5,19 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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. Three backends, one public surface: a Magnus
9
- native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
10
- else — selected automatically, zero compiles either way — and the same core as a
11
- WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
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
- `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1` forces Fiddle,
19
- `HYPERCAST_WASM=1` the wasm module — see [Backends](#backends).
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 *all three* backends, full
165
- corpus replay; cross-backend agreement specs compare Magnus against Fiddle
166
- and wasm against Fiddle across a subprocess boundary).
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` or `HYPERCAST_WASM=1` for the other two backends. The stdlib comparisons
205
- are in [the section above](#why-not-integer--timeiso8601--float); this is the three backends
206
- against each other.
207
-
208
- Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), wasmtime 48.0.1,
209
- `benchmark-ips`, same session, all three backends:
210
-
211
- | Door | Magnus | Fiddle | wasmtime |
212
- |---|---:|---:|---:|
213
- | `bool` | 112 ns | 2.35 µs | 1.75 µs |
214
- | `i32` | 133 ns | 2.63 µs | 1.80 µs |
215
- | `f64` | 166 ns | 2.70 µs | 1.84 µs |
216
- | `uuid` | 223 ns | 3.47 µs | 2.65 µs |
217
- | `timestamp` | 447 ns | 3.25 µs | 2.42 µs |
218
- | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs | 3.14 µs |
219
- | `duration` (ISO) | 632 ns | 2.91 µs | 2.10 µs |
220
- | `i32`, a fault | 225 ns | 3.18 µs | 2.50 µs |
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 this Ruby's ABI — see [Install](#install) |
231
- | `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads; also the answer when nothing loads at all |
232
- | `:wasm` | the core as a `wasm32-wasip1` module inside the `wasmtime` gem | no extension and no library for this platform, and `wasmtime` is installed — see [WebAssembly (wasmtime)](#webassembly-wasmtime) |
233
+ | `: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. Two environment variables override it:
236
+ Selection happens once, at `require`, in that order.
235
237
 
236
- | Variable | Effect |
237
- |---|---|
238
- | `HYPERCAST_WASM` | forces `:wasm`; `require` raises a `LoadError` naming the gem if `wasmtime` is missing |
239
- | `HYPERCAST_PURE` | forces `:fiddle`, even where an extension would load |
240
-
241
- Both are read for presence, not value — set to anything at all, `0` and the empty string
242
- included, they force their backend — and `HYPERCAST_WASM` wins when both are set. A forced
243
- backend that turns out to have nothing to load does not fall through to another one: the
244
- first call raises, and `HyperCast.available?` answers `false`.
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. The wasm backend serializes every call
250
- on one Mutex around one instance. `spec/cast_spec.rb`'s concurrent-callers example runs under
251
- all three.
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` (`Ractor::IsolationError` under wasm).
255
-
256
- ## WebAssembly (wasmtime)
257
-
258
- The Rust core also ships inside this gem as a `wasm32-wasip1` module
259
- (`lib/hypercast/native/wasm32-wasip1/hypercast.wasm`), and the
260
- [`wasmtime`](https://rubygems.org/gems/wasmtime) gem can run it in-process. This is the
261
- inverse of ruby.wasm — not Ruby inside a wasm sandbox, but a wasm module inside Ruby — and
262
- it is the one backend that needs no shared library for the platform it runs on: no
263
- `dlopen`, no Magnus extension, nothing compiled against this Ruby's ABI. The Magnus
264
- extension replaces every public door in place; this backend replaces only the four private
265
- bodies underneath them (`plain`, `numeric`, `declared`, `packed_version` — the whole native
266
- crossing), so every door, both verdict `Data` types, the Symbol tables and the
267
- `Time`/`DateTime`/`Rational`/`Decimal` carriers are the exact code the other two backends
268
- run. `spec/wasm_backend_spec.rb` pins that the outputs agree with the Fiddle backend byte for
269
- byte.
270
-
271
- `wasmtime` is deliberately **not** a dependency of this gem; a consumer who wants this path
272
- installs it:
273
-
274
- ```sh
275
- gem install wasmtime
276
- HYPERCAST_WASM=1 ruby -rhypercast -e 'p HyperCast::BACKEND' # => :wasm
277
- ```
278
-
279
- `HYPERCAST_WASM=1` forces the backend (and raises a `LoadError` naming the gem if it is
280
- missing). Without it, the wasm backend is only ever chosen automatically when there is no
281
- native library for this platform at all — no Magnus extension and no `libhypercast` for the
282
- RID — and `wasmtime` happens to be installed. No supported platform's behavior changes just
283
- because this backend exists.
284
-
285
- Two things are different under the sandbox, both by necessity. A wasm guest only sees its own
286
- linear memory, so the input is copied into a grow-only guest buffer and the out-value, fault
287
- span and `NumFormat` live in guest allocations made once at load — all from the module's own
288
- exported `malloc` (the same wasi-libc allocator Rust's std uses on that target), read back
289
- with `Memory#read`; using the guest's allocator rather than a host-picked offset is what keeps
290
- a buffer from being clobbered by the guest's next allocation. And a `Wasmtime::Store` is
291
- single-threaded, so every call is serialized under one Mutex around one shared instance.
292
-
293
- Measured against the other two backends in the same session — the `wasmtime` column of the
294
- table under [Benchmarks](#benchmarks).
295
-
296
- The finding worth stating: in Ruby the wasm backend is no slower than Fiddle, and on the
297
- x64 box that table comes from it is the faster of the two on every door, by a fifth to a
298
- third. Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest
299
- crossing plus the guest memory copies; on the lean doors wasmtime sits eleven to sixteen
300
- times behind the Magnus extension and Fiddle sixteen to twenty-one, narrowing to three to
301
- five times where building the Ruby value dominates. So on a platform with no native build, the fallback costs a consumer nothing
302
- they were not already paying on the universal gem.
255
+ `Ractor::UnsafeError`.
303
256
 
304
257
  ## Verifying provenance
305
258
 
306
- Every gem RubyGems.org serves — the universal fallback and each of the five precompiled
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) and the wasm module — against *their own*
319
- attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
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
- Six gems are published per release: one universal `ruby`-platform gem (Fiddle, with every
336
- platform's native library and the wasm module bundled) plus five precompiled Magnus platform
337
- gems (`x86_64-linux`, `aarch64-linux`, `arm64-darwin`, `x64-mingw-ucrt`,
338
- `aarch64-mingw-ucrt`) that `gem install` and `bundle` auto-select when they match. There is
339
- no `x86_64-darwin` platform gem: an Intel Mac installs the universal gem and runs on Fiddle
340
- over the bundled `osx-x64` library, as Alpine does. A platform
341
- gem carries the same libraries and module beside its extensions, so the Fiddle and wasm
342
- backends are still there behind `HYPERCAST_PURE` and `HYPERCAST_WASM`. No extra configuration
343
- needed either way.
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 | glibc Linux, macOS, Windows — x64 and arm64 | musl Linux (Alpine) — x64 and arm64 | anywhere else |
352
- | --- | --- | --- | --- |
353
- | 4.0 (primary) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
354
- | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
355
- | 3.3 (the floor) | Fiddle | Fiddle | wasm, if `wasmtime` is installed |
356
-
357
- What stands behind each cell: CI replays the whole suite, shared corpus included, for the
358
- first column on every push — Magnus on Ruby 3.4 and 4.0, Fiddle and wasm on 4.0, on all five
359
- platforms with a CI leg (Intel macOS has none) — and the Fiddle suite for the musl column inside an Alpine container. The 3.3 row
360
- is the same Fiddle code path, run with the Docker command under [Development](#development)
361
- (Ruby 3.3 with the Fiddle 1.1.2 it ships) rather than on every push. The last column — wasm
362
- chosen automatically because nothing native exists — has no leg of its own: CI runs the wasm
363
- backend's suite forced, on platforms that have native builds too.
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.** RubyGems does not tell musl from glibc for these gems: on Alpine, `gem install`
375
- picks the `x86_64-linux` or `aarch64-linux` platform gem exactly as it does on any other
376
- Linux (checked with RubyGems 4.0.20 on `x86_64-linux-musl`). The Magnus extension inside is
377
- linked against glibc and cannot load there — `require` fails cleanly on the missing
378
- `ld-linux` loader — so the gem falls back to Fiddle, which loads the musl build of the core
379
- (`native/linux-musl-x64` or `native/linux-musl-arm64`, chosen from `RUBY_PLATFORM`) that
380
- every gem carries. Alpine therefore works as installed, on the Fiddle backend and at Fiddle's
381
- speed. Releases through 0.3.0 carried no musl library: there the fallback found only the
382
- glibc one, `HyperCast.available?` answered `false`, and the first cast raised
383
- `Fiddle::DLError`.
384
-
385
- **Nothing in this gem is ever compiled, on any platform.** Its one dependency can be:
386
- `fiddle` is a bundled gem on Ruby 4.0 and a default gem on 3.3 and 3.4, and `gem install` is
387
- satisfied by the copy Ruby ships. Bundler resolves the newest `fiddle` on rubygems.org
388
- instead, and when that is newer than the one your Ruby ships — true today on 3.3 and 3.4, not
389
- on 4.0 — it builds Fiddle's own C extension, which takes a compiler and libffi's headers
390
- (`apk add build-base libffi-dev` on Alpine). Holding `fiddle` at your Ruby's version in the
391
- `Gemfile.lock` avoids that.
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 and wasm backends find the in-repo builds on their own
408
- when nothing is staged under `lib/hypercast/native/`. The Magnus backend does not — an
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 build --release # libhypercast, what the Fiddle backend loads
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
- BUNDLE_WITHOUT=wasm bundle install --quiet --prefer-local
458
- HYPERCAST_PURE=1 BUNDLE_WITHOUT=wasm bundle exec rspec'
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
 
@@ -1,28 +1,32 @@
1
- require "fiddle"
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 here rather than a third-party one. A Ruby gem's files are already plain
7
- # files on disk once installed, so native/{rid}/{lib} dlopen's directly — no extraction.
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
- PLAIN = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP].freeze
12
- NUMERIC = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
13
- Fiddle::TYPE_VOIDP].freeze
14
- UNIX = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP,
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: PLAIN,
19
- cast_i8: NUMERIC, cast_i16: NUMERIC, cast_i32: NUMERIC, cast_i64: NUMERIC,
20
- cast_u8: NUMERIC, cast_u16: NUMERIC, cast_u32: NUMERIC, cast_u64: NUMERIC,
21
- cast_f32: NUMERIC, cast_f64: NUMERIC, cast_decimal: NUMERIC,
22
- cast_uuid: PLAIN,
23
- cast_timestamp: PLAIN, cast_unix: UNIX, cast_excel_serial: UNIX,
24
- cast_date: PLAIN, cast_date_ordered: UNIX, cast_datetime: UNIX,
25
- cast_time: PLAIN, cast_duration: PLAIN
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
- # Whether this platform has a shared library for Fiddle to dlopen at all: a known RID
45
- # and the file actually present — in this install, or in the in-repo cargo build the
46
- # dev loop falls back to. Backend selection (hypercast.rb) asks this before falling
47
- # back to the WebAssembly backend, which needs neither.
48
- def fiddle_library_available?
49
- !library_path.nil?
50
- rescue NativePlatform::UnsupportedPlatformError
51
- false
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
- functions = DOORS.to_h do |name, signature|
87
- [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
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 three backends this install can load:
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), or the same core as a WebAssembly
10
- # module inside the wasmtime gem. HyperCast::BACKEND names the one that loaded; the
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.4.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 older core without the version
203
- # export, or a wasm module the wasmtime gem cannot instantiate all answer false. The
204
- # doors themselves keep their own behavior — the first call on an unavailable backend
205
- # raises its precise LoadError, exactly as before — and so does require-time selection
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 :malformed here — the same verdict .date gives
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 = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
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 native crossing:
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 = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
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; precompiled platform gems are how the extension ships without
501
- # ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
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
- # The third backend is WebAssembly (lib/hypercast/wasm_runtime.rb): the same core as a
504
- # wasm32-wasip1 module, run in-process by the `wasmtime` gem, which is deliberately not a
505
- # runtime dependency of this gem — a consumer who wants it installs it. HYPERCAST_WASM=1
506
- # forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
507
- # there is no native library for this platform at all and wasmtime happens to be available,
508
- # so no supported platform's behavior changes by its existence.
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["HYPERCAST_WASM"]
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
- if HyperCast::Runtime.fiddle_library_available?
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.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. Three backends behind one surface,
58
- selected automatically and all shipped prebuilt: a Magnus extension where a precompiled
59
- platform gem matches, stdlib Fiddle everywhere else, and the same core as a WebAssembly
60
- module for anyone who installs the wasmtime gem. No runtime bridge, no dependencies
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
@@ -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