kino 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # Public server API. All network I/O lives in Rust (tokio + hyper); this
5
+ # class only manages lifecycle and the Ruby worker pool.
6
+ #
7
+ # Topology is Puma-style two-level: `workers` ractors × `threads` threads
8
+ # per ractor in :ractor mode; the same total capacity flattened onto plain
9
+ # Threads in :threaded mode (which runs ANY Rack app, Rails included).
10
+ class Server
11
+ # @return [Integer, nil] the bound port (nil until #start; the actual
12
+ # port when configured with port 0)
13
+ attr_reader :port
14
+
15
+ # @return [Symbol] the resolved dispatch mode, :ractor or :threaded
16
+ attr_reader :mode
17
+
18
+ # @return [String] the bind address
19
+ attr_reader :bind
20
+
21
+ # @return [Boolean] whether TLS termination is configured
22
+ def tls?
23
+ !@tls.nil?
24
+ end
25
+
26
+ # Settings precedence: explicit kwargs > config_file DSL > defaults.
27
+ #
28
+ # @param app [#call] a Rack 3 application
29
+ # @param config_file [String, nil] path to a kino.rb config file
30
+ # @param options [Hash] any {Kino::Configuration} setting, e.g. port:,
31
+ # workers:, threads:, mode:, request_timeout:, tls: cert/key Hash
32
+ # @example
33
+ # Kino::Server.new(app, config_file: "kino.rb", port: 3000)
34
+ def initialize(app, config_file: nil, **options)
35
+ config = Configuration.new
36
+ config.load_file(config_file) if config_file
37
+ config.merge!(options)
38
+ settings = config.to_h
39
+
40
+ @app = app
41
+ @bind = settings[:bind]
42
+ @requested_port = settings[:port]
43
+ @workers = Integer(settings[:workers])
44
+ @threads = Integer(settings[:threads])
45
+ @mode = resolve_mode(settings[:mode])
46
+ @queue_depth = Integer(settings[:queue_depth])
47
+ @queue_timeout_ms = (Float(settings[:queue_timeout]) * 1000).round
48
+ @request_timeout_ms = settings[:request_timeout] ? (Float(settings[:request_timeout]) * 1000).round : 0
49
+ @batch = [Integer(settings[:batch]), 1].max
50
+ @lanes = !!settings[:lanes]
51
+ @log_requests = !!settings[:log_requests]
52
+ @shutdown_timeout = settings[:shutdown_timeout]
53
+ @tokio_threads = settings[:tokio_threads]
54
+ @tls = validate_tls(settings[:tls])
55
+ @pidfile = settings[:pidfile]
56
+ @worker_threads = []
57
+ @supervisor = nil
58
+ @started = false
59
+ end
60
+
61
+ # Bind, boot the native front-end, and spawn the worker pool.
62
+ # @return [self]
63
+ # @raise [Kino::Error] when already started
64
+ # @raise [Kino::UnshareableAppError] in forced :ractor mode with an
65
+ # unshareable app
66
+ def start
67
+ raise Error, "server already started" if @started
68
+
69
+ @id, @port = Native.server_start(
70
+ bind: @bind, port: @requested_port,
71
+ queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
72
+ request_timeout_ms: @request_timeout_ms,
73
+ tokio_threads: @tokio_threads,
74
+ tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
75
+ lanes: @lanes, log_requests: @log_requests
76
+ )
77
+ File.write(@pidfile, "#{Process.pid}\n") if @pidfile
78
+ if @mode == :ractor
79
+ @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads, batch: @batch).start
80
+ else
81
+ @worker_threads = (@workers * @threads).times.map do
82
+ worker_id = Native.register_worker(@id)
83
+ Thread.new { Worker.run(@id, worker_id, @app, @batch) }
84
+ end
85
+ end
86
+ @started = true
87
+ self
88
+ end
89
+
90
+ # Graceful shutdown: stop accepting, drain in-flight work up to the
91
+ # deadline, then escalate: abort remaining clients (500), interrupt
92
+ # blocked workers, kill stragglers; and tear down the runtime. Always
93
+ # returns by ~deadline + a small epsilon; idempotent.
94
+ #
95
+ # @param timeout [Numeric, nil] drain deadline in seconds (default:
96
+ # the configured shutdown_timeout)
97
+ # @return [nil]
98
+ def shutdown(timeout: nil)
99
+ return unless @started
100
+
101
+ deadline = monotonic_now + (timeout || @shutdown_timeout)
102
+ Native.stop_accepting(@id)
103
+
104
+ # Drain: wait for queued + in-flight to reach zero, bounded by deadline.
105
+ until monotonic_now >= deadline
106
+ queued, in_flight = Native.queue_stats(@id)
107
+ break if queued.zero? && in_flight.zero?
108
+
109
+ sleep 0.01
110
+ end
111
+
112
+ # Idle workers see the closed queue and exit their loops.
113
+ Native.close_queue(@id)
114
+ join_workers(deadline)
115
+
116
+ unless workers_done?
117
+ # Past the deadline with stuck handlers: free the clients first,
118
+ # then try to unblock and reap the workers.
119
+ Native.abort_all_inflight(@id)
120
+ Native.interrupt_all_workers(@id)
121
+ join_workers(monotonic_now + 0.2)
122
+ kill_stragglers
123
+ end
124
+
125
+ Native.shutdown_runtime(@id, 1_000)
126
+ @worker_threads.clear
127
+ @started = false
128
+ File.delete(@pidfile) if @pidfile && File.exist?(@pidfile)
129
+ nil
130
+ end
131
+
132
+ # Block until every worker has exited (i.e. until shutdown).
133
+ # @return [void]
134
+ def wait
135
+ @supervisor ? @supervisor.join : @worker_threads.each(&:join)
136
+ end
137
+
138
+ # Production entry point: start, print the banner, trap INT/TERM for
139
+ # graceful shutdown (second signal force-exits), block until done.
140
+ # The `kino` CLI funnels into this too (CLI#serve).
141
+ #
142
+ # @param app [#call] a Rack 3 application
143
+ # @param opts [Hash] see #initialize
144
+ # @return [Kino::Server] the (stopped) server, after shutdown
145
+ def self.run(app, **opts)
146
+ server = new(app, **opts)
147
+ CLI.opening_credits
148
+ server.start
149
+ CLI.action!(server)
150
+ CLI.fin_at_exit
151
+ trap_signals(server)
152
+ server.wait
153
+ server
154
+ end
155
+
156
+ # Signal handling shared by Server.run and the kino CLI: INT/TERM drain
157
+ # gracefully (a second signal force-exits), USR1 prints a stats line.
158
+ #
159
+ # @param server [Kino::Server]
160
+ # @return [void]
161
+ def self.trap_signals(server)
162
+ # kill -USR1 <pid> prints a one-line stats snapshot (find the pid in
163
+ # the pidfile when configured).
164
+ trap("USR1") do
165
+ Thread.new { $stdout.puts Kino::CLI.stats_line(server.stats) }
166
+ end
167
+ signaled = false
168
+ %w[INT TERM].each do |signal|
169
+ trap(signal) do
170
+ Process.exit!(1) if signaled
171
+ signaled = true
172
+ $stderr.write("Kino: draining (signal again to force exit)\n")
173
+ # Trap context forbids mutexes; do the real work on a thread.
174
+ Thread.new { server.shutdown }
175
+ end
176
+ end
177
+ end
178
+
179
+ # Live snapshot. Counters come from the native layer (one relaxed
180
+ # atomic per request); config echo makes the line self-describing.
181
+ #
182
+ # @return [Hash{Symbol => Object}] mode, lanes, workers, threads,
183
+ # batch, respawns; plus queued, in_flight, served, rejected,
184
+ # timeouts (and lane_depths in lanes mode) once started
185
+ def stats
186
+ base = {
187
+ mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
188
+ batch: @batch, respawns: @supervisor ? @supervisor.respawns : 0
189
+ }
190
+ return base unless @started
191
+
192
+ queued, in_flight, served, rejected, timeouts, lane_depths = Native.server_stats(@id)
193
+ base.merge!(queued:, in_flight:, served:, rejected:, timeouts:)
194
+ base[:lane_depths] = lane_depths if lane_depths
195
+ base
196
+ end
197
+
198
+ private
199
+
200
+ def validate_tls(tls)
201
+ return nil if tls.nil?
202
+ unless tls.is_a?(Hash) && tls[:cert] && tls[:key]
203
+ raise ArgumentError, "tls: expects { cert:, key: } (file paths or inline PEM)"
204
+ end
205
+
206
+ {cert: String(tls[:cert]), key: String(tls[:key])}
207
+ end
208
+
209
+ def monotonic_now
210
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ end
212
+
213
+ def join_workers(deadline)
214
+ if @supervisor
215
+ @supervisor.shutdown([deadline - monotonic_now, 0].max)
216
+ else
217
+ @worker_threads.each do |thread|
218
+ thread.join([deadline - monotonic_now, 0.01].max)
219
+ end
220
+ end
221
+ end
222
+
223
+ def workers_done?
224
+ if @supervisor
225
+ @supervisor.done?
226
+ else
227
+ @worker_threads.none?(&:alive?)
228
+ end
229
+ end
230
+
231
+ def kill_stragglers
232
+ if @supervisor
233
+ # Ractors cannot be force-killed; their clients were already freed
234
+ # by abort_all_inflight. The stuck ractor leaks until process exit.
235
+ Native.log_error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
236
+ else
237
+ @worker_threads.each { |thread| thread.kill if thread.alive? }
238
+ end
239
+ end
240
+
241
+ # Policy (mode resolution): when is an app safe for ractor
242
+ # dispatch, and how loudly do we fall back? Current policy: trust
243
+ # Ractor.shareable? on :auto with a stderr warning on fallback; forcing
244
+ # :ractor with an unshareable app is an error, and we never
245
+ # make_shareable the user's app behind their back (deep-freezing
246
+ # someone's object graph is not a server's call to make).
247
+ def resolve_mode(requested)
248
+ case requested
249
+ when :threaded
250
+ :threaded
251
+ when :ractor
252
+ unless Ractor.shareable?(@app)
253
+ raise UnshareableAppError,
254
+ "mode: :ractor requires a Ractor-shareable app (frozen middleware, " \
255
+ "Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
256
+ "or mode: :threaded"
257
+ end
258
+ :ractor
259
+ when :auto
260
+ if Ractor.shareable?(@app)
261
+ :ractor
262
+ else
263
+ warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
264
+ :threaded
265
+ end
266
+ else
267
+ raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # The stream handed to Rack 3 streaming bodies (`body.call(stream)`).
6
+ # Rack 3 requires it to be full-duplex: writes go to the native response
7
+ # channel (a slow client blocks the writer with the GVL released), reads
8
+ # pull the remaining request body through the request's own rack.input.
9
+ class Stream
10
+ def initialize(request, input)
11
+ @request = request
12
+ @input = input
13
+ @read_closed = false
14
+ @write_closed = false
15
+ end
16
+
17
+ def read(length = nil, buffer = nil)
18
+ raise IOError, "stream is closed for reading" if @read_closed
19
+
20
+ @input.read(length, buffer)
21
+ end
22
+
23
+ def write(chunk)
24
+ raise IOError, "stream is closed for writing" if @write_closed
25
+
26
+ @request.write_chunk(chunk)
27
+ chunk.bytesize
28
+ end
29
+
30
+ def <<(chunk)
31
+ write(chunk)
32
+ self
33
+ end
34
+
35
+ def flush
36
+ self
37
+ end
38
+
39
+ def close_read
40
+ @read_closed = true
41
+ nil
42
+ end
43
+
44
+ def close_write
45
+ return if @write_closed
46
+
47
+ @write_closed = true
48
+ @request.finish
49
+ nil
50
+ end
51
+
52
+ def close
53
+ close_read
54
+ close_write
55
+ end
56
+
57
+ def closed?
58
+ @read_closed && @write_closed
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kino configuration.
4
+ # Generated by `kino --init`.
5
+ #
6
+ # Every setting below is shown with its default value, commented out:
7
+ # the file is a valid no-op until you uncomment something. Precedence:
8
+ # explicit Server.new kwargs / CLI flags > this file > built-in defaults.
9
+
10
+ ## Network
11
+
12
+ # Address to listen on. Use "0.0.0.0" to accept non-local connections.
13
+ # bind "127.0.0.1"
14
+
15
+ # Port to listen on. 0 picks an ephemeral port (readable via server.port).
16
+ # The `kino` CLI defaults this to 9292 when nothing else sets it.
17
+ # port 9292
18
+
19
+ # TLS termination (rustls, in Rust; never blocks a Ruby thread).
20
+ # Values are file paths or inline PEM strings. ALPN is http/1.1.
21
+ # tls cert: "config/certs/server.pem", key: "config/certs/server.key"
22
+
23
+ ## Topology
24
+
25
+ # Puma-style two-level topology: `workers` × `threads`.
26
+ #
27
+ # In :ractor mode, `workers` is the number of worker Ractors: true
28
+ # multi-core parallelism for Ruby CPU work, one per core is a good start.
29
+ # In :threaded mode the same total (workers × threads) runs as plain
30
+ # Threads on the main ractor.
31
+
32
+ # Defaults to the number of CPU cores (Etc.nprocessors).
33
+ # workers 8
34
+
35
+ # Threads per worker. Threads inside one ractor share its lock, so they
36
+ # only add concurrency where handlers block on I/O (database calls, HTTP).
37
+ # CPU-bound apps gain nothing past 1 (and pay a lock-handoff tax: threads 1
38
+ # measured +17% on fast handlers). I/O-heavy apps want more SLOTS overall -
39
+ # in :ractor mode prefer raising `workers` over `threads` (slots are cheap,
40
+ # no fork memory): 32 workers x 1 thread beat 8x3 by +35% on waits.
41
+ # threads 3
42
+
43
+ ## Dispatch mode
44
+ #
45
+ # :auto: :ractor when the app is Ractor-shareable, else :threaded
46
+ # (with a warning). Note: a Class used as a Rack app always
47
+ # counts as "shareable" even if calling it touches unshareable
48
+ # state; force :threaded for those.
49
+ # :ractor: require a Ractor-shareable app; raises
50
+ # Kino::UnshareableAppError otherwise. The app must capture
51
+ # nothing mutable: frozen middleware, Ractor.shareable_proc
52
+ # endpoints.
53
+ # :threaded: run ANY Rack app (Rails included) on a classic thread pool.
54
+ # mode :auto
55
+
56
+ ## Backpressure
57
+
58
+ # Bounded request queue between the Rust front-end and Ruby workers.
59
+ # When it stays full past queue_timeout, clients get an immediate 503
60
+ # instead of waiting forever.
61
+ # queue_depth 1024
62
+
63
+ # Seconds a request may wait for queue space before the 503.
64
+ # queue_timeout 1.0
65
+
66
+ # Seconds the app gets to produce a response before the client receives a
67
+ # 504 instead. Off by default (nil = wait forever). The handler is NOT
68
+ # killed - its late response is dropped and its slot stays busy until it
69
+ # returns, so size this above your slowest legitimate endpoint.
70
+ # request_timeout 30
71
+
72
+ # Requests a worker may grab per queue visit. Values above 1 squeeze more
73
+ # throughput out of uniformly fast handlers, but add head-of-line blocking
74
+ # behind slow ones and stretch the effective queue depth - leave at 1
75
+ # unless your handlers are all sub-millisecond.
76
+ # batch 1
77
+
78
+ # EXPERIMENTAL lane dispatch: per-worker queues with awake-preferring
79
+ # assignment and work stealing. Cuts per-request wakeups for uniformly
80
+ # fast handlers; semantics under overload are slightly different (per-lane
81
+ # caps with brief dispatcher retries instead of one global queue).
82
+ # lanes false
83
+
84
+ # Native access log: one line per request to stdout, written by a
85
+ # Rust-side flusher thread - request threads never block on the log.
86
+ #
87
+ # On color terminals lines are tinted by status class (2xx green,
88
+ # 3xx yellow, 4xx maroon, 5xx bright red). This is the SERVER's view - it
89
+ # includes the 503 rejections your app never sees - and it interleaves
90
+ # cleanly with your app's own log (e.g. Rails') on stdout. See also
91
+ # Kino::Logger for routing the app log through the same async sink.
92
+ #
93
+ # Try enabling it in the development environment.
94
+ # log_requests false
95
+
96
+ ## Lifecycle
97
+
98
+ # Graceful-shutdown drain deadline in seconds: in-flight requests get this
99
+ # long to finish; past it, their clients receive 500s and workers are
100
+ # reaped. A second INT/TERM force-exits immediately.
101
+ # shutdown_timeout 30
102
+
103
+ # Write the master PID here on start; removed on graceful shutdown.
104
+ # pidfile "tmp/pids/kino.pid"
105
+
106
+ ## Runtime
107
+
108
+ # Threads for the tokio (Rust I/O) runtime. Default (nil) lets tokio use
109
+ # one per core: right for I/O-heavy apps. For CPU-heavy apps this is a
110
+ # real lever: `tokio_threads 1` + `threads 1` measured +26% on a pure-CPU
111
+ # benchmark (every spare thread is Ruby work you didn't run).
112
+ # tokio_threads 4
113
+
114
+ ## App
115
+
116
+ # Rackup file the `kino` CLI loads (positional CLI argument wins).
117
+ # rackup "config.ru"
118
+
119
+ # Sets RACK_ENV (unless already set) before the app is loaded by the CLI.
120
+ # environment "production"
121
+
122
+ ## Rails
123
+ #
124
+ # Rails runs on Kino TODAY in :threaded mode; uncomment for a Rails app:
125
+ #
126
+ # mode :threaded
127
+ # environment "production"
128
+ # threads 5 # match your database pool size
129
+ #
130
+ # Recommended Rails-side settings to pair with Kino:
131
+ # - config.eager_load = true and no code reloading (production defaults):
132
+ # Kino's workers serve concurrently; lazy class loading under
133
+ # concurrency is slow and, in ractor mode, unsafe.
134
+ # - Database pool >= workers × threads (config/database.yml `pool:`).
135
+ # - Rails.logger goes to stdout/stderr or a thread-safe device.
136
+ #
137
+ # Rails main is being ractorized, but
138
+ # Rails.application still captures unshareable state at boot; known
139
+ # blockers are documented in Kino's README. Track rails/rails main; when
140
+ # Ractor.make_shareable(Rails.application) succeeds, `mode :ractor` here
141
+ # is all you'll need to change.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # The request loop. Identical for threaded and ractor modes.
6
+ #
7
+ # The default (batch 1) hot path allocates one Hash per request: the env
8
+ # arrives with the native request handle embedded under "kino.request",
9
+ # and the common complete-body response rides the fused
10
+ # respond_and_take_one call: ~one FFI crossing per request, no arrays.
11
+ #
12
+ # batch > 1 trades fairness for throughput: a worker grabs up to that
13
+ # many already-queued requests per crossing, adding head-of-line blocking
14
+ # behind slow handlers and stretching effective queue depth.
15
+ module Worker
16
+ RACK_INPUT = "rack.input"
17
+ KINO_REQUEST = "kino.request"
18
+
19
+ module_function
20
+
21
+ def run(server_id, worker_id, app, batch_size = 1)
22
+ if batch_size <= 1
23
+ env = Native.take_one(server_id, worker_id)
24
+ env = handle_one(env, server_id, worker_id, app) while env
25
+ else
26
+ batch = Native.take_batch(server_id, worker_id, batch_size)
27
+ batch = process(batch, server_id, worker_id, app, batch_size) while batch
28
+ end
29
+ end
30
+
31
+ # serve() returns this when the response did NOT ride a fused
32
+ # respond-and-take (streaming body or app error) and the caller must
33
+ # take the next request itself. Frozen: worker ractors read it.
34
+ NOT_FUSED = Object.new.freeze
35
+
36
+ # Handle one request; returns the next env (fused take) or nil.
37
+ def handle_one(env, server_id, worker_id, app)
38
+ result = serve(env, app) do |request, status, headers, chunks|
39
+ request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
40
+ end
41
+ result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
42
+ end
43
+
44
+ # Handle every env in the batch; returns the next batch (the last
45
+ # simple response rides the fused respond_and_take) or nil on shutdown.
46
+ def process(batch, server_id, worker_id, app, batch_size)
47
+ last = batch.size - 1
48
+ batch.each_with_index do |env, index|
49
+ result = serve(env, app) do |request, status, headers, chunks|
50
+ if index == last
51
+ request.respond_and_take(server_id, worker_id, batch_size,
52
+ status, headers, chunks)
53
+ else
54
+ request.send_simple(status, headers, chunks)
55
+ NOT_FUSED
56
+ end
57
+ end
58
+ return result if index == last && !result.equal?(NOT_FUSED)
59
+ end
60
+ Native.take_batch(server_id, worker_id, batch_size)
61
+ end
62
+
63
+ # Run one request through the app. Complete bodies are yielded so the
64
+ # caller picks plain vs fused delivery (the block's return value passes
65
+ # through after the body is closed); streaming bodies are delivered
66
+ # here and return NOT_FUSED. App errors must never kill the worker;
67
+ # hard crashes (Exception) are the supervisor's job; and `abort` does
68
+ # the right thing whether or not the response head already went out.
69
+ def serve(env, app)
70
+ request = env[KINO_REQUEST]
71
+ env[RACK_INPUT] ||= Input.new(request)
72
+ status, headers, body = app.call(env)
73
+
74
+ if body.respond_to?(:to_ary)
75
+ result = yield(request, status.to_i, headers, join_chunks(body.to_ary))
76
+ body.close if body.respond_to?(:close)
77
+ result
78
+ else
79
+ deliver_streaming(request, status.to_i, headers, body, env[RACK_INPUT])
80
+ NOT_FUSED
81
+ end
82
+ rescue => e
83
+ Native.log_error("#{e.class}: #{e.message}")
84
+ request.abort
85
+ NOT_FUSED
86
+ end
87
+
88
+ def deliver_streaming(request, status, headers, body, input)
89
+ request.send_headers(status, headers)
90
+ if body.respond_to?(:call) && !body.respond_to?(:each)
91
+ # Rack 3 streaming body: the app drives a full-duplex stream whose
92
+ # read side is the request's existing rack.input (a fresh Input
93
+ # here would strand anything the app already buffered from it).
94
+ stream = Stream.new(request, input)
95
+ begin
96
+ body.call(stream)
97
+ ensure
98
+ stream.close
99
+ end
100
+ else
101
+ # Enumerable body: chunked transfer unless the app set content-length.
102
+ begin
103
+ body.each { |chunk| request.write_chunk(chunk) }
104
+ ensure
105
+ request.finish
106
+ body.close if body.respond_to?(:close)
107
+ end
108
+ end
109
+ end
110
+
111
+ def join_chunks(chunks)
112
+ # Single-chunk bodies (the common case) skip the join copy entirely:
113
+ # the native layer reads raw bytes, so encoding doesn't matter.
114
+ return chunks.first || "" if chunks.size <= 1
115
+
116
+ joined = (+"").force_encoding(Encoding::BINARY)
117
+ chunks.each { |chunk| joined << chunk.b }
118
+ joined
119
+ end
120
+
121
+ private_class_method :handle_one, :process, :serve, :deliver_streaming,
122
+ :join_chunks
123
+ end
124
+ end
data/lib/kino.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kino/version"
4
+ require "kino/kino"
5
+
6
+ # A high-performance Ractor web server for Ruby 4.0+: Rack 3-based, with
7
+ # a Rust (tokio + hyper) front-end and Ractor-parallel Ruby workers, plus
8
+ # a threaded fallback mode for apps (such as Rails) that are not
9
+ # Ractor-shareable.
10
+ #
11
+ # The public API is {Kino::Server}, {Kino::Configuration}, {Kino::Check},
12
+ # {Kino::Logger}, and {Kino.sleep}; the `kino` executable is implemented
13
+ # by {Kino::CLI}.
14
+ module Kino
15
+ # Base class for all errors raised by Kino.
16
+ class Error < StandardError; end
17
+
18
+ # Raised when mode: :ractor is forced but the app is not Ractor-shareable.
19
+ class UnshareableAppError < Error; end
20
+
21
+ # High-resolution sleep that bypasses the VM timer (whose wakeups inside
22
+ # non-main ractors are coarse: ~+2.5ms on Linux). Sleeps on the OS clock
23
+ # with the GVL released; chunked so Thread#kill and shutdown interrupts
24
+ # are honored between chunks.
25
+ #
26
+ # @param seconds [Numeric] how long to sleep; must be non-negative
27
+ # @return [nil]
28
+ # @raise [ArgumentError] for negative or non-numeric durations
29
+ def self.sleep(seconds)
30
+ remaining = Float(seconds)
31
+ raise ArgumentError, "sleep duration must be non-negative" if remaining.negative?
32
+
33
+ remaining = Native.sleep_chunk(remaining) while remaining.positive?
34
+ nil
35
+ end
36
+ end
37
+
38
+ require_relative "kino/cli"
39
+ require_relative "kino/logger"
40
+ require_relative "kino/check"
41
+ require_relative "kino/input"
42
+ require_relative "kino/null_input"
43
+ require_relative "kino/errors_stream"
44
+ require_relative "kino/stream"
45
+ require_relative "kino/configuration"
46
+ require_relative "kino/worker"
47
+ require_relative "kino/ractor_supervisor"
48
+ require_relative "kino/server"
49
+
50
+ # Hand the frozen shareable singletons to the native layer: it sets them
51
+ # straight into each request's env, so the worker loop allocates neither
52
+ # an errors stream nor (for bodyless requests) an input object.
53
+ Kino::Native.register_defaults(Kino::ErrorsStream::INSTANCE, Kino::NullInput::INSTANCE)