kino 0.1.0-x86_64-linux

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.
data/lib/kino/input.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # rack.input: forward-only reader over the native streaming body.
6
+ # Rack 3 dropped the rewindability requirement, which is exactly what makes
7
+ # streaming legal here. All output is binary, per spec.
8
+ class Input
9
+ CHUNK_SIZE = 65_536
10
+
11
+ def initialize(request)
12
+ @request = request
13
+ @buffer = (+"").force_encoding(Encoding::BINARY)
14
+ @eof = false
15
+ end
16
+
17
+ # IO#read semantics, as Rack::Lint enforces them:
18
+ # read -> String ("" at EOF)
19
+ # read(n) -> String of up to n bytes, nil at EOF
20
+ # read(n, buf) -> fills buf, returns it (or nil at EOF)
21
+ def read(length = nil, buffer = nil)
22
+ out = buffer ? buffer.clear.force_encoding(Encoding::BINARY) : (+"").force_encoding(Encoding::BINARY)
23
+
24
+ if length.nil?
25
+ fill_all
26
+ out << @buffer
27
+ @buffer.clear
28
+ return out
29
+ end
30
+
31
+ fill(length)
32
+ return nil if @buffer.empty? && length.positive?
33
+
34
+ out << @buffer.slice!(0, length)
35
+ out
36
+ end
37
+
38
+ def gets
39
+ fill_until_newline
40
+ return nil if @buffer.empty?
41
+
42
+ index = @buffer.index("\n")
43
+ index ? @buffer.slice!(0..index) : @buffer.slice!(0, @buffer.bytesize)
44
+ end
45
+
46
+ def each
47
+ while (chunk = read(CHUNK_SIZE))
48
+ yield chunk
49
+ end
50
+ end
51
+
52
+ def close
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ def pull
59
+ return if @eof
60
+
61
+ chunk = @request.read_body(CHUNK_SIZE)
62
+ chunk ? @buffer << chunk : @eof = true
63
+ end
64
+
65
+ def fill(length)
66
+ pull while @buffer.bytesize < length && !@eof
67
+ end
68
+
69
+ def fill_all
70
+ pull until @eof
71
+ end
72
+
73
+ def fill_until_newline
74
+ pull until @buffer.index("\n") || @eof
75
+ end
76
+ end
77
+ end
data/lib/kino/kino.so ADDED
Binary file
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Kino
6
+ # A ::Logger writing through the native async sink: formatted lines go
7
+ # onto a lock-free channel and a Rust flusher thread batches them into
8
+ # the output: no per-line mutex (which serializes every worker thread)
9
+ # and no write syscall on request threads.
10
+ #
11
+ # # e.g. Rails, config/environments/production.rb:
12
+ # config.logger = Kino::Logger.new # stdout
13
+ # config.logger = Kino::Logger.new("log/production.log")
14
+ #
15
+ # Durability: a graceful shutdown drains everything; a hard crash can
16
+ # lose the tail of the buffer (the standard async-logging trade-off).
17
+ class Logger < ::Logger
18
+ # @param path [String, nil] log file path (created/appended), or nil
19
+ # for stdout
20
+ # @param options [Hash] passed through to ::Logger#initialize
21
+ # (progname:, level:, formatter:, ...)
22
+ def initialize(path = nil, **options)
23
+ super(Device.new(path), **options)
24
+ end
25
+
26
+ # The raw IO-like device for integrations that want bytes without
27
+ # ::Logger's formatting: Rack::CommonLogger, ActiveSupport::Logger.new,
28
+ # a BroadcastLogger arm, ... Frozen and holding only an Integer id, so
29
+ # it is Ractor-shareable; one device can serve every worker.
30
+ class Device
31
+ # @param path [String, nil] a file (created/appended) or nil for stdout
32
+ def initialize(path = nil)
33
+ @id = Native.log_device_open(path&.to_s)
34
+ freeze
35
+ end
36
+
37
+ # Queue one formatted line on the async sink; never blocks.
38
+ # @param message [String]
39
+ # @return [void]
40
+ def write(message)
41
+ Native.log_device_write(@id, message.to_s)
42
+ end
43
+
44
+ # Close the device: the flusher drains its queue and exits. Writes
45
+ # after close are ignored.
46
+ # @return [void]
47
+ def close
48
+ Native.log_device_close(@id)
49
+ end
50
+
51
+ # ::Logger probes these on its device.
52
+ def reopen(*) = self
53
+ alias_method :<<, :write
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # rack.input for requests that can carry no body (most GETs). A single
6
+ # frozen, Ractor-shareable instance is set by the native layer directly
7
+ # into the env, so bodyless requests allocate no input object at all.
8
+ class NullInput
9
+ EMPTY = String.new("", encoding: Encoding::BINARY).freeze
10
+
11
+ # IO#read semantics at permanent EOF: read and read(0) return "",
12
+ # read(n > 0) returns nil. Mirrors what Input does on an empty body,
13
+ # since the native layer swaps the two classes invisibly per request.
14
+ def read(length = nil, buffer = nil)
15
+ empty = length.nil? || length.zero?
16
+ if buffer
17
+ buffer.clear.force_encoding(Encoding::BINARY)
18
+ return empty ? buffer : nil
19
+ end
20
+ empty ? EMPTY : nil
21
+ end
22
+
23
+ def gets
24
+ nil
25
+ end
26
+
27
+ def each
28
+ # no chunks, ever
29
+ end
30
+
31
+ def close
32
+ nil
33
+ end
34
+
35
+ INSTANCE = new.freeze
36
+ end
37
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Spawns worker ractors and keeps them alive. One supervisor thread per
6
+ # ractor: it blocks in Ractor#value, and a crash (anything that kills the
7
+ # ractor, Exception from app code included) wakes it to 500 the in-flight
8
+ # requests and respawn. Clean exits (queue drained) end supervision.
9
+ class RactorSupervisor
10
+ attr_reader :respawns
11
+
12
+ def initialize(server_id, app, workers:, threads:, batch: 1)
13
+ @server_id = server_id
14
+ @app = app
15
+ @workers = workers
16
+ @threads = threads
17
+ @batch = batch
18
+ @respawns = 0
19
+ @draining = false
20
+ @lock = Mutex.new
21
+ @supervisor_threads = []
22
+ end
23
+
24
+ def start
25
+ @supervisor_threads = Array.new(@workers) { |index| supervise(index) }
26
+ self
27
+ end
28
+
29
+ # Flag the drain and join supervisors up to the (numeric) deadline;
30
+ # callers wanting an unbounded wait use #join instead.
31
+ def shutdown(timeout)
32
+ @lock.synchronize { @draining = true }
33
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
34
+ @supervisor_threads.each do |thread|
35
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ thread.join([remaining, 0.01].max)
37
+ end
38
+ end
39
+
40
+ def done?
41
+ @supervisor_threads.none?(&:alive?)
42
+ end
43
+
44
+ # Block until the workers exit on their own (drain elsewhere): join
45
+ # without flipping the draining flag.
46
+ def join
47
+ @supervisor_threads.each(&:join)
48
+ end
49
+
50
+ private
51
+
52
+ def supervise(index)
53
+ Thread.new do
54
+ crashes = 0
55
+ loop do
56
+ ractor, worker_ids = spawn_worker
57
+ begin
58
+ ractor.value # blocks until the ractor terminates
59
+ break # clean exit: queue closed, workers drained
60
+ rescue Ractor::Error => e
61
+ # The ractor died mid-flight. Anything it was serving will never
62
+ # be answered by Ruby: 500 those clients NOW (not when GC gets
63
+ # around to dropping the dead heap), then decide on respawn.
64
+ worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
65
+ break if draining?
66
+
67
+ crashes += 1
68
+ @lock.synchronize { @respawns += 1 }
69
+ cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
70
+ Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
71
+ # Policy (crash recovery): unlimited respawn
72
+ # keeps the server up under rare crashes but turns a
73
+ # crash-on-every-request bug into a busy loop. A circuit breaker
74
+ # (give up / cool down after N crashes in T seconds) trades
75
+ # availability for fail-fast. Current policy: respawn forever.
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ # Fresh ractor + fresh native slots. Slots are never reused across
82
+ # respawns: stale interrupt kicks and dead weak refs go down with the
83
+ # old slot.
84
+ def spawn_worker
85
+ worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
86
+ ractor = Ractor.new(@server_id, worker_ids, @app, @batch) do |server_id, ids, app, batch|
87
+ ids.map do |id|
88
+ Thread.new do
89
+ # Crashes surface via Ractor#value in the supervisor; don't also
90
+ # spray the backtrace to stderr from inside the dying ractor.
91
+ Thread.current.report_on_exception = false
92
+ Kino::Worker.run(server_id, id, app, batch)
93
+ end
94
+ end.each(&:join)
95
+ end
96
+ [ractor, worker_ids]
97
+ end
98
+
99
+ def draining?
100
+ @lock.synchronize { @draining }
101
+ end
102
+ end
103
+ end
@@ -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