Kino is a high-performance Ractor web server for Ruby 4.0+.
Ruby threads cannot run Ruby code in parallel, so production setups fork a process per core and pay for each copy in memory. Kino runs your code on every core in one small process. A Rust (tokio + hyper) front-end owns the network, parallel Ractors run your Rack 3 app, and a threaded fallback mode runs everything else, Rails included.
- Fast. On a real 8-core server, every Kino mode is 1.5-1.7× ahead of a Puma fork cluster on I/O-light endpoints—with native HTTP/2 adding another +79% over HTTP/1.1 on the same server. Ractor mode also wins on pure CPU, +25%. Benchmarks below.
- A fraction of the memory. About ~8× on the simplistic bench Ractor app, and about 4× less memory than a Puma cluster serving Rails in fallback threaded mode.
- Parallel without forking. Ractor mode runs CPU work more than 5× faster than Kino's own GVL-bound threaded mode, in the same small process.
- Production plumbing included. Graceful drain, crash supervision
and respawn, bounded queues with 503 backpressure, request timeouts,
hardened intake (slowloris and TLS-handshake deadlines, connection
and body-size caps), an
on_errorhook for your error tracker, TLS (rustls), live stats, async access and app logging. - Tells you why.
kino --checklists exactly what blocks your app from ractor mode, finding by finding, so you do not have to decodeRactor::IsolationErroryourself. - Puma-shaped. The same
workers × threadstopology, a familiar config DSL, akinoCLI. If you can run Puma, you can run Kino.
N.B.: Ractors are officially experimental in Ruby 4.0, and so is this server. The threaded mode is solid. Still, Kino aims to be the best way to experiment with Ractors today—and the best Ractor server when they become stable.
- Why
- Benchmarks
- Install
- Usage
- Config file and CLI
kino --check- Request timeouts
- Stats
- Logging
- Timer waits
- Rack 3 compliance
- Rails
The GVL allows only one Ruby thread to run at a time. To use all cores,
Ruby servers fork processes, and every fork costs a full copy of the
app. Ractors do not have this limit: each one has its own lock, so one
process can run Ruby in parallel. What was missing is a server that
dispatches requests to them. Ruby 4.0 reworked Ractors (Ractor::Port,
shareable_proc, less lock contention) and made this worth building.
Why a Ractor server has to be built this way, and which Rust parts make Ractors fast here: doc/why-kino.md. The full design notes live in doc/architecture.md.
Measured on a real server: AWS c7a.2xlarge (8-core AMD EPYC 9R14, 16 GB, Amazon Linux 2023). This is a realistic app-server size.
These tables run a tiny synthetic Rack app—plaintext, a 10 KB body,
a CPU-bound fib, a 5 ms wait—deliberately small, to measure the server
rather than an app. It is Ractor-shareable, so Kino runs it in :ractor
mode (and :threaded for comparison). A real Rails app is a different
story: it is not Ractor-shareable, so it runs only in Kino's
:threaded fallback, with its own numbers—see Rails below.
Ruby 4.0.6 with YJIT (re-measured 2026-09), every server at its
defaults: Puma forks 8 workers × 3 threads, Kino stays in one process
(8 workers; 1 thread each in ractor modes, 3 in threaded). Numbers are
req/s by wrk (8-second windows, 64 connections, same host).
Methodology: doc/benchmarks.md.
| endpoint | Kino :ractor | + lanes | :ractor, workers 32² |
Kino :threaded | Puma (cluster) |
|---|---|---|---|---|---|
| /plaintext | 222,980 | 244,652 | 115,553 | 213,104 | 142,094 |
| /10k | 175,182 | 188,365 | 103,147 | 132,009 | 125,116 |
| /cpu (fib) | 76,695¹ | 71,830 | 54,062 | 13,463 | 61,329 |
| /io (5 ms) | 1,548 | 1,556 | 5,388 | 4,722 | 4,699 |
| /io_native | 1,570 | 1,573 | 6,207 | 4,695 | 4,693 |
Memory tells two different stories depending on the app, both by PSS (proportional set size; see note) after sustained load.
The tiny benchmark app (Ractor-shareable, so Kino runs it in :ractor
or :threaded). Kino is ~8× lighter in :ractor mode, ~10× in :threaded
than the Puma cluster—the gap stays large because a trivial app is almost
all private per-worker heap, which copy-on-write can't share:
| tiny app, Kino | Kino (one process) | Puma cluster (8 workers) | ratio |
|---|---|---|---|
| :ractor (8×1) | 135 MB | 1,072 MB | ~8× |
| :threaded (8×3) | 107 MB³ | 1,072 MB | ~10× |
A real Rails app (not Ractor-shareable—Kino's :threaded fallback
only, below). The gap is ~4×, smaller because Rails' large
framework is shared copy-on-write across Puma's forks:
| Rails hello-world | Kino :threaded | Puma cluster (8 workers) | ratio |
|---|---|---|---|
| PSS | 95 MB | 405 MB | ~4× |
"+ lanes" is the experimental per-worker-queue dispatcher (lanes true).
It posts the fastest plaintext/10k of any configuration here. Details:
doc/benchmarks.md.
¹ Stock settings, no tuning. Ractor mode beats the fork cluster on pure
CPU by +25% (+17% with lanes; Puma 8.0.2 narrowed this from the +34%
measured against 7.x). Threaded mode shows the GVL ceiling that every
single-process Ruby server hits. The old CPU-tuning recipe stays
retired: its threads 1 half is the default, and its
tokio_threads 1 half still costs on real hardware; see
doc/benchmarks.md.
² Wait-bound throughput is slots ÷ wait, and the default columns bring
8 single-thread workers against the cluster's 24 threads. Kino slots
are threads, not processes—when your app waits a lot, raise workers.
The workers 32 column is that tuning: +15% over the cluster on /io
(+32% via Kino.sleep) while still ahead of it on pure CPU, all in
one small process. The cost is the CPU-light rows (32 ractors
oversubscribe 8 cores); pick the topology your app's wait profile
needs. See
doc/benchmarks.md.
³ With MALLOC_ARENA_MAX=2 (the standard Ruby deployment setting;
Heroku's default). Without it, 24 threads churning 10 KB responses
through one glibc heap balloon to ~647 MB—an arena-fragmentation
footgun, not a leak, and ractor mode sidesteps it. See
doc/benchmarks.md.
A common first idea is to keep your current server and wrap the app in a ractor pool. We measured that too (same box; the analysis is in the doc):
| endpoint | Kino :ractor (8×3) | Puma + ractor wrapper | Falcon + ractor wrapper |
|---|---|---|---|
| /plaintext | 190,206 | 22,055 | 107,203 |
| /cpu (fib) | 68,230 | 16,528 | 50,101 |
| /io (5 ms) | 4,477 | 1,482 | 1,545 |
(The Kino column here runs 8×3—the same 24 slots the wrappers get—so the /io row is comparable; the main table's ractor column runs the 8×1 default.)
Kino speaks HTTP/2 natively, so it skips the usual nginx-termination hop. Same box as the tables above; all over TLS, h2load, 64 in-flight, same app:
| /plaintext | req/s | /upload (64 KB) |
|---|---|---|
| Kino native h2 | 164,044 | 19,226 |
| Kino HTTP/1.1 (same boot) | 80,789 | 17,210 |
| nginx h2 → Kino HTTP/1.1 | 109,219 | 1,217¹ |
| Falcon (native h2) | 55,637 | 18,838 |
Native h2 doubles HTTP/1.1 throughput on the same TLS server
(+79% on cleartext h2c; fewer, larger socket operations, and HPACK
spares re-sending cookies) and is +50% over fronting the same Kino
with nginx—the proxy hop is pure cost. Uploads, h2's classic weak
spot, run at HTTP/1 parity. Full matrix, cleartext h2c lanes, and
methodology: doc/benchmarks.md; reproduce
with bench/h2.sh.
¹ nginx default-config h2 request-body flow control; see the doc.
Rails is not Ractor-shareable today, so Kino serves it in :threaded
fallback—one GVL-bound process. On the same box (examples/rails-hello,
edge Rails, production, 8×5):
| Rails hello-world | req/s | memory (PSS) |
|---|---|---|
| Kino :threaded (one process) | 2,731 | 95 MB |
| Puma cluster (8 workers) | 15,422 | 405 MB |
The honest trade-off: Puma's fork cluster uses all 8 cores, so it serves ~5.6× the throughput—at ~4× the memory. Ractor-mode Rails would close the throughput gap at one-process memory cost; the upstream blockers are tracked in doc/rails-on-ractors.md.
In short: on the tiny synthetic app, ractor mode beats fork-level CPU parallelism (5.7× Kino's own GVL-bound threaded mode, +25% over the cluster) in one process, at about 1/8th of the cluster's memory by PSS (~4× on a real Rails app). Every Kino mode is 1.5-1.7× ahead of the cluster on I/O-light endpoints, and native HTTP/2 adds +79% over HTTP/1.1 on top. The macOS numbers (secondary; everything there hits the loopback ceiling) and the YJIT × Ractors gotcha are in doc/benchmarks.md.
Reproduce: bench/run.sh [seconds] [concurrency] for the main table,
bench/studies.sh for the follow-ups (CPU recipe, topology, scaling,
sharded I/O, logging, memory), bench/h2.sh for the HTTP/2 matrix.
You need Ruby >= 4.0. Add Kino to your application's bundle:
bundle add kino # or: gem install kino (outside a bundle)or put it in the Gemfile yourself:
gem "kino"Then generate a config and serve:
bundle exec kino --init # writes kino.rb; every directive documented in place
bundle exec kino # picks up config.ru + kino.rb, serves on :9292(After a standalone gem install, the kino command works without
bundle exec.)
Prefer your framework's own command? Kino ships a Rack handler, so
rails server -u kino and rackup -s kino boot it too. They read the
same kino.rb (or config/kino.rb), the host's -p/-b flags win over
the file, and rackup -s kino -O Workers=4 -O Mode=threaded reaches the
rest (rackup -s kino --help lists them).
No Rust compiler needed: released versions ship precompiled native gems for Linux (x86_64/aarch64, glibc and musl) and macOS (arm64). On other platforms the gem compiles at install time; that needs a Rust toolchain, plus clang/libclang on Linux.
require "kino"
# Ractor mode needs a Ractor-shareable app: capture nothing, freeze config.
app = Ractor.shareable_proc do |env|
[200, { "content-type" => "text/plain" }, ["Hello from #{Ractor.current}"]]
end
Kino::Server.run(app, port: 9292) # traps INT/TERM; Ctrl-C drains gracefullyOr embedded, with everything spelled out:
server = Kino::Server.new(app,
bind: "127.0.0.1", # or "unix:///run/kino.sock" behind a proxy
port: 9292, # 0 = ephemeral; read back via server.port
workers: Kino.available_parallelism, # ractors (parallelism); the default
threads: 1, # per worker; ractor default 1, threaded default 3
mode: :auto, # :auto | :ractor | :threaded
queue_depth: 1024, # bounded queue; overflow → 503
queue_timeout: 5.0, # seconds before 503 on a full queue
request_timeout: nil, # seconds before a slow response becomes a 504 (nil = off)
max_connections: 8192, # cap concurrent connections; default: most of ulimit -n
max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it
on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500
shutdown_timeout: 30, # drain deadline
control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port
control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics
tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
http2: true, # ALPN h2 on TLS + plaintext h2c; false = HTTP/1 only
)
server.start
server.shutdown # graceful: drain → deadline → abort stragglers:ractor:workersRactors ×threadsThreads each. The app must beRactor.shareable?(frozen middleware,shareable_procendpoints). Forcing:ractorwith an unshareable app raisesKino::UnshareableAppError. A crashed ractor returns 500 to its in-flight requests right away, then respawns.:threaded: the same machinery onworkers × threadsplain Threads. Runs any Rack app, including Rails, today. Parallel for I/O, serialized by the GVL for CPU.:auto(default)::ractorwhen the app is shareable, otherwise a warning and:threaded. One caveat: a class used as a Rack app always counts as "shareable" (classes are), even if calling it touches unshareable state. Force:threadedfor those.
io_shards true (off by default) moves HTTP I/O from Tokio's shared
multi-thread runtime onto current-thread shards: one thread accepts and
hands each connection to the least-loaded shard, which then owns it for
its lifetime—no work-stealing, no cross-thread wakeups on the hot path.
On the 8-core reference box fast handlers gain +1-3% (best with
io_threads 8); the win grows with scheduler contention, so measure on
your own core count. Ruby-bound endpoints are unchanged. Orthogonal to
mode: it reshapes the Rust side only.
# kino.rb
io_shards true
io_threads 8 # optional; default: half the available CPUsOn by default, on both transports, with nothing to configure:
- TLS binds advertise
h2via ALPN, so browsers and h2-capable clients negotiate HTTP/2 and everything else stays on HTTP/1.1. - Plaintext binds serve prior-knowledge h2c: a client that opens
with the HTTP/2 preface (an h2-preferring load balancer, a gRPC-style
backend hop,
curl --http2-prior-knowledge) gets HTTP/2; ordinary clients are HTTP/1.1 exactly as before. Browsers never do h2 on plaintext, so a certificate-less kino behaves identically for them.
The Rack side is spec-complete on h2: SERVER_PROTOCOL is "HTTP/2",
HTTP_HOST/SERVER_NAME/SERVER_PORT come from the :authority
pseudo-header (h2 requests carry no Host header), split cookie headers
are rejoined with "; ", and streamed uploads flow through the same
backpressured body channel as HTTP/1. Streams multiplex into the same
worker slots as keep-alive requests—workers × threads bounds
concurrency either way. (rack.hijack stays out on h2 as it is on h1;
the protocol has no 101 upgrade to hijack anyway.) http2 false pins
the server to HTTP/1 and drops h2 from ALPN.
Settings can live in a Puma-style Ruby DSL file: kino.rb in the
working directory, or config/kino.rb (the Rails layout), is picked up
automatically; -C PATH names any other. Precedence: explicit kwargs
and CLI flags > config file > defaults.
# kino.rb
port 9292
workers 8
threads 1
mode :ractorkino --init # write a fully commented sample kino.rb
kino # config.ru + kino.rb, port 9292
kino --check # explain whether the app can run in :ractor mode
kino -C config/kino.rb -p 3000 -w 4 -m ractor my_app.ruThe generated sample documents every directive, including the Rails settings and the performance notes.
When an app cannot run in :ractor mode, Kino can tell you why, instead
of leaving you with a bare Ractor::IsolationError. The check changes
nothing (it does not freeze your objects) and names each blocker:
captured variables with the place they were defined, instance variables
by path, and the class-level instance variable trap that catches
class-style apps:
$ kino --check
check: app is NOT Ractor-shareable
- app (Proc at app.rb:12)—captures `cache` = {} (Hash) (unshareable)
- app (HelloApp).@instance—class-level ivar holds #<HelloApp…>—classes
pass Ractor.shareable?, but reading this from a worker ractor raises
Ractor::IsolationError on the first request
hints: freeze config at boot; build endpoints with Ractor.shareable_proc;
keep per-worker resources in Ractor.store_if_absent; or run mode :threaded.
Exit status is 0/1, so it works in CI. The programmatic form is
Kino::Check.report(app).
request_timeout: seconds (or request_timeout 30 in kino.rb) limits
how long the app may take to produce a response. Past the deadline the
client gets an immediate 504 while the handler keeps running; its
late response is dropped without harm. Off by default. The handler is
deliberately not killed, because interrupting arbitrary Ruby mid-flight
is unsafe. A stuck handler still occupies its worker slot until it
returns, so set the deadline above your slowest legitimate endpoint and
watch stats[:timeouts].
Timeouts guard your app; the network intake guards itself. New
connections past max_connections (default: most of ulimit -n) wait
in the kernel backlog; request bodies past max_body_size (default
50 MB, nil delegates to a fronting proxy) get a 413; and fixed
deadlines drop slow-header clients (15 s), stalled TLS handshakes
(10 s), and uploads stalled mid-body (30 s). When a worker catches an
app or delivery error,
on_error ->(error, env) { ErrorTracker.capture(error) } is called
after the client got its 500—the only place a tracker sees errors
raised while the response was being written (in :ractor mode, build
the handler with Ractor.shareable_proc).
Kino fires four lifecycle hooks alongside on_error, split by firing context.
Worker-context hooks run inside the worker and are available to all workers:
after_worker_boot { |worker_id| }: runs once before the worker begins serving, with its slot id. In:ractormode it runs inside the worker ractor and must beRactor.shareable_proc.after_request_complete { |env, status| }: fires inside the worker after each successful response. This is the hot path—leave it unset for zero cost. In:ractormode it must beRactor.shareable_proc.
Main-context hooks run on the main thread, outside workers, and are plain procs:
after_boot { }: fires once after the worker pool is up. Wire readiness here—sd_notify, a "server ready" metric, and so on.on_worker_exit { |worker_index, error| }: fires when a worker exits, with its index and the crash cause (or nil on a clean exit).
after_worker_boot's argument is the worker's slot id, while in :ractor mode on_worker_exit's argument identifies the exited ractor (0..workers - 1)—a different number space—so don't correlate boot and exit by that number in :ractor mode.
A raising hook is logged and never kills a worker.
quarantine_timeout: seconds (or quarantine_timeout 60 in kino.rb)
quarantines a dispatch slot whose request has run longer than the deadline
and spawns a replacement worker to restore capacity—distinct from
request_timeout, which gives the client a 504 but leaves the slot
occupied. quarantine_max (default: the worker count in :ractor mode,
workers × threads in :threaded) caps the total number of replacement
events over the process lifetime—past it the monitor stops replacing and
the server runs at reduced capacity.
The wedged worker is never interrupted or force-killed, and its slot stays
quarantined for good. In :threaded mode, if the blocked thread
eventually returns, it keeps serving requests on that same slot—but the
slot itself stays flagged quarantined (busy_ms reported as 0) for the rest
of the process; in :ractor mode the wedged ractor (and its supervisor
thread) leaks until the process exits, since a wedged ractor cannot be
safely interrupted. Monitor quarantine activity via server.stats
(top-level quarantined count and per-slot worker_status[].quarantined
flag), GET /stats (same), and GET /metrics (kino_quarantined_workers
gauge and kino_quarantine_replacements_total counter).
server.stats returns a live snapshot: the configuration plus counters
from the native layer (one relaxed atomic per request, no measurable
cost):
server.stats
# => {mode: :ractor, lanes: false, workers: 8, threads: 1, batch: 1,
# respawns: 0, queued: 0, in_flight: 2, served: 1041, rejected: 0,
# timeouts: 0, worker_status: [...]}
# plus lane_depths: [...] when lane dispatch is onFrom the outside, kill -USR1 <pid> logs the same snapshot as one line
(pair it with pidfile to find the pid):
kino[4213] main: stats mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
For pull-based monitoring, control_bind "127.0.0.1:9293" (or a
unix:// path) serves a read-only control plane from the native
layer on its own thread—it keeps answering even while every Ruby worker
is busy or stuck, and reports draining through a graceful shutdown:
GET /stats—the same snapshot asserver.stats, as JSON (plusstateandversion).GET /metrics—Prometheus text format (kino_requests_served_total,kino_queue_depth,kino_ready, …).
Both /stats and /metrics also break the counters down per dispatch
slot: /stats carries a worker_status array (index, served,
in_flight, busy_ms) and /metrics emits kino_worker_*{worker="N"}
series, one entry per execution slot (workers × threads)—a crashed
worker's slot is never reused, so it stays in the list with its counters
frozen where they stopped, meaning the array (and its worker="N" metric
series) grows by one across every respawn. busy_ms is how long the
slot's current request has been running (0 when idle), so a single slot
climbing while the rest sit at 0 is your stuck worker.
The /stats response and server.stats carry queue_time (count and
summed seconds), and /metrics exposes kino_request_queue_seconds—a
Prometheus histogram of queue-wait time, the worker-saturation signal.
Counts admitted requests only; a 503 after queue wait goes to rejected,
not queue_time.
GET /ready—200when serving,503while booting or draining: wire it to your load balancer or Kubernetes readiness probe.GET /live—200whenever the process is alive: the liveness probe.
control_token "..." puts /stats and /metrics behind
Authorization: Bearer; the probes stay open.
With one log line per request, Kino::Logger sustained 1.7× the
throughput of a shared ::Logger (155k vs 90k req/s on the benchmark
box). There are two native pieces. Both write through a lock-free
channel to a Rust flusher thread, so request threads never take a log
mutex and never make a write syscall:
-
Access log (
log_requests true): two records per request to stdout, including the 503s that never reach your app. The arrival line is queued before the app runs, so a request that hangs shows as an arrow with no answer; the completion line carries the status, the total, and a timing breakdown:rubyis the time the request spent in Ruby (with the GC pause and the objects allocated during it),kinothe server's own overhead,waitthe queue time before a worker took it. Recommended in development; cheap enough for production. On color terminals the completion line is tinted by status class: 2xx green, 3xx yellow, 4xx maroon, 5xx bright red:2026-08-22 14:03:11 +0300 → GET /users?q=1 from 127.0.0.1 2026-08-22 14:03:11 +0300 ← 200 GET /users?q=1 12.4ms (ruby 9.1ms [gc 0.8ms; 1.5k obj]; kino 3.2ms; wait 0.1ms)The GC and allocation figures come from the VM's process-wide counters, so they appear only where one request at a time can own them: in
:threadedmode, or in:ractormode withworkers 1. Parallel ractors would bill each other's work, so there the breakdown is(ruby; kino; wait)alone. -
Kino::Logger: a::Loggerover the same async sink, for your app's own logging (Kino::Logger.new("log/production.log"), or no argument for stdout). The raw IO-like device isKino::Logger::Device, for integrations that want bytes without::Loggerformatting. The device is frozen and Ractor-shareable, so one device serves every worker.
Kino::Logger in a Rails app: it is a real ::Logger subclass, so
it fits anywhere Rails expects a logger:
# config/environments/production.rb, simplest forms:
config.logger = Kino::Logger.new # stdout
config.logger = Kino::Logger.new("log/production.log") # file
# both file and stdout:
config.logger = ActiveSupport::BroadcastLogger.new(
Kino::Logger.new("log/production.log"), Kino::Logger.new
)
# tagged logging wraps it like any ::Logger:
config.logger = ActiveSupport::TaggedLogging.new(Kino::Logger.new)From a plain Rack app, give middleware the logger, or hand
Rack::CommonLogger the raw device (it just calls write):
# config.ru
use Rack::CommonLogger, Kino::Logger::Device.new # access-style app log
run MyApp(If you only want request lines, prefer Kino's own log_requests true.
It is free for your Ruby threads, and it also sees the 503s that never
reach Rack.)
Graceful shutdown drains both logs fully. A hard crash can lose the tail of the buffer, and when you log faster than the disk can take (over 100k lines/s), the sink drops lines instead of blocking request threads. These trade-offs are measured in doc/benchmarks.md.
Server lines. Everything Kino says about itself (draining, a crash
and its respawn, a hook that raised, quarantine, the stats line,
rack.errors) reads kino[<pid>] <source>: message, the source being
the worker that spoke, worker-3 (or worker-3/thread-2 in a
multi-threaded ractor), or main. On color terminals the label is dim
for notes, yellow for warnings, red for errors; the message stays plain.
A failed request gets a report instead of a bare backtrace: the request
line, the error, and where it raised in your code, then the trace with
your frames first, relative to the working directory, and the rest
folded:
kino[4213] worker-2: 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
app.rb:12:in 'explode'
/usr/lib/ruby/gems/4.0.0/gems/rack-3.2.7/lib/rack/builder.rb:...
… 38 more
Hooks can log through the same channel with Kino::Log.info, .warn,
and .error; it is safe inside worker ractors.
Kino.sleep(seconds) is a high-resolution sleep on the OS clock with
the GVL released. MRI's own sleep wakes up late inside non-main
ractors (details and numbers in doc/benchmarks.md).
Use Kino.sleep for explicit timer waits in handlers. Ordinary blocking
I/O does not need it.
The spec suite runs every test app under Rack::Lint over real sockets:
streaming request bodies (forward-only rack.input), enumerable and
callable (full-duplex stream) response bodies, lowercase and multi-value
headers, HEAD/204 semantics. Full hijack is left out on purpose; it is
optional in Rack 3.
Rails (edge) runs on Kino today in :threaded mode (rails server -u kino, or the kino CLI); see examples/rails-hello. Ractor-mode Rails
is blocked upstream. The exact
blockers, the Ruby::Box findings, and what would unlock it are written
up in doc/rails-on-ractors.md. The example
ships a probe script that re-tests against whatever Rails you bundle.
bin/setup
bundle exec rake # compile, Rust tests, specs, RBS, lint
RB_SYS_CARGO_PROFILE=dev bundle exec rake compile # fast dev rebuildsThanks to Mat Sadler for magnus.
For ractors, thanks to Koichi Sasada, John Hawthorn, Jean Boussier, Luke Gruber, and other Ruby core contributors.
For the Rust network stack, thanks to Sean McArthur for hyper, and to Carl Lerche, Alice Ryhl, and the other Tokio maintainers for the runtime underneath it. Thanks to Joshua Barretto for flume—its channels carry every request between the network side and the workers.
Claude Code (Fable 5, Opus 4.8).
Bug reports and pull requests are welcome on GitHub at https://github.com/yaroslav/kino.
The gem is available as open source under the terms of the MIT License.