quicsilver 0.3.0 → 0.5.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +8 -1
  5. data/CHANGELOG.md +82 -0
  6. data/Gemfile.lock +27 -2
  7. data/README.md +205 -29
  8. data/Rakefile +75 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/README.md +44 -91
  17. data/examples/benchmark.rb +191 -0
  18. data/examples/connection_pool_demo.rb +47 -0
  19. data/examples/example_helper.rb +69 -0
  20. data/examples/falcon_middleware.rb +44 -0
  21. data/examples/feature_demo.rb +125 -0
  22. data/examples/grpc_style.rb +97 -0
  23. data/examples/latency_aware_routing.rb +382 -0
  24. data/examples/lifecycle_drain.rb +264 -0
  25. data/examples/minimal_http3_server.rb +6 -18
  26. data/examples/priorities.rb +60 -0
  27. data/examples/protocol_http_server.rb +31 -0
  28. data/examples/rack_http3_server.rb +8 -20
  29. data/examples/rails_feature_test.rb +280 -0
  30. data/examples/server_stats.rb +117 -0
  31. data/examples/simple_client_test.rb +5 -5
  32. data/examples/streaming_sse.rb +33 -0
  33. data/examples/trailers.rb +69 -0
  34. data/examples/webtransport_smoke.rb +546 -0
  35. data/examples/worker_load_balancing.rb +425 -0
  36. data/ext/quicsilver/extconf.rb +111 -12
  37. data/ext/quicsilver/include/msquic.h +2226 -0
  38. data/ext/quicsilver/include/msquic_posix.h +696 -0
  39. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  40. data/ext/quicsilver/msquic_patch.rb +68 -0
  41. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  42. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  43. data/ext/quicsilver/quicsilver.c +1646 -189
  44. data/lib/quicsilver/client/client.rb +546 -88
  45. data/lib/quicsilver/client/connection_pool.rb +202 -0
  46. data/lib/quicsilver/client/request.rb +106 -14
  47. data/lib/quicsilver/errors.rb +39 -0
  48. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  49. data/lib/quicsilver/protocol/adapter.rb +196 -0
  50. data/lib/quicsilver/protocol/capsule.rb +38 -0
  51. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  52. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  53. data/lib/quicsilver/protocol/datagram.rb +40 -0
  54. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  55. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  56. data/lib/quicsilver/protocol/frames.rb +137 -10
  57. data/lib/quicsilver/protocol/priority.rb +65 -0
  58. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  59. data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
  60. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  61. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  62. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  63. data/lib/quicsilver/protocol/request_parser.rb +43 -141
  64. data/lib/quicsilver/protocol/response_encoder.rb +40 -5
  65. data/lib/quicsilver/protocol/response_parser.rb +26 -128
  66. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  67. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  68. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  69. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  70. data/lib/quicsilver/rack/context.rb +33 -0
  71. data/lib/quicsilver/response.rb +40 -0
  72. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  73. data/lib/quicsilver/server/receive_queue.rb +106 -0
  74. data/lib/quicsilver/server/request_handler.rb +111 -48
  75. data/lib/quicsilver/server/request_registry.rb +18 -5
  76. data/lib/quicsilver/server/scheduler.rb +50 -0
  77. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  78. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  79. data/lib/quicsilver/server/server.rb +848 -104
  80. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  81. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  82. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  83. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  84. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  85. data/lib/quicsilver/transport/configuration.rb +113 -11
  86. data/lib/quicsilver/transport/connection.rb +376 -132
  87. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  88. data/lib/quicsilver/transport/event_loop.rb +36 -0
  89. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  90. data/lib/quicsilver/transport/stream.rb +18 -3
  91. data/lib/quicsilver/transport/stream_event.rb +25 -2
  92. data/lib/quicsilver/transport/stream_id.rb +26 -0
  93. data/lib/quicsilver/version.rb +1 -1
  94. data/lib/quicsilver.rb +45 -11
  95. data/lib/rackup/handler/quicsilver.rb +57 -38
  96. data/quicsilver.gemspec +17 -2
  97. metadata +120 -10
  98. data/benchmarks/concurrent.rb +0 -110
  99. data/benchmarks/quicsilver_server.rb +0 -46
  100. data/benchmarks/rails.rb +0 -170
  101. data/benchmarks/throughput.rb +0 -113
  102. data/examples/setup_certs.sh +0 -57
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ # Subclass of Protocol::Rack's adapter that adds rack.trailers support.
6
+ #
7
+ # Protocol-rack's wrap_headers creates new Protocol::HTTP::Headers,
8
+ # losing any trailer! state. We override call to inject trailers
9
+ # from env["rack.trailers"] after wrap_headers runs.
10
+ #
11
+ # Usage in a Rack app:
12
+ # env["rack.trailers"] = { "grpc-status" => "0", "grpc-message" => "OK" }
13
+ #
14
+ class RackAdapter < ::Protocol::Rack::Adapter::Rack31
15
+ def call(request)
16
+ env = self.make_environment(request)
17
+ add_transport_context(env, request)
18
+ add_rack_context(env, request.rack_context) if request.respond_to?(:rack_context) && request.rack_context
19
+
20
+ # Wire rack.early_hints for 103 Early Hints
21
+ if request.respond_to?(:interim_response) && request.interim_response
22
+ env["rack.early_hints"] = ->(hint_headers) {
23
+ request.send_interim_response(103, ::Protocol::HTTP::Headers[hint_headers.map { |k, v| [k, v] }])
24
+ }
25
+ end
26
+
27
+ status, headers, body = @app.call(env)
28
+
29
+ headers, meta = self.wrap_headers(headers)
30
+
31
+ # Inject trailers from rack.trailers into the protocol-http headers
32
+ if (trailers = env["rack.trailers"]) && trailers.is_a?(Hash) && !trailers.empty?
33
+ headers.trailer!
34
+ trailers.each { |k, v| headers.add(k, v) }
35
+ end
36
+
37
+ ::Protocol::Rack::Response.wrap(env, status, headers, meta, body, request)
38
+ rescue => error
39
+ self.handle_error(env, status, headers, body, error)
40
+ end
41
+
42
+ private
43
+
44
+ def add_rack_context(env, rack_context)
45
+ env["quicsilver.context"] = rack_context
46
+
47
+ if rack_context.respond_to?(:webtransport) && rack_context.webtransport
48
+ env["quicsilver.webtransport"] = rack_context.webtransport
49
+ end
50
+ end
51
+
52
+ def add_transport_context(env, request)
53
+ return unless request.respond_to?(:transport_context)
54
+ return unless (context = request.transport_context)
55
+
56
+ connection = context["connection"] || {}
57
+
58
+ if connection_id = connection["connection_id"]
59
+ env["quicsilver.connection_id"] = connection_id
60
+ end
61
+
62
+ if request_id = connection["request_id"]
63
+ env["quicsilver.request_id"] = request_id
64
+ end
65
+
66
+ if stream_id = connection["stream_id"]
67
+ env["quicsilver.stream_id"] = stream_id
68
+ end
69
+
70
+ env["quicsilver.context"] ||= ::Quicsilver::Rack::Context.new(
71
+ stream_id: connection["stream_id"],
72
+ metadata: context
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ # Nonblocking admission for Body::Writable; only the consumer may wait.
6
+ class ReceiveQueue
7
+ def self.validate_limits(bytes, chunks)
8
+ [bytes, chunks].each do |limit|
9
+ raise ArgumentError, "Receive limits must be positive integers" unless limit.is_a?(Integer) && limit.positive?
10
+ end
11
+ end
12
+
13
+ # Accounting only. Every byte pushed here was sent inside a limit this
14
+ # endpoint advertised (RFC 9000 §4.1), so there is no refusing it; the
15
+ # receiver controls the rate by deciding when to advertise more (§4.2),
16
+ # which is what native receive credit does. Whether being over the limit
17
+ # means "stop granting" or "stop the stream" is the caller's policy.
18
+ def initialize(bytes:, chunks:)
19
+ self.class.validate_limits(bytes, chunks)
20
+ @byte_limit = bytes
21
+ @chunk_limit = chunks
22
+ @queue = Thread::Queue.new
23
+ @mutex = Mutex.new
24
+ @bytes = 0
25
+ @chunks = 0
26
+ @generation = 0
27
+ end
28
+
29
+ def push(data)
30
+ @mutex.synchronize do
31
+ raise ClosedQueueError if @queue.closed?
32
+
33
+ @queue.push([data, data.bytesize, @generation])
34
+ @bytes += data.bytesize
35
+ @chunks += 1
36
+ end
37
+ self
38
+ end
39
+
40
+ def over_limit?
41
+ @mutex.synchronize { @bytes > @byte_limit || @chunks > @chunk_limit }
42
+ end
43
+
44
+ def available_bytes
45
+ @mutex.synchronize { @chunks < @chunk_limit ? [@byte_limit - @bytes, 0].max : 0 }
46
+ end
47
+
48
+ def release_capacity_to(&callback)
49
+ capacity = @mutex.synchronize do
50
+ return if @queue.closed? || @release_capacity
51
+
52
+ @release_capacity = callback
53
+ [[@byte_limit - @bytes, 0].max, [@chunk_limit - @chunks, 0].max]
54
+ end
55
+ callback.call(*capacity)
56
+ end
57
+
58
+ def pop
59
+ entry = @queue.pop
60
+ return unless entry
61
+
62
+ data, bytes, generation = entry
63
+ grant_bytes = grant_chunks = 0
64
+ release_capacity = @mutex.synchronize do
65
+ # A concurrent discard already released entries from the old queue.
66
+ if generation == @generation
67
+ @bytes -= bytes
68
+ @chunks -= 1
69
+ # Grant back what was freed, capped at the capacity that is now
70
+ # actually spare. After an oversized first receive the two differ,
71
+ # and granting the full amount would admit another oversized one.
72
+ grant_bytes = [bytes, @byte_limit - @bytes].min.clamp(0..)
73
+ grant_chunks = [1, @chunk_limit - @chunks].min.clamp(0..)
74
+ @release_capacity unless @queue.closed?
75
+ end
76
+ end
77
+ release_capacity&.call(grant_bytes, grant_chunks) if grant_bytes.positive? || grant_chunks.positive?
78
+ data
79
+ end
80
+
81
+ def clear
82
+ @mutex.synchronize do
83
+ # Writable clears only when discarding input; prevent a racing push
84
+ # between its clear and close calls.
85
+ @release_capacity = nil
86
+ @queue.close
87
+ @queue.clear
88
+ @generation += 1
89
+ @bytes = @chunks = 0
90
+ end
91
+ self
92
+ end
93
+
94
+ def close
95
+ @mutex.synchronize do
96
+ @release_capacity = nil
97
+ @queue.close
98
+ end
99
+ self
100
+ end
101
+
102
+ def closed? = @queue.closed?
103
+ def empty? = @queue.empty?
104
+ end
105
+ end
106
+ end
@@ -3,18 +3,46 @@
3
3
  module Quicsilver
4
4
  class Server
5
5
  class RequestHandler
6
- # Safe HTTP methods allowed in 0-RTT early data (RFC 9110 §9.2.1)
7
6
  SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
8
7
 
8
+ attr_reader :adapter
9
+
9
10
  def initialize(app:, configuration:, request_registry:, cancelled_streams:, cancelled_mutex:)
10
- @app = app
11
11
  @configuration = configuration
12
12
  @request_registry = request_registry
13
13
  @cancelled_streams = cancelled_streams
14
14
  @cancelled_mutex = cancelled_mutex
15
+ @adapter = Protocol::Adapter.new(app)
15
16
  end
16
17
 
17
18
  def call(connection, stream, early_data: false)
19
+ request = parse_request(connection, stream, early_data: early_data)
20
+ return unless request
21
+
22
+ response = @adapter.call(request)
23
+
24
+ send_response(connection, stream, request, response)
25
+ rescue Server::DrainTimeoutError
26
+ Quicsilver.logger.debug("Request interrupted by drain: stream #{stream.stream_id}")
27
+ rescue Protocol::FrameError => e
28
+ Quicsilver.logger.error("Frame error: #{e.message} (0x#{e.error_code.to_s(16)})")
29
+ Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
30
+ rescue Protocol::MessageError => e
31
+ Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
32
+ stream.reset(e.error_code) if stream.writable?
33
+ rescue => e
34
+ Quicsilver.logger.error("Error handling request: #{e.class} - #{e.message}")
35
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
36
+ connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
37
+ ensure
38
+ @request_registry.complete(stream.stream_id, connection.handle)
39
+ @cancelled_mutex.synchronize { @cancelled_streams.delete([connection.handle, stream.stream_id]) }
40
+ connection.remove_stream(stream.stream_id)
41
+ end
42
+
43
+ private
44
+
45
+ def parse_request(connection, stream, early_data: false)
18
46
  parser = Protocol::RequestParser.new(
19
47
  stream.data,
20
48
  max_body_size: @configuration.max_body_size,
@@ -23,63 +51,98 @@ module Quicsilver
23
51
  max_frame_payload_size: @configuration.max_frame_payload_size
24
52
  )
25
53
  parser.parse
26
- parser.validate_headers! # raises MessageError for missing/invalid pseudo-headers
27
- env = parser.to_rack_env
54
+ parser.validate_headers!
28
55
 
29
- if env && @app
30
- env["quicsilver.early_data"] = early_data
56
+ headers = parser.headers
57
+ unless headers && !headers.empty?
58
+ connection.send_error(stream, 400, "Bad Request") if stream.writable?
59
+ return
60
+ end
31
61
 
32
- # RFC 8470: reject unsafe methods on 0-RTT unless app opted in
33
- if @configuration.early_data_policy == :reject &&
34
- early_data && !SAFE_METHODS.include?(env["REQUEST_METHOD"])
35
- connection.send_error(stream, 425, "Too Early") if stream.writable?
36
- return
37
- end
62
+ method = headers[":method"]
38
63
 
39
- @request_registry.track(
40
- stream.stream_id,
41
- connection.handle,
42
- path: env["PATH_INFO"] || "/",
43
- method: env["REQUEST_METHOD"] || "GET"
44
- )
64
+ if @configuration.early_data_policy == :reject &&
65
+ early_data && !SAFE_METHODS.include?(method)
66
+ connection.send_error(stream, 425, "Too Early") if stream.writable?
67
+ return
68
+ end
69
+
70
+ request, body = @adapter.build_request(
71
+ headers,
72
+ remote_address: connection.remote_address,
73
+ remote_port: connection.remote_port,
74
+ transport_context: connection.request_context(stream_id: stream.stream_id)
75
+ )
76
+ request.headers.add("quicsilver-early-data", early_data.to_s)
45
77
 
46
- status, headers, body = @app.call(env)
78
+ # Wire interim_response so apps can send 103 Early Hints.
79
+ # Falcon mode: app calls request.send_interim_response(103, headers)
80
+ # Rack mode: bridged to rack.early_hints via EarlyHintsMiddleware
81
+ request.interim_response = ->(status, headers) {
82
+ connection.send_informational(stream, status, headers)
83
+ }
47
84
 
48
- if cancelled_stream?(stream.stream_id)
49
- Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
50
- return
51
- end
85
+ if body && parser.body && parser.body.size > 0
86
+ parser.body.rewind
87
+ body_data = parser.body.read
88
+ body.write(body_data) unless body_data.empty?
89
+ end
90
+ body&.close_write
91
+
92
+ connection.apply_stream_priority(stream, parser.priority)
52
93
 
53
- raise "Stream handle not found for stream #{stream.stream_id}" unless stream.writable?
94
+ @request_registry.track(
95
+ stream.stream_id, connection.handle,
96
+ path: headers[":path"] || "/", method: method || "GET"
97
+ )
54
98
 
55
- connection.send_response(stream, status, headers, body, head_request: env["REQUEST_METHOD"] == "HEAD")
56
- @request_registry.complete(stream.stream_id)
99
+ request
100
+ end
101
+
102
+ def send_response(connection, stream, request, response)
103
+ if cancelled_stream?(stream.stream_id, connection.handle)
104
+ Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
105
+ return
106
+ end
107
+
108
+ raise "Stream handle not found for stream #{stream.stream_id}" unless stream.writable?
109
+
110
+ headers = response.headers
111
+
112
+ # Extract trailers before flattening (Protocol::HTTP::Headers tracks
113
+ # trailer! state that a plain Hash would lose — needed for gRPC).
114
+ trailers = if headers.respond_to?(:trailer?) && headers.trailer?
115
+ trailer_hash = {}
116
+ headers.trailer.each { |name, value| trailer_hash[name] = value }
117
+ trailer_hash
118
+ end
119
+
120
+ # Pairs, not a Hash: a field can legitimately repeat, and set-cookie MUST NOT be combined
121
+ # (RFC 9110 §5.3). Keying by name dropped every repeat but the last, which silently discarded
122
+ # the session cookie of any Rails response that set more than one — sign-in appeared to work
123
+ # and then forgot the request it had just authenticated.
124
+ response_headers = []
125
+ if headers.respond_to?(:header)
126
+ headers.header.each { |name, value| response_headers << [ name, value ] }
57
127
  else
58
- connection.send_error(stream, 400, "Bad Request") if stream.writable?
128
+ headers&.each { |name, value| response_headers << [ name, value ] }
59
129
  end
60
- rescue Server::DrainTimeoutError
61
- Quicsilver.logger.debug("Request interrupted by drain: stream #{stream.stream_id}")
62
- rescue Protocol::FrameError => e
63
- # Frame errors are connection-level: signal via CONNECTION_CLOSE with H3 error code
64
- Quicsilver.logger.error("Frame error: #{e.message} (0x#{e.error_code.to_s(16)})")
65
- Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
66
- rescue Protocol::MessageError => e
67
- # Message errors are stream-level: signal via RESET_STREAM with H3 error code
68
- Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
69
- Quicsilver.stream_reset(stream.stream_handle, e.error_code) if stream.writable?
70
- rescue => e
71
- Quicsilver.logger.error("Error handling request: #{e.class} - #{e.message}")
72
- Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
73
- connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
74
- ensure
75
- @request_registry.complete(stream.stream_id) if @request_registry.include?(stream.stream_id)
76
- @cancelled_mutex.synchronize { @cancelled_streams.delete(stream.stream_id) }
77
- end
78
130
 
79
- private
131
+ # Protocol-rack moves content-length from headers to body.length —
132
+ # re-add it so the HTTP/3 response includes the header.
133
+ unless response_headers.any? { |name, _| name == "content-length" }
134
+ response_headers << [ "content-length", response.body.length.to_s ] if response.body&.length
135
+ end
136
+
137
+ body = response.body || []
138
+ connection.send_response(stream, response.status, response_headers, body,
139
+ head_request: request.head?, trailers: trailers)
140
+ @request_registry.complete(stream.stream_id, connection.handle)
141
+ connection.remove_stream(stream.stream_id)
142
+ end
80
143
 
81
- def cancelled_stream?(stream_id)
82
- @cancelled_mutex.synchronize { @cancelled_streams.include?(stream_id) }
144
+ def cancelled_stream?(stream_id, connection_handle)
145
+ @cancelled_mutex.synchronize { @cancelled_streams.include?([connection_handle, stream_id]) }
83
146
  end
84
147
  end
85
148
  end
@@ -10,8 +10,9 @@ module Quicsilver
10
10
 
11
11
  def track(stream_id, connection_handle, path:, method:, started_at: Time.now)
12
12
  @mutex.synchronize do
13
- @requests[stream_id] = {
13
+ @requests[key_for(stream_id, connection_handle)] = {
14
14
  connection_handle: connection_handle,
15
+ stream_id: stream_id,
15
16
  path: path,
16
17
  method: method,
17
18
  started_at: started_at
@@ -19,8 +20,10 @@ module Quicsilver
19
20
  end
20
21
  end
21
22
 
22
- def complete(stream_id)
23
- @mutex.synchronize { @requests.delete(stream_id) }
23
+ def complete(stream_id, connection_handle)
24
+ @mutex.synchronize do
25
+ @requests.delete(key_for(stream_id, connection_handle))
26
+ end
24
27
  end
25
28
 
26
29
  def active_count
@@ -42,8 +45,18 @@ module Quicsilver
42
45
  @mutex.synchronize { @requests.empty? }
43
46
  end
44
47
 
45
- def include?(stream_id)
46
- @mutex.synchronize { @requests.key?(stream_id) }
48
+ def include?(stream_id, connection_handle)
49
+ @mutex.synchronize do
50
+ @requests.key?(key_for(stream_id, connection_handle))
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def key_for(stream_id, connection_handle)
57
+ raise ArgumentError, "Connection handle is required" if connection_handle.nil?
58
+
59
+ [connection_handle, stream_id]
47
60
  end
48
61
  end
49
62
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ # Base class for work schedulers.
6
+ #
7
+ # A scheduler controls how incoming request work units are executed —
8
+ # via threads, fibers, or any other concurrency model. Subclasses
9
+ # implement the delivery mechanism.
10
+ #
11
+ # Usage:
12
+ # scheduler = ThreadScheduler.new(size: 10, max_queue_size: 100, &handler)
13
+ # scheduler.start # start workers
14
+ # scheduler.enqueue(work) # schedule a work unit
15
+ # scheduler.drain(timeout: 5) # wait for queue to empty
16
+ # scheduler.stop # shut down workers
17
+ #
18
+ class Scheduler
19
+ # Schedule a work unit for execution.
20
+ def enqueue(work)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Is the work queue at capacity?
25
+ def full?
26
+ raise NotImplementedError
27
+ end
28
+
29
+ # Number of pending work units.
30
+ def pending
31
+ raise NotImplementedError
32
+ end
33
+
34
+ # Wait for the queue to empty.
35
+ def drain(timeout: 5)
36
+ raise NotImplementedError
37
+ end
38
+
39
+ # Start processing work units.
40
+ def start
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # Shut down workers.
45
+ def stop
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scheduler"
4
+
5
+ module Quicsilver
6
+ class Server
7
+ module Schedulers
8
+ # Fiber-based scheduler — uses Async::Queue for cooperative scheduling.
9
+ # Expects to be running inside an Async reactor (e.g. Falcon provides one).
10
+ # Requires the async gem.
11
+ class FiberScheduler < Scheduler
12
+ def initialize(concurrency:, max_queue_size:, &handler)
13
+ require "async"
14
+ require "async/queue"
15
+ require "async/barrier"
16
+
17
+ @concurrency = concurrency
18
+ @max_queue_size = max_queue_size
19
+ @handler = handler
20
+ @queue = Async::Queue.new
21
+ @running = false
22
+ @barrier = nil
23
+ end
24
+
25
+ def enqueue(work)
26
+ @queue.enqueue(work)
27
+ end
28
+
29
+ def full?
30
+ @queue.size >= @max_queue_size
31
+ end
32
+
33
+ def pending
34
+ @queue.size
35
+ end
36
+
37
+ # Spawn fiber workers inside the current Async reactor.
38
+ # Caller must already be inside Async do (e.g. Falcon).
39
+ def start
40
+ @running = true
41
+ @barrier = Async::Barrier.new
42
+
43
+ @concurrency.times do
44
+ @barrier.async do
45
+ while @running
46
+ work = @queue.dequeue
47
+ break if work == :shutdown
48
+
49
+ run(work)
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # One unit of work must not take the worker fiber down with it; see
56
+ # the thread scheduler for the same reasoning.
57
+ private def run(work)
58
+ @handler.call(work)
59
+ rescue => e
60
+ Quicsilver.logger.error("Unhandled error in worker: #{e.class} - #{e.message}")
61
+ Quicsilver.logger.debug(e.backtrace&.first(5)&.join("\n").to_s)
62
+ end
63
+
64
+ def drain(timeout: 5)
65
+ deadline = Time.now + timeout
66
+ while @queue.size > 0 && Time.now < deadline
67
+ sleep 0.05
68
+ end
69
+ end
70
+
71
+ def stop
72
+ @running = false
73
+ @concurrency.times { @queue.enqueue(:shutdown) }
74
+ @barrier&.wait
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scheduler"
4
+
5
+ module Quicsilver
6
+ class Server
7
+ module Schedulers
8
+ # Thread-based scheduler — uses a thread pool with Thread::Queue.
9
+ class ThreadScheduler < Scheduler
10
+ def initialize(concurrency:, max_queue_size:, &handler)
11
+ @size = concurrency
12
+ @max_queue_size = max_queue_size
13
+ @handler = handler
14
+ @queue = Queue.new
15
+ @threads = []
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def enqueue(work)
20
+ @queue.push(work)
21
+ end
22
+
23
+ def full?
24
+ @queue.size >= @max_queue_size
25
+ end
26
+
27
+ def pending
28
+ @queue.size
29
+ end
30
+
31
+ def start
32
+ @size.times do
33
+ thread = Thread.new do
34
+ while (work = @queue.pop)
35
+ break if work == :shutdown
36
+
37
+ run(work)
38
+ end
39
+ end
40
+ @mutex.synchronize { @threads << thread }
41
+ end
42
+ end
43
+
44
+ def drain(timeout: 5)
45
+ deadline = Time.now + timeout
46
+ while @queue.size > 0 && Time.now < deadline
47
+ sleep 0.05
48
+ end
49
+ end
50
+
51
+ # One unit of work must not take the worker down with it. An escaping
52
+ # error would end the thread, shrinking the pool for the rest of the
53
+ # process, and Thread#join would re-raise it in whoever stops the
54
+ # server. DrainTimeoutError is our own interruption, so it ends the
55
+ # worker as intended.
56
+ private def run(work)
57
+ @handler.call(work)
58
+ rescue DrainTimeoutError
59
+ raise
60
+ rescue => e
61
+ Quicsilver.logger.error("Unhandled error in worker: #{e.class} - #{e.message}")
62
+ Quicsilver.logger.debug(e.backtrace&.first(5)&.join("\n").to_s)
63
+ end
64
+
65
+ def stop
66
+ @size.times { @queue.push(:shutdown) }
67
+ @mutex.synchronize do
68
+ @threads.each { |t| t.join(2) }
69
+ @threads.each { |t| t.raise(DrainTimeoutError, "drain timeout") if t.alive? }
70
+ @threads.clear
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end