Skip to content

nsgi-org/ruby

Repository files navigation

nsgi-ruby

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.

Architecture

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
Loading

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; no String is allocated unless the app asks for one. Every buffer is invalidated before nsgi_handle returns, and a stashed NSGI::Request raises NSGI::Error instead 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-in Fiber::Scheduler; assign NSGI.scheduler from 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_PENDING bounds the queue with 503s.
  • App-owned response memory: each response is serialized into a single malloc block ([NsgiHeader array][header bytes][body bytes]); nsgi_free_response recovers 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::Buffer bodies 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 by rb_protect; Rust panics by catch_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.

Application contract

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}!"]
end

NSGI::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.

Building and running

cargo build                                            # builds target/debug/libnsgi_ruby.{dylib,so}
NSGI_APP=host/fixture.rb NSGI_LIB=lib cargo run -p nsgi-mock-host

The 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

Status

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).

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors