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.
- checksums.yaml +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/Cargo.lock +993 -0
- data/Cargo.toml +15 -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/ext/kino/Cargo.toml +49 -0
- data/ext/kino/build.rs +5 -0
- data/ext/kino/extconf.rb +6 -0
- data/ext/kino/src/env_strings.rs +318 -0
- data/ext/kino/src/gvl.rs +103 -0
- data/ext/kino/src/lib.rs +90 -0
- data/ext/kino/src/logsink.rs +155 -0
- data/ext/kino/src/queue.rs +207 -0
- data/ext/kino/src/registry.rs +268 -0
- data/ext/kino/src/request.rs +432 -0
- data/ext/kino/src/response.rs +214 -0
- data/ext/kino/src/server.rs +621 -0
- data/ext/kino/src/style.rs +87 -0
- data/ext/kino/src/test_support.rs +82 -0
- data/ext/kino/src/timer.rs +57 -0
- data/ext/kino/src/tls.rs +96 -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/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 +219 -0
|
@@ -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
|
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
|