itsi 0.1.8 → 0.1.9

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -0
  3. data/crates/itsi_server/src/lib.rs +5 -0
  4. data/crates/itsi_server/src/request/itsi_request.rs +30 -2
  5. data/crates/itsi_server/src/response/itsi_response.rs +12 -2
  6. data/crates/itsi_server/src/server/itsi_server.rs +127 -70
  7. data/crates/itsi_server/src/server/listener.rs +1 -1
  8. data/crates/itsi_server/src/server/serve_strategy/single_mode.rs +18 -12
  9. data/crates/itsi_server/src/server/signal.rs +7 -0
  10. data/crates/itsi_server/src/server/thread_worker.rs +3 -4
  11. data/crates/itsi_server/src/server/tls.rs +11 -8
  12. data/crates/itsi_tracing/src/lib.rs +18 -1
  13. data/gems/scheduler/Cargo.lock +12 -12
  14. data/gems/scheduler/ext/itsi_server/src/lib.rs +5 -0
  15. data/gems/scheduler/ext/itsi_server/src/request/itsi_request.rs +30 -2
  16. data/gems/scheduler/ext/itsi_server/src/response/itsi_response.rs +12 -2
  17. data/gems/scheduler/ext/itsi_server/src/server/itsi_server.rs +127 -70
  18. data/gems/scheduler/ext/itsi_server/src/server/listener.rs +1 -1
  19. data/gems/scheduler/ext/itsi_server/src/server/serve_strategy/single_mode.rs +18 -12
  20. data/gems/scheduler/ext/itsi_server/src/server/signal.rs +7 -0
  21. data/gems/scheduler/ext/itsi_server/src/server/thread_worker.rs +3 -4
  22. data/gems/scheduler/ext/itsi_server/src/server/tls.rs +11 -8
  23. data/gems/scheduler/ext/itsi_tracing/src/lib.rs +18 -1
  24. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  25. data/gems/scheduler/test/test_address_resolve.rb +0 -1
  26. data/gems/scheduler/test/test_file_io.rb +0 -1
  27. data/gems/scheduler/test/test_kernel_sleep.rb +3 -4
  28. data/gems/server/Rakefile +8 -1
  29. data/gems/server/ext/itsi_server/src/lib.rs +5 -0
  30. data/gems/server/ext/itsi_server/src/request/itsi_request.rs +30 -2
  31. data/gems/server/ext/itsi_server/src/response/itsi_response.rs +12 -2
  32. data/gems/server/ext/itsi_server/src/server/itsi_server.rs +127 -70
  33. data/gems/server/ext/itsi_server/src/server/listener.rs +1 -1
  34. data/gems/server/ext/itsi_server/src/server/serve_strategy/single_mode.rs +18 -12
  35. data/gems/server/ext/itsi_server/src/server/signal.rs +7 -0
  36. data/gems/server/ext/itsi_server/src/server/thread_worker.rs +3 -4
  37. data/gems/server/ext/itsi_server/src/server/tls.rs +11 -8
  38. data/gems/server/ext/itsi_tracing/src/lib.rs +18 -1
  39. data/gems/server/lib/itsi/request.rb +29 -21
  40. data/gems/server/lib/itsi/server/rack/handler/itsi.rb +3 -4
  41. data/gems/server/lib/itsi/server/rack_interface.rb +79 -0
  42. data/gems/server/lib/itsi/server/scheduler_interface.rb +21 -0
  43. data/gems/server/lib/itsi/server/signal_trap.rb +24 -0
  44. data/gems/server/lib/itsi/server/version.rb +1 -1
  45. data/gems/server/lib/itsi/server.rb +67 -101
  46. data/gems/server/test/helpers/test_helper.rb +28 -0
  47. data/gems/server/test/test_itsi_server.rb +275 -3
  48. data/lib/itsi/version.rb +1 -1
  49. data/sandbox/deploy/main.tf +1 -0
  50. data/sandbox/itsi_sandbox_rack/Gemfile.lock +2 -2
  51. data/tasks.txt +0 -6
  52. metadata +13 -11
  53. data/gems/server/lib/itsi/signals.rb +0 -23
  54. data/gems/server/test/test_helper.rb +0 -7
  55. /data/gems/server/lib/itsi/{index.html.erb → index.html} +0 -0
@@ -10,6 +10,13 @@ pub static SIGNAL_HANDLER_CHANNEL: LazyLock<(
10
10
  broadcast::Receiver<LifecycleEvent>,
11
11
  )> = LazyLock::new(|| sync::broadcast::channel(5));
12
12
 
13
+ pub fn send_shutdown_event() {
14
+ SIGNAL_HANDLER_CHANNEL
15
+ .0
16
+ .send(LifecycleEvent::Shutdown)
17
+ .expect("Failed to send shutdown event");
18
+ }
19
+
13
20
  pub static SIGINT_COUNT: AtomicI8 = AtomicI8::new(0);
14
21
  fn receive_signal(signum: i32, _: sighandler_t) {
15
22
  SIGINT_COUNT.fetch_add(-1, std::sync::atomic::Ordering::SeqCst);
@@ -1,7 +1,7 @@
1
1
  use super::itsi_server::RequestJob;
2
2
  use crate::{request::itsi_request::ItsiRequest, ITSI_SERVER};
3
3
  use itsi_rb_helpers::{
4
- call_with_gvl, call_without_gvl, create_ruby_thread, kill_threads, HeapValue,
4
+ call_with_gvl, call_without_gvl, create_ruby_thread, kill_threads, HeapVal, HeapValue,
5
5
  };
6
6
  use itsi_tracing::{debug, error, info, warn};
7
7
  use magnus::{
@@ -52,7 +52,7 @@ pub struct TerminateWakerSignal(bool);
52
52
  pub fn build_thread_workers(
53
53
  pid: Pid,
54
54
  threads: NonZeroU8,
55
- app: Opaque<Value>,
55
+ app: HeapVal,
56
56
  scheduler_class: Option<String>,
57
57
  ) -> Result<(Arc<Vec<ThreadWorker>>, async_channel::Sender<RequestJob>)> {
58
58
  let (sender, receiver) = async_channel::bounded(20);
@@ -79,11 +79,10 @@ pub fn build_thread_workers(
79
79
  }
80
80
 
81
81
  pub fn load_app(
82
- app: Opaque<Value>,
82
+ app: HeapVal,
83
83
  scheduler_class: Option<String>,
84
84
  ) -> Result<(Opaque<Value>, Option<Opaque<Value>>)> {
85
85
  call_with_gvl(|ruby| {
86
- let app = app.get_inner_with(&ruby);
87
86
  let app = Opaque::from(
88
87
  app.funcall::<_, _, Value>(*ID_CALL, ())
89
88
  .expect("Couldn't load app"),
@@ -48,23 +48,26 @@ pub fn configure_tls(
48
48
  ) -> Result<ItsiTlsAcceptor> {
49
49
  let domains = query_params
50
50
  .get("domains")
51
- .map(|v| v.split(',').map(String::from).collect::<Vec<_>>());
51
+ .map(|v| v.split(',').map(String::from).collect::<Vec<_>>())
52
+ .or_else(|| query_params.get("domain").map(|v| vec![v.to_string()]));
52
53
 
53
- if query_params.get("cert").is_some_and(|c| c == "auto") {
54
+ if query_params.get("cert").is_some_and(|c| c == "acme") {
54
55
  if let Some(domains) = domains {
55
56
  let directory_url = &*ITSI_ACME_DIRECTORY_URL;
56
57
  info!(
57
58
  domains = format!("{:?}", domains),
58
59
  directory_url, "Requesting acme cert"
59
60
  );
61
+ let acme_contact_email = query_params
62
+ .get("acme_email")
63
+ .map(|s| s.to_string())
64
+ .or_else(|| (*ITSI_ACME_CONTACT_EMAIL).as_ref().ok().map(|s| s.to_string()))
65
+ .ok_or_else(|| itsi_error::ItsiError::ArgumentError(
66
+ "acme_cert query param or ITSI_ACME_CONTACT_EMAIL must be set before you can auto-generate let's encrypt certificates".to_string(),
67
+ ))?;
60
68
 
61
69
  let acme_config = AcmeConfig::new(domains)
62
- .contact([format!("mailto:{}", (*ITSI_ACME_CONTACT_EMAIL).as_ref().map_err(|_| {
63
- itsi_error::ItsiError::ArgumentError(
64
- "ITSI_ACME_CONTACT_EMAIL must be set before you can auto-generate production certificates"
65
- .to_string(),
66
- )
67
- })?)])
70
+ .contact([format!("mailto:{}", acme_contact_email)])
68
71
  .cache(LockedDirCache::new(&*ITSI_ACME_CACHE_DIR))
69
72
  .directory(directory_url);
70
73
 
@@ -1,11 +1,13 @@
1
1
  use std::env;
2
2
 
3
3
  use atty::{Stream, is};
4
+ use tracing::level_filters::LevelFilter;
4
5
  pub use tracing::{debug, error, info, trace, warn};
5
6
  pub use tracing_attributes::instrument; // Explicitly export from tracing-attributes
6
7
  use tracing_subscriber::{
7
- EnvFilter,
8
+ EnvFilter, Layer,
8
9
  fmt::{self, format},
10
+ layer::SubscriberExt,
9
11
  };
10
12
 
11
13
  #[instrument]
@@ -39,3 +41,18 @@ pub fn init() {
39
41
  .init();
40
42
  }
41
43
  }
44
+
45
+ pub fn run_silently<F, R>(f: F) -> R
46
+ where
47
+ F: FnOnce() -> R,
48
+ {
49
+ // Build a minimal subscriber that filters *everything* out
50
+ let no_op_subscriber =
51
+ tracing_subscriber::registry().with(fmt::layer().with_filter(LevelFilter::OFF));
52
+
53
+ // Turn that subscriber into a `Dispatch`
54
+ let no_op_dispatch = tracing::dispatcher::Dispatch::new(no_op_subscriber);
55
+
56
+ // Temporarily set `no_op_dispatch` as the *default* within this closure
57
+ tracing::dispatcher::with_default(&no_op_dispatch, f)
58
+ }
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+ require "socket"
5
+
3
6
  module Itsi
4
7
  class Request
5
- require "stringio"
6
- require "socket"
7
-
8
8
  attr_accessor :hijacked
9
9
 
10
- def to_env
10
+ def to_rack_env
11
11
  path = self.path
12
12
  host = self.host
13
13
  version = self.version
14
- body = self.body
15
14
  {
16
15
  "SERVER_SOFTWARE" => "Itsi",
17
16
  "SCRIPT_NAME" => script_name,
@@ -25,31 +24,40 @@ module Itsi
25
24
  "HTTP_HOST" => host,
26
25
  "SERVER_PROTOCOL" => version,
27
26
  "HTTP_VERSION" => version,
27
+ "itsi.request" => self,
28
+ "itsi.response" => response,
28
29
  "rack.version" => [version],
29
30
  "rack.url_scheme" => scheme,
30
- "rack.input" => \
31
- case body
32
- when Array then File.open(body.first, "rb")
33
- when String then StringIO.new(body)
34
- else body
35
- end,
31
+ "rack.input" => build_input_io,
36
32
  "rack.errors" => $stderr,
37
33
  "rack.multithread" => true,
38
34
  "rack.multiprocess" => true,
39
35
  "rack.run_once" => false,
40
36
  "rack.hijack?" => true,
41
37
  "rack.multipart.buffer_size" => 16_384,
42
- "rack.hijack" => lambda do
43
- self.hijacked = true
44
- UNIXSocket.pair.yield_self do |(server_sock, app_sock)|
45
- response.hijack(server_sock.fileno)
46
- server_sock.sync = true
47
- app_sock.sync = true
48
- app_sock.instance_variable_set("@server_sock", server_sock)
49
- app_sock
50
- end
51
- end
38
+ "rack.hijack" => build_hijack_proc
52
39
  }.tap { |r| headers.each { |(k, v)| r[k] = v } }
53
40
  end
41
+
42
+ def build_hijack_proc
43
+ lambda do
44
+ self.hijacked = true
45
+ UNIXSocket.pair.yield_self do |(server_sock, app_sock)|
46
+ response.hijack(server_sock.fileno)
47
+ server_sock.sync = true
48
+ app_sock.sync = true
49
+ app_sock.instance_variable_set("@server_sock", server_sock)
50
+ app_sock
51
+ end
52
+ end
53
+ end
54
+
55
+ def build_input_io
56
+ case body
57
+ when Array then File.open(body.first, "rb")
58
+ when String then StringIO.new(body)
59
+ else body
60
+ end
61
+ end
54
62
  end
55
63
  end
@@ -3,13 +3,12 @@ return unless defined?(::Rackup::Handler) || defined?(Rack::Handler)
3
3
  module Rack
4
4
  module Handler
5
5
  module Itsi
6
-
7
6
  def self.run(app, options = {})
8
7
  ::Itsi::Server.new(
9
- app: ->{ app },
10
- binds: ["#{options.fetch(:host, "127.0.0.1")}:#{options.fetch(:Port, 3001)}"],
8
+ app: -> { app },
9
+ binds: ["http://#{options.fetch(:host, "127.0.0.1")}:#{options.fetch(:Port, 3001)}"],
11
10
  workers: options.fetch(:workers, 1),
12
- threads: options.fetch(:threads, 1),
11
+ threads: options.fetch(:threads, 1)
13
12
  ).start
14
13
  end
15
14
  end
@@ -0,0 +1,79 @@
1
+ module Itsi
2
+ class Server
3
+ module RackInterface
4
+ # Interface to Rack applications.
5
+ # Here we build the env, and invoke the Rack app's call method.
6
+ # We then turn the Rack response into something Itsi server understands.
7
+ def call(app, request)
8
+ respond request, app.call(request.to_rack_env)
9
+ end
10
+
11
+ # Itsi responses are asynchronous and can be streamed.
12
+ # Response chunks are sent using response.send_frame
13
+ # and the response is finished using response.close_write.
14
+ # If only a single chunk is written, you can use the #send_and_close method.
15
+ def respond(request, (status, headers, body))
16
+ response = request.response
17
+
18
+ # Don't try and respond if we've been hijacked.
19
+ # The hijacker is now responsible for this.
20
+ return if request.hijacked
21
+
22
+ # 1. Set Status
23
+ response.status = status
24
+
25
+ # 2. Set Headers
26
+ body_streamer = streaming_body?(body) ? body : headers.delete("rack.hijack")
27
+ headers.each do |key, value|
28
+ next response.add_header(key, value) unless value.is_a?(Array)
29
+
30
+ value.each do |v|
31
+ response.add_header(key, v)
32
+ end
33
+ end
34
+
35
+ # 3. Set Body
36
+ # As soon as we start setting the response
37
+ # the server will begin to stream it to the client.
38
+
39
+ # If we're partially hijacked or returned a streaming body,
40
+ # stream this response.
41
+
42
+ if body_streamer
43
+ body_streamer.call(StreamIO.new(response))
44
+
45
+ # If we're enumerable with more than one chunk
46
+ # also stream, otherwise write in a single chunk
47
+ elsif body.respond_to?(:each) || body.respond_to?(:to_ary)
48
+ unless body.respond_to?(:each)
49
+ body = body.to_ary
50
+ raise "Body #to_ary didn't return an array" unless body.is_a?(Array)
51
+ end
52
+ # We offset this iteration intentionally,
53
+ # to optimize for the case where there's only one chunk.
54
+ buffer = nil
55
+ body.each do |part|
56
+ response.send_frame(buffer.to_s) if buffer
57
+ buffer = part
58
+ end
59
+
60
+ begin
61
+ response.send_and_close(buffer.to_s)
62
+ rescue StandardError
63
+ binding.b
64
+ end
65
+ else
66
+ response.send_and_close(body.to_s)
67
+ end
68
+ ensure
69
+ response.close_write
70
+ body.close if body.respond_to?(:close)
71
+ end
72
+
73
+ # A streaming body is one that responds to #call and not #each.
74
+ def streaming_body?(body)
75
+ body.respond_to?(:call) && !body.respond_to?(:each)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,21 @@
1
+ module Itsi
2
+ class Server
3
+ module SchedulerInterface
4
+ # Simple wrapper to instantiate a scheduler, start it,
5
+ # and immediate have it invoke a scheduler proc
6
+ def start_scheduler_loop(scheduler_class, scheduler_task)
7
+ scheduler = scheduler_class.new
8
+ Fiber.set_scheduler(scheduler)
9
+ [scheduler, Fiber.schedule(&scheduler_task)]
10
+ end
11
+
12
+ # When running in scheduler mode,
13
+ # each request is wrapped in a Fiber.
14
+ def schedule(app, request)
15
+ Fiber.schedule do
16
+ call(app, request)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ module Itsi
2
+ module SignalTrap
3
+
4
+ DEFAULT_SIGNALS = ["DEFAULT", "", nil].freeze
5
+ INTERCEPTED_SIGNALS = ["INT"].freeze
6
+
7
+ def trap(signal, *args, &block)
8
+ unless INTERCEPTED_SIGNALS.include?(signal.to_s) && block.nil? && Itsi::Server.running?
9
+ return super(signal, *args, &block)
10
+ end
11
+
12
+ Itsi::Server.reset_signal_handlers
13
+ nil
14
+ end
15
+ end
16
+ end
17
+
18
+ [Kernel, Signal].each do |receiver|
19
+ receiver.singleton_class.prepend(Itsi::SignalTrap)
20
+ end
21
+
22
+ [Object].each do |receiver|
23
+ receiver.include(Itsi::SignalTrap)
24
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Itsi
4
4
  class Server
5
- VERSION = "0.1.8"
5
+ VERSION = "0.1.9"
6
6
  end
7
7
  end
@@ -2,119 +2,85 @@
2
2
 
3
3
  require_relative "server/version"
4
4
  require_relative "server/itsi_server"
5
- require_relative "signals"
5
+ require_relative "server/rack_interface"
6
+ require_relative "server/signal_trap"
7
+ require_relative "server/scheduler_interface"
8
+ require_relative "server/rack/handler/itsi"
6
9
  require_relative "request"
7
10
  require_relative "stream_io"
8
- require_relative "server/rack/handler/itsi"
9
- require 'erb'
10
11
 
11
- DEFAULT_INDEX = IO.read(__dir__ + '/index.html.erb')
12
+ # When you Run Itsi without a Rack app,
13
+ # we start a tiny
14
+ DEFAULT_INDEX = IO.read("#{__dir__}/index.html").freeze
15
+ DEFAULT_BINDS = ["http://0.0.0.0:3000"].freeze
16
+ DEFAULT_APP = lambda {
17
+ require "json"
18
+ lambda do |env|
19
+ headers, body = \
20
+ if env["itsi.response"].json?
21
+ [
22
+ { "Content-Type" => "application/json" },
23
+ [{ "message" => "You're running on Itsi!", "rack_env" => env,
24
+ "version" => Itsi::Server::VERSION }.to_json]
25
+ ]
26
+ else
27
+ [
28
+ { "Content-Type" => "text/html" },
29
+ [
30
+ format(
31
+ DEFAULT_INDEX,
32
+ REQUEST_METHOD: env["REQUEST_METHOD"],
33
+ PATH_INFO: env["PATH_INFO"],
34
+ SERVER_NAME: env["SERVER_NAME"],
35
+ SERVER_PORT: env["SERVER_PORT"],
36
+ REMOTE_ADDR: env["REMOTE_ADDR"],
37
+ HTTP_USER_AGENT: env["HTTP_USER_AGENT"]
38
+ )
39
+ ]
40
+ ]
41
+ end
42
+ [200, headers, body]
43
+ end
44
+ }
12
45
 
13
46
  module Itsi
14
47
  class Server
48
+ extend RackInterface
49
+ extend SchedulerInterface
15
50
 
16
- def self.running?
17
- @running ||= false
18
- end
19
-
20
- def self.start(
21
- app: ->(env){
22
- [env['CONTENT_TYPE'], env['HTTP_ACCEPT']].include?('application/json') ?
23
- [200, {"Content-Type" => "application/json"}, ["{\"message\": \"You're running on Itsi!\"}"]] :
24
- [200, {"Content-Type" => "text/html"}, [
25
- DEFAULT_INDEX % {
26
- REQUEST_METHOD: env['REQUEST_METHOD'],
27
- PATH_INFO: env['PATH_INFO'],
28
- SERVER_NAME: env['SERVER_NAME'],
29
- SERVER_PORT: env['SERVER_PORT'],
30
- REMOTE_ADDR: env['REMOTE_ADDR'],
31
- HTTP_USER_AGENT: env['HTTP_USER_AGENT']
32
- }
33
- ]]
34
- },
35
- binds: ['http://0.0.0.0:3000'],
36
- **opts
37
- )
38
- server = new(app: ->{app}, binds: binds, **opts)
39
- @running = true
40
- Signal.trap('INT', 'DEFAULT')
41
- server.start
42
- ensure
43
- @running = false
44
- end
45
-
46
- def self.call(app, request)
47
- respond request, app.call(request.to_env)
48
- end
49
-
50
- def self.streaming_body?(body)
51
- body.respond_to?(:call) && !body.respond_to?(:each)
52
- end
53
-
54
- def self.respond(request, (status, headers, body))
55
- response = request.response
56
-
57
- # Don't try and respond if we've been hijacked.
58
- # The hijacker is now responsible for this.
59
- return if request.hijacked
60
-
61
- # 1. Set Status
62
- response.status = status
63
-
64
- # 2. Set Headers
65
- headers.each do |key, value|
66
- next response.add_header(key, value) unless value.is_a?(Array)
67
-
68
- value.each do |v|
69
- response.add_header(key, v)
70
- end
51
+ class << self
52
+ def running?
53
+ !!@running
71
54
  end
72
55
 
73
- # 3. Set Body
74
- # As soon as we start setting the response
75
- # the server will begin to stream it to the client.
76
-
77
- # If we're partially hijacked or returned a streaming body,
78
- # stream this response.
79
-
80
- if (body_streamer = streaming_body?(body) ? body : headers.delete("rack.hijack"))
81
- body_streamer.call(StreamIO.new(response))
82
-
83
- # If we're enumerable with more than one chunk
84
- # also stream, otherwise write in a single chunk
85
- elsif body.respond_to?(:each) || body.respond_to?(:to_ary)
86
- unless body.respond_to?(:each)
87
- body = body.to_ary
88
- raise "Body #to_ary didn't return an array" unless body.is_a?(Array)
89
- end
90
- # We offset this iteration intentionally,
91
- # to optimize for the case where there's only one chunk.
92
- buffer = nil
93
- body.each do |part|
94
- response.send_frame(buffer.to_s) if buffer
95
- buffer = part
96
- end
97
-
98
- response.send_and_close(buffer.to_s)
99
- else
100
- response.send_and_close(body.to_s)
56
+ def build(
57
+ app: DEFAULT_APP[],
58
+ binds: DEFAULT_BINDS,
59
+ **opts
60
+ )
61
+ new(app: -> { app }, binds: binds, **opts)
101
62
  end
102
- ensure
103
- response.close_write
104
- body.close if body.respond_to?(:close)
105
- end
106
63
 
107
- def self.start_scheduler_loop(scheduler_class, scheduler_task)
108
- scheduler = scheduler_class.new
109
- Fiber.set_scheduler(scheduler)
110
- [scheduler, Fiber.schedule(&scheduler_task)]
111
- end
64
+ def start_in_background_thread(silence: true, **opts)
65
+ start(background: true, silence: silence, **opts)
66
+ end
112
67
 
113
- # If scheduler is enabled
114
- # Each request is wrapped in a Fiber.
115
- def self.schedule(app, request)
116
- Fiber.schedule do
117
- call(app, request)
68
+ def start(background: false, **opts)
69
+ build(**opts).tap do |server|
70
+ previous_handler = Signal.trap("INT", "DEFAULT")
71
+ @running = true
72
+ if background
73
+ Thread.new do
74
+ server.start
75
+ @running = false
76
+ Signal.trap("INT", previous_handler)
77
+ end
78
+ else
79
+ server.start
80
+ @running = false
81
+ Signal.trap("INT", previous_handler)
82
+ end
83
+ end
118
84
  end
119
85
  end
120
86
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/reporters"
4
+ require "itsi/server"
5
+ require "itsi/scheduler"
6
+
7
+ Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
8
+
9
+ def free_bind
10
+ server = TCPServer.new("0.0.0.0", 0)
11
+ port = server.addr[1]
12
+ server.close
13
+ "http://0.0.0.0:#{port}"
14
+ end
15
+
16
+ def run_app(app, **opts)
17
+ bind = free_bind
18
+ server = Itsi::Server.start_in_background_thread(
19
+ app: app,
20
+ binds: [bind],
21
+ **opts
22
+ )
23
+
24
+ sleep 0.1
25
+ yield URI(bind), server
26
+ ensure
27
+ server&.stop
28
+ end