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.3is an experimental preview matching thequietquicandquietquic-proto0.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/ theasyncgem 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_sysgenerates a GNU Makefile (the base BSDmakecan't run it) and itsbindgenbuild script needslibclang:pkg install -y ruby devel/ruby-gems rust cmake gmake llvmand these environment variables set for the build:
export MAKE=gmake export LIBCLANG_PATH="$(ls -d /usr/local/llvm*/lib | tail -1)"(
gmakebecauserb_sys/rake-compiler emit a GNU-style Makefile;llvmsupplieslibclang.sofor rb-sys'sbindgenstep.) This is exactly what.github/workflows/ci.yml'sfreebsdjob 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(theasyncgem), the waiting call parks the current fiber viaFiber.scheduler'sio_waithook, so the reactor runs other tasks on the same thread while it waits. Two concurrent connections/streams inside oneAsyncblock 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.