kino 0.5.0 → 0.7.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.
@@ -92,3 +92,47 @@ pub fn close(ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
92
92
  chan.tx.lock().take();
93
93
  Ok(())
94
94
  }
95
+
96
+ /// Build the cache-backed slice of a Rack env directly, the way `build_env`
97
+ /// does for a real request: REMOTE_ADDR from `ip`, SERVER_NAME/SERVER_PORT
98
+ /// from `host` (the Host-header path) or, when `authority` is given, from
99
+ /// it (the :authority path, which also sets HTTP_HOST), plus one interned
100
+ /// header value. The rooting stress spec calls this from several ractors
101
+ /// at once with more distinct inputs than the LRU caches hold.
102
+ pub fn env_probe(
103
+ ruby: &magnus::Ruby,
104
+ host: String,
105
+ authority: Option<String>,
106
+ ip: String,
107
+ user_agent: String,
108
+ ) -> Result<magnus::RHash, magnus::Error> {
109
+ use crate::env_strings;
110
+ use crate::request::split_host_port;
111
+
112
+ let ip: std::net::IpAddr = ip.parse().map_err(|e| {
113
+ magnus::Error::new(ruby.exception_arg_error(), format!("bad ip {ip:?}: {e}"))
114
+ })?;
115
+ let env = ruby.hash_new();
116
+ env_strings::set_addr_env(ruby, env, ip)?;
117
+ match &authority {
118
+ Some(authority) => {
119
+ env_strings::set_authority_env(ruby, env, authority, || split_host_port(authority, 80))?
120
+ }
121
+ None => {
122
+ env_strings::set_host_env(ruby, env, host.as_bytes(), || split_host_port(&host, 80))?
123
+ }
124
+ }
125
+ let strings = env_strings::get();
126
+ let key = strings
127
+ .header_names
128
+ .get("user-agent")
129
+ .expect("user-agent is a common header");
130
+ env_strings::set_value_env(
131
+ ruby,
132
+ env,
133
+ ruby.get_inner(*key),
134
+ "user-agent",
135
+ user_agent.as_bytes(),
136
+ )?;
137
+ Ok(env)
138
+ }
data/ext/kino/src/tls.rs CHANGED
@@ -17,7 +17,13 @@ fn pem_bytes(input: &str) -> Result<Vec<u8>, String> {
17
17
  }
18
18
  }
19
19
 
20
- pub fn build_acceptor(cert: &str, key: &str) -> Result<TlsAcceptor, String> {
20
+ pub fn build_acceptor(cert: &str, key: &str, http2: bool) -> Result<TlsAcceptor, String> {
21
+ Ok(TlsAcceptor::from(Arc::new(server_config(
22
+ cert, key, http2,
23
+ )?)))
24
+ }
25
+
26
+ fn server_config(cert: &str, key: &str, http2: bool) -> Result<ServerConfig, String> {
21
27
  let cert_pem = pem_bytes(cert)?;
22
28
  let key_pem = pem_bytes(key)?;
23
29
 
@@ -36,14 +42,19 @@ pub fn build_acceptor(cert: &str, key: &str) -> Result<TlsAcceptor, String> {
36
42
  .with_no_client_auth()
37
43
  .with_single_cert(certs, key)
38
44
  .map_err(|e| format!("TLS config rejected: {e}"))?;
39
- config.alpn_protocols = vec![b"http/1.1".to_vec()];
40
-
41
- Ok(TlsAcceptor::from(Arc::new(config)))
45
+ // Preference order matters: h2 first, so an HTTP/2-capable client
46
+ // negotiates it; a client offering only http/1.1 still matches.
47
+ config.alpn_protocols = if http2 {
48
+ vec![b"h2".to_vec(), b"http/1.1".to_vec()]
49
+ } else {
50
+ vec![b"http/1.1".to_vec()]
51
+ };
52
+ Ok(config)
42
53
  }
43
54
 
44
55
  #[cfg(test)]
45
56
  mod tests {
46
- use super::build_acceptor;
57
+ use super::{build_acceptor, server_config};
47
58
 
48
59
  const CERT: &str = "-----BEGIN CERTIFICATE-----
49
60
  MIIBfjCCASWgAwIBAgIUTs9+cVJSzJjy4TSi9YLEd+i4KiIwCgYIKoZIzj0EAwIw
@@ -65,12 +76,12 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
65
76
 
66
77
  #[test]
67
78
  fn inline_pem_builds_an_acceptor() {
68
- assert!(build_acceptor(CERT, KEY).is_ok());
79
+ assert!(build_acceptor(CERT, KEY, true).is_ok());
69
80
  }
70
81
 
71
82
  #[test]
72
83
  fn missing_file_paths_error_without_panicking() {
73
- let err = build_acceptor("/nonexistent/cert.pem", "/nonexistent/key.pem")
84
+ let err = build_acceptor("/nonexistent/cert.pem", "/nonexistent/key.pem", true)
74
85
  .err()
75
86
  .expect("missing files");
76
87
  assert!(err.contains("cannot read"));
@@ -78,13 +89,15 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
78
89
 
79
90
  #[test]
80
91
  fn pem_without_certificates_is_rejected() {
81
- let err = build_acceptor(KEY, KEY).err().expect("a key is not a cert");
92
+ let err = build_acceptor(KEY, KEY, true)
93
+ .err()
94
+ .expect("a key is not a cert");
82
95
  assert!(err.contains("no certificates found"));
83
96
  }
84
97
 
85
98
  #[test]
86
99
  fn pem_without_a_key_is_rejected() {
87
- let err = build_acceptor(CERT, CERT)
100
+ let err = build_acceptor(CERT, CERT, true)
88
101
  .err()
89
102
  .expect("a cert is not a key");
90
103
  assert!(err.contains("no private key found"));
@@ -95,9 +108,106 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
95
108
  let err = build_acceptor(
96
109
  CERT,
97
110
  "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----",
111
+ true,
98
112
  )
99
113
  .err()
100
114
  .expect("garbage key");
101
115
  assert!(!err.is_empty());
102
116
  }
117
+
118
+ #[test]
119
+ fn alpn_advertises_h2_first_when_http2_is_on() {
120
+ let config = server_config(CERT, KEY, true).expect("valid PEM");
121
+ assert_eq!(
122
+ config.alpn_protocols,
123
+ vec![b"h2".to_vec(), b"http/1.1".to_vec()]
124
+ );
125
+ }
126
+
127
+ #[test]
128
+ fn alpn_advertises_only_http11_when_http2_is_off() {
129
+ let config = server_config(CERT, KEY, false).expect("valid PEM");
130
+ assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]);
131
+ }
132
+
133
+ /// A real rustls handshake over an in-memory pipe: an h2-capable
134
+ /// client must land on "h2", proving the advertisement is not just a
135
+ /// config field but what the wire negotiates. The client skips
136
+ /// certificate verification (the fixture cert is CA-flagged, which
137
+ /// webpki refuses as an end-entity cert): ALPN is under test here,
138
+ /// not the trust chain.
139
+ #[tokio::test]
140
+ async fn handshake_negotiates_h2_with_a_capable_client() {
141
+ use std::sync::Arc;
142
+
143
+ use tokio_rustls::rustls::client::danger::{
144
+ HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
145
+ };
146
+ use tokio_rustls::rustls::crypto::WebPkiSupportedAlgorithms;
147
+ use tokio_rustls::rustls::pki_types::ServerName;
148
+ use tokio_rustls::rustls::ClientConfig;
149
+ use tokio_rustls::{TlsAcceptor, TlsConnector};
150
+
151
+ #[derive(Debug)]
152
+ struct AcceptAny(WebPkiSupportedAlgorithms);
153
+
154
+ impl ServerCertVerifier for AcceptAny {
155
+ fn verify_server_cert(
156
+ &self,
157
+ _end_entity: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
158
+ _intermediates: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>],
159
+ _server_name: &ServerName<'_>,
160
+ _ocsp_response: &[u8],
161
+ _now: tokio_rustls::rustls::pki_types::UnixTime,
162
+ ) -> Result<ServerCertVerified, tokio_rustls::rustls::Error> {
163
+ Ok(ServerCertVerified::assertion())
164
+ }
165
+
166
+ fn verify_tls12_signature(
167
+ &self,
168
+ _message: &[u8],
169
+ _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
170
+ _dss: &tokio_rustls::rustls::DigitallySignedStruct,
171
+ ) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
172
+ Ok(HandshakeSignatureValid::assertion())
173
+ }
174
+
175
+ fn verify_tls13_signature(
176
+ &self,
177
+ _message: &[u8],
178
+ _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
179
+ _dss: &tokio_rustls::rustls::DigitallySignedStruct,
180
+ ) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
181
+ Ok(HandshakeSignatureValid::assertion())
182
+ }
183
+
184
+ fn supported_verify_schemes(&self) -> Vec<tokio_rustls::rustls::SignatureScheme> {
185
+ self.0.supported_schemes()
186
+ }
187
+ }
188
+
189
+ let acceptor =
190
+ TlsAcceptor::from(Arc::new(server_config(CERT, KEY, true).expect("valid PEM")));
191
+
192
+ let provider = tokio_rustls::rustls::crypto::ring::default_provider();
193
+ let verifier = AcceptAny(provider.signature_verification_algorithms);
194
+ let mut client = ClientConfig::builder()
195
+ .dangerous()
196
+ .with_custom_certificate_verifier(Arc::new(verifier))
197
+ .with_no_client_auth();
198
+ client.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
199
+ let connector = TlsConnector::from(Arc::new(client));
200
+
201
+ let (client_io, server_io) = tokio::io::duplex(16 * 1024);
202
+ let server = tokio::spawn(async move { acceptor.accept(server_io).await });
203
+ let domain = ServerName::try_from("localhost").expect("server name");
204
+ let client_stream = connector
205
+ .connect(domain, client_io)
206
+ .await
207
+ .expect("client handshake");
208
+ let server_stream = server.await.expect("join").expect("server handshake");
209
+
210
+ assert_eq!(client_stream.get_ref().1.alpn_protocol(), Some(&b"h2"[..]));
211
+ assert_eq!(server_stream.get_ref().1.alpn_protocol(), Some(&b"h2"[..]));
212
+ }
103
213
  }
data/lib/kino/cli.rb CHANGED
@@ -127,7 +127,7 @@ module Kino
127
127
  stats = server.stats
128
128
  puts dim("- ruby: #{RUBY_DESCRIPTION}")
129
129
  puts dim("- env: #{ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}")
130
- puts dim("- mode: #{server.mode}, #{count(stats[:workers], "worker")} × #{count(stats[:threads], "thread")}")
130
+ puts dim("- mode: #{server.mode}, #{workers_label(stats)} × #{count(stats[:threads], "thread")}")
131
131
  puts dim("- pid: #{Process.pid}")
132
132
  puts dim("- listening: #{server.url}")
133
133
  puts dim("- control: #{server.control_url}") if server.control_url
@@ -140,6 +140,14 @@ module Kino
140
140
  "#{number} #{noun}#{"s" unless number == 1}"
141
141
  end
142
142
 
143
+ # The pool as configured: "8 workers", or "8-32 workers" when it can
144
+ # grow.
145
+ def workers_label(stats)
146
+ return count(stats[:workers], "worker") if stats[:max_workers] == stats[:workers]
147
+
148
+ "#{stats[:workers]}-#{stats[:max_workers]} workers"
149
+ end
150
+
143
151
  # Roll credits when the process ends: normal exit or crash (at_exit
144
152
  # also runs after an uncaught exception; only a force-exit skips it).
145
153
  # @return [void]
@@ -234,7 +242,7 @@ module Kino
234
242
  end
235
243
 
236
244
  def write_sample(path)
237
- require "kino"
245
+ require "kino" # audition:disable runtime-require
238
246
  Configuration.write_sample(path)
239
247
  puts "Kino: wrote sample config to #{path}"
240
248
  0
@@ -245,8 +253,8 @@ module Kino
245
253
 
246
254
  # Resolve the full configuration once: file + CLI flag overrides.
247
255
  def resolve_config(options)
248
- require "kino"
249
- require "rack"
256
+ require "kino" # audition:disable runtime-require
257
+ require "rack" # audition:disable runtime-require
250
258
 
251
259
  config_file = options[:config_file] || Configuration.default_path
252
260
 
@@ -10,6 +10,8 @@ module Kino
10
10
  bind: "127.0.0.1",
11
11
  port: 0,
12
12
  workers: nil, # resolved to Kino.available_parallelism in #to_h
13
+ max_workers: nil, # nil = fixed pool; above workers = elastic pool
14
+ scale_down_after: nil, # resolved to 30 seconds in Server
13
15
  threads: nil, # resolved per mode in Server: 1 in :ractor, 3 in :threaded
14
16
  mode: :auto,
15
17
  queue_depth: 1024,
@@ -30,6 +32,7 @@ module Kino
30
32
  io_threads: nil,
31
33
  tokio_threads: nil,
32
34
  tls: nil,
35
+ http2: true,
33
36
  environment: nil,
34
37
  pidfile: nil,
35
38
  control_bind: nil,
@@ -43,7 +46,7 @@ module Kino
43
46
  SETTINGS = DEFAULTS.keys.freeze
44
47
 
45
48
  # Source template for {.sample}.
46
- SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
49
+ SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__).freeze
47
50
 
48
51
  # Where the `kino` CLI and the Rack handler look for a config file when
49
52
  # none is named: the project root first, then the Rails-style config/.
@@ -145,6 +148,8 @@ module Kino
145
148
  # bind "0.0.0.0"
146
149
  # port 9292
147
150
  # workers 8 # ractors (or thread groups in :threaded mode)
151
+ # max_workers 32 # elastic pool ceiling; unset = fixed pool
152
+ # scale_down_after 30 # seconds idle before an extra worker retires
148
153
  # threads 3 # threads per worker
149
154
  # mode :ractor # :auto | :ractor | :threaded
150
155
  # queue_depth 2048
@@ -171,9 +176,19 @@ module Kino
171
176
  # Port to listen on; 0 picks an ephemeral port.
172
177
  def port(port) = @config.set(:port, Integer(port))
173
178
 
174
- # Worker count (ractors in :ractor mode); defaults to CPU cores.
179
+ # Worker count (ractors in :ractor mode); defaults to CPU cores. The
180
+ # pool floor when max_workers is set.
175
181
  def workers(count) = @config.set(:workers, Integer(count))
176
182
 
183
+ # Pool ceiling: under sustained queue pressure the pool grows past
184
+ # `workers`, one worker at a time, up to this many. Unset (the
185
+ # default) keeps the pool fixed at `workers`.
186
+ def max_workers(count) = @config.set(:max_workers, Integer(count))
187
+
188
+ # Seconds a worker above the floor must sit idle before it is
189
+ # retired (default 30). Only meaningful with max_workers.
190
+ def scale_down_after(seconds) = @config.set(:scale_down_after, seconds)
191
+
177
192
  # Threads per worker (I/O concurrency inside one ractor); default is
178
193
  # mode-dependent: 1 in :ractor mode, 3 in :threaded.
179
194
  def threads(count) = @config.set(:threads, Integer(count))
@@ -245,6 +260,11 @@ module Kino
245
260
  # TLS termination; file paths or inline PEM strings.
246
261
  def tls(cert:, key:) = @config.set(:tls, {cert: cert, key: key})
247
262
 
263
+ # HTTP/2: negotiated via ALPN on TLS binds, served to
264
+ # prior-knowledge clients on plaintext. On by default; set false
265
+ # to serve HTTP/1 only.
266
+ def http2(enabled = true) = @config.set(:http2, !!enabled)
267
+
248
268
  # Sets RACK_ENV (unless already set) before the CLI loads the app.
249
269
  def environment(env) = @config.set(:environment, env.to_s)
250
270
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # A thread on the main ractor that calls `scan` every `tick` seconds
6
+ # until stopped. Main-ractor so it stays responsive when worker ractors
7
+ # are wedged. A scan that raises is logged and skipped, never fatal:
8
+ # monitors keep the server healthy, they must not take it down.
9
+ class Monitor
10
+ def initialize(name:, tick:)
11
+ @name = name
12
+ @tick = tick
13
+ @running = false
14
+ @thread = nil
15
+ end
16
+
17
+ def start
18
+ @running = true
19
+ @thread = Thread.new do
20
+ Thread.current.name = @name
21
+ run
22
+ end
23
+ self
24
+ end
25
+
26
+ def stop
27
+ @running = false
28
+ @thread&.join(@tick * 2)
29
+ end
30
+
31
+ private
32
+
33
+ def run
34
+ tick while @running
35
+ rescue => e
36
+ Log.error("#{@name} crashed: #{e.class}: #{e.message}")
37
+ end
38
+
39
+ def tick
40
+ scan
41
+ rescue => e
42
+ Log.error("#{@name} tick error: #{e.class}: #{e.message}")
43
+ ensure
44
+ sleep @tick
45
+ end
46
+
47
+ # One poll; subclasses define it.
48
+ def scan
49
+ raise NotImplementedError, "#{self.class} must define scan"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Grows and shrinks the worker pool between `floor` and `ceiling`. One
6
+ # thread on the main ractor polls the native queue and per-slot sensors
7
+ # every tick and drives a pool (RactorSupervisor or ThreadedPool) through
8
+ # three methods: `active_count`, `groups` (worker index => slot ids for
9
+ # every worker that may be retired), `grow`, and `retire(index)`.
10
+ #
11
+ # Policy: grow by one worker per tick once requests have been waiting in
12
+ # the queue on two consecutive ticks (a burst that clears within a tick
13
+ # is not pressure); retire one worker per tick, the one idle longest,
14
+ # once it has been idle for `scale_down_after`. "Idle" means no slot of
15
+ # the worker held a request at two consecutive samples and its served
16
+ # count did not move between them, so a worker serving short requests
17
+ # between samples is never mistaken for an idle one.
18
+ class PoolScaler < Monitor
19
+ TICK = 0.1
20
+ # Consecutive ticks with a non-empty queue before the pool grows.
21
+ PRESSURE_TICKS = 2
22
+
23
+ def initialize(server_id:, pool:, floor:, ceiling:, scale_down_after:, tick: TICK)
24
+ super(name: "pool scaler", tick: tick)
25
+ @server_id = server_id
26
+ @pool = pool
27
+ @floor = floor
28
+ @ceiling = ceiling
29
+ @scale_down_after = scale_down_after
30
+ @pressure = 0
31
+ @last_served = {}
32
+ @idle_since = {}
33
+ end
34
+
35
+ # One policy step over one set of observations: the monotonic time,
36
+ # the queue depth, and worker_stats rows ([slot, served, in_flight,
37
+ # busy_ms, quarantined, retired]). Public so the policy is testable
38
+ # without a server.
39
+ def step(now, queued, rows)
40
+ @pressure = queued.positive? ? @pressure + 1 : 0
41
+ if @pressure >= PRESSURE_TICKS
42
+ grow if @pool.active_count < @ceiling
43
+ return
44
+ end
45
+
46
+ observe_idle(now, rows)
47
+ return unless @pool.active_count > @floor
48
+
49
+ index, since = @idle_since.min_by { |_index, at| at }
50
+ return unless index && now - since >= @scale_down_after
51
+
52
+ retire(index)
53
+ end
54
+
55
+ private
56
+
57
+ def scan
58
+ queued, _in_flight = Native.queue_stats(@server_id)
59
+ step(Process.clock_gettime(Process::CLOCK_MONOTONIC), queued, Native.worker_stats(@server_id))
60
+ end
61
+
62
+ def grow
63
+ index = @pool.grow
64
+ return unless index
65
+
66
+ Native.record_scale_up(@server_id)
67
+ Log.info("pool grew to #{@pool.active_count} workers (queue pressure)")
68
+ end
69
+
70
+ def retire(index)
71
+ return unless @pool.retire(index)
72
+
73
+ forget(index)
74
+ Native.record_scale_down(@server_id)
75
+ Log.info("pool shrank to #{@pool.active_count} workers (worker-#{index} idle)")
76
+ end
77
+
78
+ # Per worker: idle when no slot holds a request at this sample and the
79
+ # served total did not move since the previous one. First sighting of
80
+ # a worker is never idle (it takes two samples to know).
81
+ def observe_idle(now, rows)
82
+ by_slot = rows.to_h { |row| [row[0], row] }
83
+ groups = @pool.groups
84
+ (@idle_since.keys - groups.keys).each { |index| forget(index) }
85
+ groups.each do |index, slot_ids|
86
+ served = slot_ids.sum { |id| by_slot.dig(id, 1) || 0 }
87
+ busy = slot_ids.any? { |id| (by_slot.dig(id, 2) || 0).positive? }
88
+ idle = !busy && @last_served[index] == served
89
+ @last_served[index] = served
90
+ if idle
91
+ @idle_since[index] ||= now
92
+ else
93
+ @idle_since.delete(index)
94
+ end
95
+ end
96
+ end
97
+
98
+ def forget(index)
99
+ @idle_since.delete(index)
100
+ @last_served.delete(index)
101
+ end
102
+ end
103
+ end
@@ -3,54 +3,22 @@
3
3
  module Kino
4
4
  # @private
5
5
  # Polls per-slot busy_ms and, past the timeout, quarantines a wedged slot
6
- # and asks the replacer to spawn a fresh worker. Runs one thread on the
7
- # main ractor (uncontended by wedged worker ractors, so it stays
8
- # responsive in :ractor mode). Never interrupts the wedged worker.
9
- class QuarantineMonitor
6
+ # and asks the replacer to spawn a fresh worker. Never interrupts the
7
+ # wedged worker.
8
+ class QuarantineMonitor < Monitor
10
9
  def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
10
+ super(name: "quarantine monitor", tick: tick)
11
11
  @server_id = server_id
12
12
  @timeout_ms = timeout_ms
13
13
  @max = max
14
14
  @replacer = replacer
15
- @tick = tick
16
15
  @outstanding = 0
17
16
  @at_cap_logged = false
18
- @running = false
19
- @thread = nil
20
- end
21
-
22
- def start
23
- @running = true
24
- @thread = Thread.new do
25
- Thread.current.name = "quarantine"
26
- run
27
- end
28
- self
29
- end
30
-
31
- def stop
32
- @running = false
33
- @thread&.join(@tick * 2)
34
17
  end
35
18
 
36
19
  private
37
20
 
38
- def run
39
- tick while @running
40
- rescue => e
41
- Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
42
- end
43
-
44
- def tick
45
- scan_slots
46
- rescue => e
47
- # A bad tick must never kill the monitor.
48
- Log.error("quarantine tick error: #{e.class}: #{e.message}")
49
- ensure
50
- sleep @tick
51
- end
52
-
53
- def scan_slots
21
+ def scan
54
22
  Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
55
23
  next if quarantined || busy_ms <= @timeout_ms
56
24