An NSGI-compliant Ruby bridge: a high-performance alternative to Rack's server interface.
Rack pays for every request at the C/Ruby boundary: methods, paths, and headers are copied into freshly allocated String/Hash objects before the application sees a byte. NSGI (Native Web Server Gateway Interface) instead represents all request/response fields as raw (*const u8, usize) pairs over a zero-dependency C ABI, which lets Ruby 3+ read request data zero-copy through IO::Buffer and serve concurrent synchronous nsgi_handle calls from a single embedded VM.
Like PSGI (and deliberately unlike Rack), the protocol and this implementation stay loosely coupled: any host that speaks the NSGI C ABI can load this bridge, and any Ruby framework can target the app contract without depending on this gem's internals.
This gem builds a cdylib that exports the two NSGI entry points, nsgi_handle and nsgi_free_response, and hosts an embedded CRuby VM behind them. The host may call nsgi_handle concurrently from OS threads CRuby knows nothing about, so requests are handed off to a dedicated VM thread running a Fiber::Scheduler reactor:
sequenceDiagram
participant Host as Host thread (1..N)
participant Queue as Job queue + wake pipe
participant VM as Ruby VM thread (reactor)
Host->>Host: nsgi_handle(req)
Host->>Queue: push job {req, condvar, result slot}
Queue--)VM: wake byte
Note over Host: block on condvar
VM->>Queue: pop job
VM->>VM: Fiber.schedule per request
Note over VM: app waits (IO, sleep) => fiber yields,<br/>other requests keep running
VM->>VM: lazy zero-copy IO::Buffer views
VM->>VM: serialize response, invalidate buffers
VM--)Host: signal condvar
Host->>Host: return NsgiResponse
Key properties:
- Zero-copy, lazy reads: request fields are exposed to the app as read-only external
IO::Buffers over host-borrowed memory, created only when an accessor is called; noStringis allocated unless the app asks for one. Every buffer is invalidated beforensgi_handlereturns, and a stashedNSGI::RequestraisesNSGI::Errorinstead of minting new views, so an app can never read freed host memory. - Fiber-multiplexed concurrency: each request runs in its own Fiber under
NSGI::Scheduler(a minimal built-inFiber::Scheduler; assignNSGI.schedulerfrom the app file to plug in another). When one request sleeps or waits on IO, others keep running: the mock host's 32 concurrent 50ms sleeps finish in ~220ms instead of 1.6s.NSGI_MAX_PENDINGbounds the queue with 503s. - App-owned response memory: each response is serialized into a single
mallocblock ([NsgiHeader array][header bytes][body bytes]);nsgi_free_responserecovers the block base from the struct itself and frees it from any thread with zero VM interaction. Ruby string pointers never cross the ABI (GC compaction may move them);IO::Bufferbodies are serialized straight from their backing memory. - Nothing unwinds into the host: Ruby exceptions become 500 responses inside
NSGI.__dispatch; bridge-level raises are caught byrb_protect; Rust panics bycatch_unwind. The last resort is a static, never-freed 500 response, and even a crashed reactor degrades to serving 500s instead of hanging host threads.
The normative contract lives in SPEC.md; the short version: an NSGI app file's last expression evaluates to an object responding to call (the PSGI convention). The app receives an NSGI::Request and returns a [status, headers, body] triplet:
# app.rb
->(request) do
[200, { "content-type" => "text/plain" }, "Hello, #{request.path.get_string}!"]
endNSGI::Request exposes method, path, query, headers, and body as lazily created IO::Buffer views (query/body are nil when absent; headers is an array of [name, value] buffer pairs). The buffers are request-scoped: copy what you need (e.g. #get_string) before returning. The body may be returned as a String or an IO::Buffer.
cargo build # builds target/debug/libnsgi_ruby.{dylib,so}
NSGI_APP=host/fixture.rb NSGI_LIB=lib cargo run -p nsgi-mock-hostThe mock host dlopens the cdylib exactly as a real NSGI host would and exercises it from concurrent OS threads, covering the happy path, the exception→500 path, and buffer-lifetime enforcement.
Environment variables read by the bridge at boot:
| Variable | Meaning |
|---|---|
NSGI_APP |
Path to the application file (required) |
NSGI_LIB |
Path to this gem's lib/ directory (defaults to the build-time location) |
NSGI_MAX_PENDING |
Queue bound; further requests get a 503 (default: unbounded) |
NSGI_DEBUG |
When set, prints exception backtraces that would otherwise become silent 500s |
Working end-to-end: embedded VM boot, foreign-thread handoff, lazy zero-copy request views with invalidation, fiber-per-request concurrency with a pluggable scheduler, backpressure, arena responses incl. IO::Buffer bodies (leak-checked), exception/panic containment, CI with valgrind/leaks. See the issue tracker for what's next; IO::Buffer upstream gaps are tracked under the upstream/ruby label (e.g. #8).
MIT