kino 0.5.0-aarch64-linux → 0.7.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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # The :threaded-mode worker pool: `workers` groups of `threads` plain
6
+ # Threads, each thread on its own dispatch slot. A group is one ractor's
7
+ # worth of capacity, so `workers` and `max_workers` mean the same thing
8
+ # in both modes. Behind PoolScaler here (`grow`, `retire`, `groups`,
9
+ # `active_count`), the quarantine monitor's replacer (`replace`), and
10
+ # the join/kill sweeps Server#shutdown runs.
11
+ class ThreadedPool
12
+ def initialize(server_id, app, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
13
+ @server_id = server_id
14
+ @app = app
15
+ @threads = threads
16
+ @batch = batch
17
+ @hooks = hooks
18
+ @on_worker_exit = on_worker_exit
19
+ @lock = Mutex.new
20
+ # index => {slots:, threads:}; the groups asked to leave; quarantine
21
+ # replacements (one thread each, standing in for a wedged slot:
22
+ # neither counted nor retired, so the wedged group keeps counting as
23
+ # the capacity it still is). Retired groups hand their slots back to
24
+ # the bank.
25
+ @groups = {}
26
+ @slot_to_group = {}
27
+ @retiring = {}
28
+ @bank = SlotBank.new(server_id)
29
+ @replacements = {}
30
+ @wedged = {}
31
+ @next_index = -1
32
+ end
33
+
34
+ def start(workers)
35
+ workers.times { spawn_group }
36
+ report_active
37
+ self
38
+ end
39
+
40
+ # Groups that are staying: quarantine replacements aside, and minus
41
+ # those already told to leave. A retiring group leaves the count at
42
+ # once, not when its last thread exits, so the scaler never sees a
43
+ # stale surplus and retires past its floor.
44
+ def active_count
45
+ @lock.synchronize do
46
+ @groups.count { |index, _| !@replacements.key?(index) && !@retiring.key?(index) }
47
+ end
48
+ end
49
+
50
+ # Group index => slot ids for every group the scaler may retire: not
51
+ # already leaving, not wedged, not a quarantine replacement.
52
+ def groups
53
+ reap
54
+ @lock.synchronize do
55
+ @groups
56
+ .reject { |index, _| @retiring.key?(index) || @wedged.key?(index) || @replacements.key?(index) }
57
+ .transform_values { |group| group[:slots].dup }
58
+ end
59
+ end
60
+
61
+ # Add one group; returns its index.
62
+ def grow
63
+ reap
64
+ index = spawn_group
65
+ report_active
66
+ index
67
+ end
68
+
69
+ # Send a group home: its slots stop receiving work now, each thread
70
+ # finishes what it holds and leaves at its next idle tick, and the
71
+ # slots come back to the free list once every thread has exited.
72
+ def retire(index)
73
+ slots = @lock.synchronize do
74
+ next nil unless @groups.key?(index) && !@retiring.key?(index)
75
+
76
+ @retiring[index] = true
77
+ @groups[index][:slots]
78
+ end
79
+ return false unless slots
80
+
81
+ slots.each { |id| Native.retire_slot(@server_id, id) }
82
+ true
83
+ end
84
+
85
+ # The quarantine replacer: spawn a replacement thread on a fresh slot
86
+ # FIRST (may raise ThreadError), then quarantine the wedged slot.
87
+ def replace(worker_id)
88
+ spawn_group(slots: 1, replacement: true)
89
+ Native.quarantine_slot(@server_id, worker_id)
90
+ @lock.synchronize do
91
+ wedged = @slot_to_group[worker_id]
92
+ @wedged[wedged] = true if wedged
93
+ end
94
+ true
95
+ end
96
+
97
+ # Join every thread up to the (numeric) deadline.
98
+ def shutdown(timeout)
99
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
100
+ all_threads.each do |thread|
101
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ thread.join([remaining, 0.01].max)
103
+ end
104
+ end
105
+
106
+ def done?
107
+ all_threads.none?(&:alive?)
108
+ end
109
+
110
+ # Block until every thread exits on its own (drain elsewhere).
111
+ def join
112
+ all_threads.each(&:join)
113
+ end
114
+
115
+ def kill_stragglers
116
+ all_threads.each { |thread| thread.kill if thread.alive? }
117
+ end
118
+
119
+ private
120
+
121
+ def all_threads
122
+ @lock.synchronize { @groups.values.flat_map { |group| group[:threads] } }
123
+ end
124
+
125
+ # The group is in the table before its first thread starts, so a
126
+ # ThreadError partway through leaves nothing untracked for shutdown.
127
+ def spawn_group(slots: @threads, replacement: false)
128
+ group = {slots: [], threads: []}
129
+ index = @lock.synchronize do
130
+ @next_index += 1
131
+ @groups[@next_index] = group
132
+ @replacements[@next_index] = true if replacement
133
+ @next_index
134
+ end
135
+ slots.times do
136
+ id = @bank.claim
137
+ @lock.synchronize do
138
+ group[:slots] << id
139
+ @slot_to_group[id] = index
140
+ end
141
+ thread = spawn_thread(id)
142
+ @lock.synchronize { group[:threads] << thread }
143
+ end
144
+ index
145
+ end
146
+
147
+ def spawn_thread(worker_id)
148
+ Thread.new do
149
+ # Named so log lines from inside say which worker spoke.
150
+ Thread.current.name = "worker-#{worker_id}"
151
+ error = nil
152
+ begin
153
+ Worker.run(@server_id, worker_id, @app, @batch, @hooks)
154
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
155
+ error = e
156
+ raise
157
+ ensure
158
+ HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Retiring groups whose threads have all exited give their slots back
164
+ # and leave the table. Runs before every pool decision, so the count
165
+ # the control plane sees never lags by more than a scaler tick.
166
+ def reap
167
+ freed = @lock.synchronize do
168
+ done = @retiring.keys.select { |index| @groups[index][:threads].none?(&:alive?) }
169
+ done.flat_map do |index|
170
+ group = @groups.delete(index)
171
+ @retiring.delete(index)
172
+ group[:slots].each { |id| @slot_to_group.delete(id) }
173
+ group[:slots]
174
+ end
175
+ end
176
+ return if freed.empty?
177
+
178
+ @bank.release(freed)
179
+ report_active
180
+ end
181
+
182
+ def report_active
183
+ Native.set_active_workers(@server_id, active_count)
184
+ end
185
+ end
186
+ end
data/lib/kino/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Kino
4
4
  # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/lib/kino/worker.rb CHANGED
@@ -20,12 +20,16 @@ module Kino
20
20
 
21
21
  def run(server_id, worker_id, app, batch_size = 1, hooks = nil)
22
22
  fire_after_worker_boot(hooks, worker_id)
23
+ # One resolution of (server, worker) into a native handle; every take
24
+ # after this skips the registry lookup. nil = server already gone.
25
+ worker = Native.worker(server_id, worker_id)
26
+ return unless worker
23
27
  if batch_size <= 1
24
- env = Native.take_one(server_id, worker_id)
25
- env = handle_one(env, server_id, worker_id, app, hooks) while env
28
+ env = worker.take_one
29
+ env = handle_one(env, worker, app, hooks) while env
26
30
  else
27
- batch = Native.take_batch(server_id, worker_id, batch_size)
28
- batch = process(batch, server_id, worker_id, app, batch_size, hooks) while batch
31
+ batch = worker.take_batch(batch_size)
32
+ batch = process(batch, worker, app, batch_size, hooks) while batch
29
33
  end
30
34
  end
31
35
 
@@ -35,22 +39,21 @@ module Kino
35
39
  NOT_FUSED = Object.new.freeze
36
40
 
37
41
  # Handle one request; returns the next env (fused take) or nil.
38
- def handle_one(env, server_id, worker_id, app, hooks)
42
+ def handle_one(env, worker, app, hooks)
39
43
  result = serve(env, app, hooks) do |request, status, headers, chunks|
40
- request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
44
+ request.respond_and_take_one(worker, status, headers, chunks)
41
45
  end
42
- result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
46
+ result.equal?(NOT_FUSED) ? worker.take_one : result
43
47
  end
44
48
 
45
49
  # Handle every env in the batch; returns the next batch (the last
46
50
  # simple response rides the fused respond_and_take) or nil on shutdown.
47
- def process(batch, server_id, worker_id, app, batch_size, hooks)
51
+ def process(batch, worker, app, batch_size, hooks)
48
52
  last = batch.size - 1
49
53
  batch.each_with_index do |env, index|
50
54
  result = serve(env, app, hooks) do |request, status, headers, chunks|
51
55
  if index == last
52
- request.respond_and_take(server_id, worker_id, batch_size,
53
- status, headers, chunks)
56
+ request.respond_and_take(worker, batch_size, status, headers, chunks)
54
57
  else
55
58
  request.send_simple(status, headers, chunks)
56
59
  NOT_FUSED
@@ -58,7 +61,7 @@ module Kino
58
61
  end
59
62
  return result if index == last && !result.equal?(NOT_FUSED)
60
63
  end
61
- Native.take_batch(server_id, worker_id, batch_size)
64
+ worker.take_batch(batch_size)
62
65
  end
63
66
 
64
67
  # Run one request through the app. Complete bodies are yielded so the
data/lib/kino.rb CHANGED
@@ -57,8 +57,12 @@ require_relative "kino/configuration"
57
57
  require_relative "kino/hook_fire"
58
58
  require_relative "kino/worker_hooks"
59
59
  require_relative "kino/worker"
60
+ require_relative "kino/slot_bank"
60
61
  require_relative "kino/ractor_supervisor"
62
+ require_relative "kino/threaded_pool"
63
+ require_relative "kino/monitor"
61
64
  require_relative "kino/quarantine_monitor"
65
+ require_relative "kino/pool_scaler"
62
66
  require_relative "kino/server"
63
67
 
64
68
  # Hand the frozen shareable singletons to the native layer: it sets them
@@ -9,13 +9,13 @@ module Rackup
9
9
  module Kino
10
10
  # Host option name => Kino setting plus the coercion it needs: rackup
11
11
  # hands `-O NAME=VALUE` values (and its own -p) over as strings.
12
- OPTION_MAP = {
12
+ OPTION_MAP = Ractor.make_shareable({
13
13
  Host: [:bind, ->(value) { value.to_s }],
14
14
  Port: [:port, ->(value) { Integer(value) }],
15
15
  Workers: [:workers, ->(value) { Integer(value) }],
16
16
  Threads: [:threads, ->(value) { Integer(value) }],
17
17
  Mode: [:mode, ->(value) { value.to_sym }]
18
- }.freeze
18
+ })
19
19
  private_constant :OPTION_MAP
20
20
 
21
21
  # Boot a server for `app` and block until it shuts down, the way the
@@ -27,7 +27,7 @@ module Rackup
27
27
  # want a handle on it
28
28
  # @return [::Kino::Server] the stopped server, after shutdown
29
29
  def self.run(app, **options)
30
- require "kino"
30
+ require "kino" # audition:disable runtime-require
31
31
  server = ::Kino::Server.new(app, **server_options(options))
32
32
  yield server if block_given?
33
33
  server.run
@@ -58,7 +58,7 @@ module Rackup
58
58
  # @param options [Hash{Symbol => Object}]
59
59
  # @return [Hash{Symbol => Object}]
60
60
  def self.server_options(options)
61
- require "kino"
61
+ require "kino" # audition:disable runtime-require
62
62
  options = options.dup
63
63
  host_defaults = {}
64
64
  if (typed = options.delete(:user_supplied_options))
data/sig/kino.rbs CHANGED
@@ -110,6 +110,8 @@ module Kino
110
110
  def bind: (String host) -> untyped
111
111
  def port: (int port) -> untyped
112
112
  def workers: (int count) -> untyped
113
+ def max_workers: (int count) -> untyped
114
+ def scale_down_after: (Numeric seconds) -> untyped
113
115
  def threads: (int count) -> untyped
114
116
  def mode: (Symbol | String mode) -> untyped
115
117
  def queue_depth: (int depth) -> untyped
@@ -130,6 +132,7 @@ module Kino
130
132
  def io_threads: (int? count) -> untyped
131
133
  def tokio_threads: (int count) -> untyped
132
134
  def tls: (cert: String, key: String) -> untyped
135
+ def http2: (?boolish enabled) -> untyped
133
136
  def environment: (String | Symbol env) -> untyped
134
137
  def pidfile: (String path) -> untyped
135
138
  def control_bind: (String addr) -> String
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Yaroslav Markin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-29 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: httpx
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rackup
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -166,12 +180,16 @@ files:
166
180
  - lib/kino/kino.so
167
181
  - lib/kino/log.rb
168
182
  - lib/kino/logger.rb
183
+ - lib/kino/monitor.rb
169
184
  - lib/kino/null_input.rb
185
+ - lib/kino/pool_scaler.rb
170
186
  - lib/kino/quarantine_monitor.rb
171
187
  - lib/kino/ractor_supervisor.rb
172
188
  - lib/kino/server.rb
189
+ - lib/kino/slot_bank.rb
173
190
  - lib/kino/stream.rb
174
191
  - lib/kino/templates/kino.rb.tt
192
+ - lib/kino/threaded_pool.rb
175
193
  - lib/kino/version.rb
176
194
  - lib/kino/worker.rb
177
195
  - lib/kino/worker_hooks.rb