quietquic (Ruby bindings)

DISCLAIMER: This was 100% AI coded, prompted by the human "creator" but planned and designed by multiple AI models including those from Anthropic, Google, and OpenAI. Trust at your own peril.

Ruby bindings for the independent quietquic Rust project, a cloaked QUIC transport: scanner-invisible (silent to any peer that doesn't prove PSK possession) and camouflaged as ordinary QUIC v1/HTTP3 traffic on the wire. This gem is a thin native-extension wrapper (Magnus + rb-sys) around the Rust quietquic crate's public API — it adds no transport or cryptographic logic of its own, only a Ruby-friendly client/server surface with first-class async-gem cooperation.

Release status: 0.1.0.alpha.3 is an experimental preview matching the quietquic and quietquic-proto 0.1.0-alpha.3 Rust components. It is not production quality and has not received an independent security audit.

See the Rust crate's README for the full threat model (what quietquic defeats and does not defeat) — read it before deploying; nothing here repeats it. The design rationale for this gem specifically is in docs/specs/2026-07-04-quietquic-ruby-design.md.


Install

The gem builds a native Rust extension at install time, so an installing machine needs a Rust toolchain (stable, via rustup or a system package) in addition to Ruby.

From a checkout

Add this gem to a Gemfile pointing at this checkout (path source) or at the repository directly (git source):

# Path source (e.g. this repo checked out alongside your app):
gem "quietquic", path: "../quietquicrb"

# ...or, after the repository is hosted, its git URL:
gem "quietquic", git: "https://github.com/astounding/quietquicrb.git"

Then:

bundle install

Bundler resolves rb_sys/rake-compiler (declared in the gemspec) and drives extconf.rb, which compiles the bundled Rust extension crate via cargo. The extension depends on the exact published quietquic 0.1.0-alpha.3 crate from crates.io; it does not use a local QuietQUIC checkout or vendored Rust source. This is the supported, tested install path — it's exactly what this repo's own test suite and CI (.github/workflows/ci.yml, covering Linux, macOS, and FreeBSD) exercise.

Standalone gem install — status

A built .gem (rake build / gem build quietquic.gemspec) packages only the Ruby binding and its native extension sources. During installation, Cargo downloads the published Rust crates from crates.io. rb_sys is declared as a runtime dependency precisely so a plain gem install quietquic can resolve everything it needs to compile the extension. What we have not set up yet is publishing this gem to a public gem source (e.g. rubygems.org) — until then, gem install quietquic "from the internet" has nothing to fetch. If you have a built .gem file in hand (or a private gem server hosting it), installing it directly works today; the Bundler path/git source above remains the recommended flow for working from this repository.

Platforms & prerequisites

  • Ruby 3.1+ (MRI). Fiber.scheduler / the async gem need a reasonably modern Ruby; older Rubies are not supported.

  • macOS, Linux, FreeBSD.

  • Rust 1.88+ reachable as cargo/rustc (matching the Rust crates' minimum supported toolchain).

  • FreeBSD needs a few extra system packages beyond the default toolchain, because rb_sys generates a GNU Makefile (the base BSD make can't run it) and its bindgen build script needs libclang:

    pkg install -y ruby devel/ruby-gems rust cmake gmake llvm
    

    and these environment variables set for the build:

    export MAKE=gmake
    export LIBCLANG_PATH="$(ls -d /usr/local/llvm*/lib | tail -1)"
    

    (gmake because rb_sys/rake-compiler emit a GNU-style Makefile; llvm supplies libclang.so for rb-sys's bindgen step.) This is exactly what .github/workflows/ci.yml's freebsd job does.


Quickstart

require "quietquic"

Server

srv = QuietQUIC::Server.bind(
  listen:  "0.0.0.0:4433",
  clients: { "laptop" => "aabb…(64 hex chars)" } # client_id => psk_hex
)
puts srv.local_address # => "0.0.0.0:4433" (the kernel-chosen port if you bound :0)

loop do
  conn = srv.accept # parks the fiber under Async; blocks (GVL-released) otherwise
  Thread.new do
    stream = conn.accept_stream
    data = stream.read_to_end
    puts "got #{data.bytesize} bytes from #{conn.remote_address}"

    # Reply on a second stream.
    reply = conn.open_stream
    reply.write_all("thanks: #{data}")
    reply.finish_and_wait
  end
end

Or from a TOML secrets file (reuses the crate's own loader, including its chmod 600 group/world-readable warning):

srv = QuietQUIC::Server.bind_toml("server.toml")

Client

conn = QuietQUIC::Client.connect(
  client_id: "laptop",
  psk:       "aabb…(64 hex chars)",
  server:    "example.com:4433"
)

out = conn.open_stream
out.write_all("hello over a cloaked pipe")
out.finish

reply_stream = conn.accept_stream
puts reply_stream.read_to_end

conn.close

Or: conn = QuietQUIC::Client.connect_toml("client.toml").

That's the whole alpha surface: Server.bind/.bind_toml/#accept/#local_address/#close, Client.connect/.connect_toml, and Connection#open_stream/#accept_stream/#remote_address/#close, Stream#write_all/#finish/#finish_and_wait/#read_to_end. Bytes in and out are binary Strings (Encoding::BINARY); PSKs are 64-hex-character Strings.


Async cooperation

Every method that can wait (Server#accept, Client.connect/.connect_toml, Connection#open_stream/#accept_stream, Stream#write_all/#finish/ #finish_and_wait/#read_to_end) goes through the same bridge:

  • Inside Async do … end (the async gem), the waiting call parks the current fiber via Fiber.scheduler's io_wait hook, so the reactor runs other tasks on the same thread while it waits. Two concurrent connections/streams inside one Async block genuinely interleave rather than serialize.
  • With no scheduler installed, the same call blocks the calling thread while releasing the GVL, so plain scripts work unmodified and other Ruby Threads keep making progress.

async is an optional peer dependency — this gem works with or without it installed; it only changes behavior if a Fiber.scheduler is actually active when you call in. Applications that want this mode must add gem "async" to their Gemfile (or install it separately); it is not a runtime dependency of quietquic.

Two execution models

The ordinary two-terminal examples require no Ruby event-loop library:

ruby examples/echo_server.rb
ruby examples/echo_client.rb

QuietQUIC performs their network operations on its native process-global Tokio runtime. Ruby waits through the native self-pipe bridge; without a fiber scheduler the calling Ruby thread blocks with the GVL released.

The cooperative example keeps the Ruby application on one event-loop thread:

gem install async
ruby examples/cooperative_async.rb

It deliberately parks a server fiber in accept, runs a visible ticker on the same Ruby thread, and then completes an echo round trip. “Single-threaded” describes the Ruby application side: native Tokio worker threads still perform the transport I/O. The example does not use Thread.new.


Fork safety

This gem runs async operations on a process-global multi-thread tokio runtime. That runtime's worker threads do not survive fork(2) — only the forking thread is duplicated into the child. So a pre-forking Ruby server (Puma/Unicorn, or Spring) that touches the runtime in the parent and then forks leaves each worker child with a runtime whose worker threads are gone; the child's first await would otherwise hang forever.

To make that a loud, actionable failure instead of a silent hang, the gem records the PID at runtime initialization and checks it before every async op. A call from a process that inherited an already-initialized runtime raises:

QuietQUIC::Error: quietquic's async runtime does not survive fork();
initialize it after forking (e.g. in a Puma on_worker_boot / after_fork hook)
rather than before

Workaround: initialize (and use) quietquic only after forking. Don't open connections/servers in the parent; do it in the worker-boot hook so each child builds its own runtime:

# config/puma.rb
on_worker_boot do
  # First quietquic use in THIS child initializes its own runtime.
  $qq_conn = QuietQUIC::Client.connect(client_id: "...", psk: "...", server: "...")
end

(Unicorn: after_fork. Spring: re-establish inside the forked worker.) A parent that never touches quietquic before forking is fine — the child initializes the runtime in its own process on first use.


Streams

The Rust quietquic 0.1.0-alpha.3 API splits bidirectional streams into send and receive halves. The Ruby API keeps a small Stream facade around those halves: write_all, finish, and finish_and_wait operate on the send half; read_to_end(limit:) operates on the receive half.

finish queues FIN locally. finish_and_wait(timeout: 10) queues FIN and waits for the peer's QUIC transport to acknowledge the send half; that proves transport receipt, not that the peer application processed the bytes. Use it before immediately closing a connection whose final stream must reach the peer. The timeout is an upper bound, not an unconditional delay.

read_to_end(limit:) parks until the peer sends FIN and defaults to a 1 MiB limit so an authenticated peer cannot grow receiver memory without bound.

Send-half calls on the same Stream are serialized with each other, and receive-half calls are serialized with each other. A read and a write on the same Stream use separate native halves, but both sides of a protocol still need sane ordering: if both peers wait to read before either peer finishes writing, the application protocol has deadlocked.

The echo examples use two streams for a simple request/reply shape:

out = conn.open_stream
out.write_all(payload)
out.finish

in_stream = conn.accept_stream
reply = in_stream.read_to_end

Exceptions

All native-boundary errors raise under one hierarchy so a bare rescue QuietQUIC::Error catches everything the gem can raise:

  • QuietQUIC::Error — base class (< StandardError).
    • QuietQUIC::ConfigError — bad config: malformed PSK hex, a bad listen/ server address, a missing/invalid TOML file.
    • QuietQUIC::ConnectError — the client's handshake failed or timed out (the crate's own internal connect timeout, ~10s, covers "nothing answered").
    • QuietQUIC::ConnectionLost — a previously-established connection was closed or dropped (locally or by the peer) while a call was waiting on it.
    • QuietQUIC::StreamError — a stream-level failure: reset, stopped, or refused.

A Rust panic in the extension is turned into a Ruby exception rather than aborting the process — a bug in the extension should not crash your Ruby process outright:

  • A panic at a synchronous native boundary is converted to an exception by Magnus's panic hook.
  • A panic inside an async operation (a future spawned on the runtime) is caught and surfaced as QuietQUIC::Error (base class) with the message "quietquic internal error: the async operation panicked", so the awaiting call raises instead of crashing or hanging. (Without this, the result slot would never be populated and the awaiting fiber/thread would park forever.)

License

0BSD (BSD Zero Clause), same as the Rust crate. See LICENSE.