kino 0.1.0-aarch64-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.
- checksums.yaml +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/doc/README.md +6 -0
- data/doc/architecture.md +161 -0
- data/doc/benchmarks.md +321 -0
- data/doc/rails-on-ractors.md +50 -0
- data/doc/why-kino.md +91 -0
- data/exe/kino +26 -0
- data/lib/kino/check.rb +199 -0
- data/lib/kino/cli.rb +254 -0
- data/lib/kino/configuration.rb +190 -0
- data/lib/kino/errors_stream.rb +25 -0
- data/lib/kino/input.rb +77 -0
- data/lib/kino/kino.so +0 -0
- data/lib/kino/logger.rb +56 -0
- data/lib/kino/null_input.rb +37 -0
- data/lib/kino/ractor_supervisor.rb +103 -0
- data/lib/kino/server.rb +271 -0
- data/lib/kino/stream.rb +61 -0
- data/lib/kino/templates/kino.rb.tt +141 -0
- data/lib/kino/version.rb +6 -0
- data/lib/kino/worker.rb +124 -0
- data/lib/kino.rb +53 -0
- data/sig/kino.rbs +178 -0
- metadata +193 -0
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
|
data/lib/kino/logger.rb
ADDED
|
@@ -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
|
data/lib/kino/server.rb
ADDED
|
@@ -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
|
data/lib/kino/stream.rb
ADDED
|
@@ -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
|