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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Kino
6
+ # Server settings with Puma-style precedence:
7
+ # explicit Server.new kwargs > config file DSL > defaults.
8
+ class Configuration
9
+ # Every setting and its default; the full reference lives in the
10
+ # generated sample config (`kino --init`).
11
+ DEFAULTS = {
12
+ bind: "127.0.0.1",
13
+ port: 0,
14
+ workers: nil, # resolved to Etc.nprocessors in #to_h
15
+ threads: 3,
16
+ mode: :auto,
17
+ queue_depth: 1024,
18
+ queue_timeout: 1.0,
19
+ request_timeout: nil,
20
+ batch: 1,
21
+ lanes: false,
22
+ log_requests: false,
23
+ shutdown_timeout: 30,
24
+ tokio_threads: nil,
25
+ tls: nil,
26
+ environment: nil,
27
+ pidfile: nil,
28
+ rackup: nil
29
+ }.freeze
30
+
31
+ # The known setting names.
32
+ SETTINGS = DEFAULTS.keys.freeze
33
+
34
+ # Source template for {.sample}.
35
+ SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
36
+
37
+ # The fully-commented sample config (see `kino --init`).
38
+ # @return [String]
39
+ def self.sample
40
+ File.read(SAMPLE_TEMPLATE)
41
+ end
42
+
43
+ # Write the sample config to +path+. Refuses to clobber an existing
44
+ # file unless force: true.
45
+ #
46
+ # @param path [String]
47
+ # @param force [Boolean] overwrite an existing file
48
+ # @return [String] the path written
49
+ # @raise [Kino::Error] when the file exists and force is false
50
+ def self.write_sample(path, force: false)
51
+ if File.exist?(path) && !force
52
+ raise Error, "#{path} already exists (use force: true to overwrite)"
53
+ end
54
+
55
+ File.write(path, sample)
56
+ path
57
+ end
58
+
59
+ def initialize
60
+ @values = {}
61
+ end
62
+
63
+ # @param key [Symbol] a key from DEFAULTS
64
+ # @return [Object] the explicit value, or the default
65
+ def [](key)
66
+ @values.fetch(key) { DEFAULTS.fetch(key) }
67
+ end
68
+
69
+ # @param key [Symbol] a key from DEFAULTS
70
+ # @param value [Object]
71
+ # @raise [ArgumentError] for unknown settings
72
+ def set(key, value)
73
+ raise ArgumentError, "unknown setting #{key.inspect}" unless DEFAULTS.key?(key)
74
+
75
+ @values[key] = value
76
+ end
77
+
78
+ # @return [Boolean] whether the key was explicitly set
79
+ def set?(key)
80
+ @values.key?(key)
81
+ end
82
+
83
+ # Load a config file (Ruby DSL) into this configuration.
84
+ # @param path [String]
85
+ # @return [self]
86
+ # @raise [Kino::Error] when the file does not exist
87
+ def load_file(path)
88
+ raise Error, "config file not found: #{path}" unless File.exist?(path)
89
+
90
+ DSL.new(self).instance_eval(File.read(path), path, 1)
91
+ self
92
+ end
93
+
94
+ # Explicit kwargs win over everything already set.
95
+ # @param options [Hash{Symbol => Object}]
96
+ # @return [self]
97
+ def merge!(options)
98
+ options.each { |key, value| set(key, value) }
99
+ self
100
+ end
101
+
102
+ # @return [Hash{Symbol => Object}] every setting, defaults filled in
103
+ def to_h
104
+ SETTINGS.to_h { |key| [key, self[key]] }.tap do |h|
105
+ h[:workers] ||= Etc.nprocessors
106
+ end
107
+ end
108
+
109
+ # The settings Server.new accepts: everything except the keys only the
110
+ # CLI consumes (rackup file selection, RACK_ENV).
111
+ # @return [Hash{Symbol => Object}]
112
+ def server_options
113
+ to_h.except(:rackup, :environment)
114
+ end
115
+
116
+ # The config-file DSL, deliberately Puma-shaped:
117
+ #
118
+ # # kino.rb
119
+ # bind "0.0.0.0"
120
+ # port 9292
121
+ # workers 8 # ractors (or thread groups in :threaded mode)
122
+ # threads 3 # threads per worker
123
+ # mode :ractor # :auto | :ractor | :threaded
124
+ # queue_depth 2048
125
+ # queue_timeout 0.5
126
+ # shutdown_timeout 15
127
+ # tokio_threads 4
128
+ # tls cert: "cert.pem", key: "key.pem"
129
+ #
130
+ # Every directive is documented in the generated sample config
131
+ # (`kino --init`); the one-liners here only state the value each
132
+ # directive expects.
133
+ class DSL
134
+ def initialize(config)
135
+ @config = config
136
+ end
137
+
138
+ # Address to listen on ("0.0.0.0" accepts non-local connections).
139
+ def bind(host) = @config.set(:bind, host)
140
+
141
+ # Port to listen on; 0 picks an ephemeral port.
142
+ def port(port) = @config.set(:port, Integer(port))
143
+
144
+ # Worker count (ractors in :ractor mode); defaults to CPU cores.
145
+ def workers(count) = @config.set(:workers, Integer(count))
146
+
147
+ # Threads per worker (I/O concurrency inside one ractor).
148
+ def threads(count) = @config.set(:threads, Integer(count))
149
+
150
+ # Dispatch mode: :auto, :ractor, or :threaded.
151
+ def mode(mode) = @config.set(:mode, mode.to_sym)
152
+
153
+ # Bounded request-queue depth; overflow earns clients a 503.
154
+ def queue_depth(depth) = @config.set(:queue_depth, Integer(depth))
155
+
156
+ # Seconds a request may wait for queue space before the 503.
157
+ def queue_timeout(seconds) = @config.set(:queue_timeout, Float(seconds))
158
+
159
+ # Seconds the app gets before the client receives a 504; nil = off.
160
+ def request_timeout(seconds) = @config.set(:request_timeout, seconds && Float(seconds))
161
+
162
+ # Requests a worker may grab per queue visit (default 1).
163
+ def batch(count) = @config.set(:batch, Integer(count))
164
+
165
+ # EXPERIMENTAL per-worker lane dispatch.
166
+ def lanes(enabled) = @config.set(:lanes, !!enabled)
167
+
168
+ # Native access log: one status-colored line per request to stdout.
169
+ def log_requests(enabled) = @config.set(:log_requests, !!enabled)
170
+
171
+ # Graceful-shutdown drain deadline in seconds.
172
+ def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
173
+
174
+ # Threads for the tokio (Rust I/O) runtime; default: one per core.
175
+ def tokio_threads(count) = @config.set(:tokio_threads, Integer(count))
176
+
177
+ # TLS termination; file paths or inline PEM strings.
178
+ def tls(cert:, key:) = @config.set(:tls, {cert: cert, key: key})
179
+
180
+ # Sets RACK_ENV (unless already set) before the CLI loads the app.
181
+ def environment(env) = @config.set(:environment, env.to_s)
182
+
183
+ # Write the master PID here on start.
184
+ def pidfile(path) = @config.set(:pidfile, path.to_s)
185
+
186
+ # Rackup file the `kino` CLI loads (positional argument wins).
187
+ def rackup(path) = @config.set(:rackup, path.to_s)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # rack.errors: stateless writer into the native logger. Frozen singleton,
6
+ # which also makes it Ractor-shareable; one instance serves all workers.
7
+ class ErrorsStream
8
+ def puts(message)
9
+ Native.log_error(message.to_s)
10
+ nil
11
+ end
12
+
13
+ def write(message)
14
+ message = message.to_s
15
+ Native.log_error(message)
16
+ message.bytesize
17
+ end
18
+
19
+ def flush
20
+ self
21
+ end
22
+
23
+ INSTANCE = new.freeze
24
+ end
25
+ end
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
@@ -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