Skip to content

Load the networking stack only when an import needs it - #30

Merged
jamescook merged 4 commits into
mainfrom
wasm-friendly
Jul 31, 2026
Merged

Load the networking stack only when an import needs it#30
jamescook merged 4 commits into
mainfrom
wasm-friendly

Conversation

@jamescook

Copy link
Copy Markdown
Owner

Adding ssrf_filter in 0.6.0 made require 'cataract' depend on the socket extension. Both backends required import_resolver at load time, and that file required open-uri and ssrf_filter at file scope, which pull in net/http and resolv. Parsing a string of CSS needed a networking stack, and the library would not load at all where sockets don't exist - WebAssembly/WASI being the case that matters. v0.5.0 loaded fine there.

Three separate things reached for sockets:

  • both backends required import_resolver up front. Stylesheet#load_uri and #resolve_imports now require it on entry instead, which is every path that uses it.
  • import_resolver required open-uri and ssrf_filter at file scope, so option validation, URL normalization and file:// fetching couldn't be used without them - and neither could a caller-supplied fetcher, which is how a host with no sockets is meant to resolve imports at all. They load in DefaultFetcher#fetch_http now.
  • DefaultFetcher#call named OpenURI::HTTPError, SsrfFilter::Error and SocketError in rescue clauses. Ruby evaluates those constants when an exception passes through, so any failure raised NameError where the libraries were absent - including failures having nothing to do with the network. That translation moved down into #fetch_http with the requires.

#call also rescued StandardError and re-raised as ImportError, which caught the ImportError it had itself raised a few lines earlier, so "Unsupported scheme: ftp" was reported as "Error fetching import: ftp://... (Cataract::ImportError: Unsupported scheme: ftp)". It now re-raises ImportError untouched.

With this, on a host without sockets: the library loads, CSS parses and serializes, flatten works, @import statements parse and survive, and imports resolve either through a caller-supplied fetcher or from the filesystem. Only fetching over HTTP is unavailable, which is inherent.

ci-wasm.yml runs test/wasm/smoke.rb against a real ruby.wasm build under wasmtime to check exactly that. It is workflow_dispatch only: it pulls a prebuilt Ruby, covers the pure backend alone, and answers a question that changes rarely. The script uses plain raises rather than minitest, so gem resolution isn't a second thing that can fail.

Adding ssrf_filter in 0.6.0 made `require 'cataract'` depend on the
socket extension. Both backends required import_resolver at load time,
and that file required open-uri and ssrf_filter at file scope, which
pull in net/http and resolv. Parsing a string of CSS needed a networking
stack, and the library would not load at all where sockets don't exist -
WebAssembly/WASI being the case that matters. v0.5.0 loaded fine there.

Three separate things reached for sockets:

- both backends required import_resolver up front. Stylesheet#load_uri
  and #resolve_imports now require it on entry instead, which is every
  path that uses it.
- import_resolver required open-uri and ssrf_filter at file scope, so
  option validation, URL normalization and file:// fetching couldn't be
  used without them - and neither could a caller-supplied fetcher, which
  is how a host with no sockets is meant to resolve imports at all. They
  load in DefaultFetcher#fetch_http now.
- DefaultFetcher#call named OpenURI::HTTPError, SsrfFilter::Error and
  SocketError in rescue clauses. Ruby evaluates those constants when an
  exception passes through, so any failure raised NameError where the
  libraries were absent - including failures having nothing to do with
  the network. That translation moved down into #fetch_http with the
  requires.

#call also rescued StandardError and re-raised as ImportError, which
caught the ImportError it had itself raised a few lines earlier, so
"Unsupported scheme: ftp" was reported as "Error fetching import:
ftp://... (Cataract::ImportError: Unsupported scheme: ftp)". It now
re-raises ImportError untouched.

With this, on a host without sockets: the library loads, CSS parses and
serializes, flatten works, @import statements parse and survive, and
imports resolve either through a caller-supplied fetcher or from the
filesystem. Only fetching over HTTP is unavailable, which is inherent.

ci-wasm.yml runs test/wasm/smoke.rb against a real ruby.wasm build under
wasmtime to check exactly that. It is workflow_dispatch only: it pulls a
prebuilt Ruby, covers the pure backend alone, and answers a question
that changes rarely. The script uses plain raises rather than minitest,
so gem resolution isn't a second thing that can fail.
A failing worker raised "cataract benchmark failed" and nothing else.
Its stderr was streamed to the console as it arrived, but by the time
the exception surfaced - through a rake task, or a test that captures
output - it was gone, leaving a failure with no cause and no way to tell
which of the four variants to reproduce.

run_subprocess now keeps stderr alongside stdout, and a failed variant
reports the exit status, the full command with the backend/JIT/results
variables that select it, and the tail of stderr.

It distinguishes exiting from being killed, because those look identical
otherwise and read very differently:

  cataract benchmark failed - killed by SIGSEGV
  cataract benchmark failed - exited 3

The signal case matters here: the native worker runs a C extension
under Process.warmup, which compacts the heap, and a crash there
produces no Ruby backtrace at all.

Also drops three comments that documented the absence of a require
rather than the behaviour of anything present.
Core carries Time#iso8601 from Ruby 4.0 on. Below that it comes from the
time library, which nothing here required, so every benchmark run died on
3.3:

  undefined method `iso8601' for an instance of Time

SystemMetadata.collect runs during setup for every benchmark, so this hit
each of the four worker subprocesses rather than just the final write.
The failure was invisible on 4.0, which is where the harness was
developed.

Also sends the harness's own failure report to stderr instead of stdout,
and falls back to a worker's stdout when its stderr is empty. Both were
in the way of finding this: the worker printed its cause to stdout, the
parent captured only stderr, and the plumbing test wrapped the run in
capture_io - so the message existed and was discarded three times over.
@jamescook
jamescook marked this pull request as ready for review July 31, 2026 22:38
@jamescook
jamescook merged commit 327a807 into main Jul 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant