rage-rb 1.23.0 → 1.25.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/CONTRIBUTING.md +240 -0
  4. data/README.md +66 -123
  5. data/lib/rage/application.rb +1 -0
  6. data/lib/rage/cable/cable.rb +20 -15
  7. data/lib/rage/cable/channel.rb +2 -1
  8. data/lib/rage/configuration.rb +166 -29
  9. data/lib/rage/controller/api.rb +10 -34
  10. data/lib/rage/cookies.rb +1 -1
  11. data/lib/rage/deferred/deferred.rb +7 -0
  12. data/lib/rage/deferred/metadata.rb +8 -0
  13. data/lib/rage/deferred/scheduler.rb +25 -0
  14. data/lib/rage/deferred/task.rb +19 -5
  15. data/lib/rage/errors.rb +83 -0
  16. data/lib/rage/events/subscriber.rb +6 -1
  17. data/lib/rage/fiber.rb +14 -23
  18. data/lib/rage/fiber_scheduler.rb +51 -6
  19. data/lib/rage/internal.rb +15 -6
  20. data/lib/rage/middleware/fiber_wrapper.rb +1 -0
  21. data/lib/rage/openapi/builder.rb +1 -1
  22. data/lib/rage/openapi/converter.rb +5 -1
  23. data/lib/rage/openapi/nodes/method.rb +2 -1
  24. data/lib/rage/openapi/nodes/root.rb +2 -1
  25. data/lib/rage/openapi/openapi.rb +33 -1
  26. data/lib/rage/openapi/parser.rb +73 -2
  27. data/lib/rage/openapi/parsers/ext/alba.rb +30 -2
  28. data/lib/rage/openapi/parsers/ext/blueprinter.rb +110 -0
  29. data/lib/rage/openapi/parsers/request.rb +2 -2
  30. data/lib/rage/openapi/parsers/response.rb +3 -2
  31. data/lib/rage/openapi/parsers/yaml.rb +27 -5
  32. data/lib/rage/params_parser.rb +2 -2
  33. data/lib/rage/pubsub/adapters/redis.rb +2 -1
  34. data/lib/rage/router/constrainer.rb +1 -1
  35. data/lib/rage/router/dsl.rb +7 -2
  36. data/lib/rage/sse/application.rb +1 -0
  37. data/lib/rage/telemetry/tracer.rb +1 -0
  38. data/lib/rage/version.rb +1 -1
  39. data/lib/rage-rb.rb +6 -0
  40. metadata +6 -4
  41. data/lib/rage/cable/adapters/base.rb +0 -16
  42. data/lib/rage/cable/adapters/redis.rb +0 -128
@@ -219,6 +219,14 @@ class Rage::Configuration
219
219
  end
220
220
  # @!endgroup
221
221
 
222
+ # @!group Error Reporter Configuration
223
+ # Allows configuring error reporters.
224
+ # @return [Rage::Configuration::ErrorReporters]
225
+ def error_reporters
226
+ @error_reporters ||= ErrorReporters.new
227
+ end
228
+ # @!endgroup
229
+
222
230
  # @!group OpenAPI Configuration
223
231
  # Allows configuring OpenAPI settings.
224
232
  # @return [Rage::Configuration::OpenAPI]
@@ -265,6 +273,22 @@ class Rage::Configuration
265
273
  end
266
274
  # @!endgroup
267
275
 
276
+ # @!group Router Configuration
277
+ # Allows configuring router settings.
278
+ # @return [Rage::Configuration::Router]
279
+ def router
280
+ @router ||= Router.new
281
+ end
282
+ # @!endgroup
283
+
284
+ # @!group Blocking Operation Pool Configuration
285
+ # Allows configuring the thread pool for offloading native calls.
286
+ # @return [Rage::Configuration::BlockingOperationPool]
287
+ def blocking_operation_pool
288
+ @blocking_operation_pool ||= BlockingOperationPool.new
289
+ end
290
+ # @!endgroup
291
+
268
292
  # @private
269
293
  def pubsub
270
294
  @pubsub ||= PubSub.new
@@ -278,7 +302,7 @@ class Rage::Configuration
278
302
  # @private
279
303
  def run_after_initialize!
280
304
  run_hooks_for!(:after_initialize, self)
281
- __finalize
305
+ __finalize(true)
282
306
  end
283
307
 
284
308
  class LogContext
@@ -378,6 +402,60 @@ class Rage::Configuration
378
402
  end
379
403
  end
380
404
 
405
+ class ErrorReporters
406
+ # @private
407
+ def initialize
408
+ @objects = []
409
+ end
410
+
411
+ # @private
412
+ def objects
413
+ @objects.dup
414
+ end
415
+
416
+ # Add a new error reporter.
417
+ # Error reporters should respond to `#call` and accept one of:
418
+ # - `call(exception)`
419
+ # - `call(exception, context: {})`
420
+ #
421
+ # @param reporter [#call]
422
+ # @return [self]
423
+ # @example
424
+ # Rage.configure do
425
+ # config.error_reporters << SentryReporter.new
426
+ # end
427
+ def <<(reporter)
428
+ validate_input!(reporter)
429
+ return self if @objects.include?(reporter)
430
+
431
+ @objects << reporter
432
+ Rage::Errors.__send__(:__register_reporter, reporter)
433
+
434
+ self
435
+ end
436
+
437
+ alias_method :push, :<<
438
+
439
+ # Remove an error reporter.
440
+ # @param reporter [#call] the reporter to remove
441
+ # @example
442
+ # reporter = SentryReporter.new
443
+ # Rage.configure do
444
+ # config.error_reporters.delete(reporter)
445
+ # end
446
+ def delete(reporter)
447
+ deleted = @objects.delete(reporter)
448
+ Rage::Errors.__send__(:__unregister_reporter, reporter) if deleted
449
+ deleted
450
+ end
451
+
452
+ private
453
+
454
+ def validate_input!(reporter)
455
+ raise ArgumentError, "error reporter must respond to #call" unless reporter.respond_to?(:call)
456
+ end
457
+ end
458
+
381
459
  class Server
382
460
  # @!attribute port
383
461
  # Specify the port the server will listen on.
@@ -632,33 +710,6 @@ class Rage::Configuration
632
710
  end
633
711
  end
634
712
  end
635
-
636
- # @private
637
- def config
638
- @config ||= begin
639
- config_file = Rage.root.join("config/cable.yml")
640
-
641
- if config_file.exist?
642
- yaml = ERB.new(config_file.read).result
643
- YAML.safe_load(yaml, aliases: true, symbolize_names: true)[Rage.env.to_sym] || {}
644
- else
645
- {}
646
- end
647
- end
648
- end
649
-
650
- # @private
651
- def adapter_config
652
- config.except(:adapter)
653
- end
654
-
655
- # @private
656
- def adapter
657
- case config[:adapter]
658
- when "redis"
659
- Rage::Cable::Adapters::Redis.new(adapter_config)
660
- end
661
- end
662
713
  end
663
714
 
664
715
  class PublicFileServer
@@ -707,6 +758,46 @@ class Rage::Configuration
707
758
  # @private
708
759
  def initialize
709
760
  @configured = false
761
+ @schedule_blocks = []
762
+ end
763
+
764
+ # Schedule a periodic task to run at a fixed interval.
765
+ # @example
766
+ # Rage.configure do
767
+ # config.deferred.schedule do
768
+ # every 5.minutes, task: ClearCache
769
+ # end
770
+ # end
771
+ def schedule(&block)
772
+ @schedule_blocks << block
773
+ end
774
+
775
+ # @private
776
+ # Evaluates all stored schedule blocks and returns the collected tasks.
777
+ # Called at boot time after all app constants are loaded.
778
+ def scheduled_tasks
779
+ @schedule_blocks.flat_map do |block|
780
+ dsl = ScheduleDSL.new
781
+ dsl.instance_eval(&block)
782
+ dsl.tasks
783
+ end
784
+ end
785
+
786
+ # @private
787
+ class ScheduleDSL
788
+ attr_reader :tasks
789
+
790
+ def initialize
791
+ @tasks = []
792
+ end
793
+
794
+ # Registers a task to run on a fixed interval (in seconds)
795
+ def every(interval, task:)
796
+ unless task.is_a?(Class) && task.include?(Rage::Deferred::Task)
797
+ raise ArgumentError, "#{task} must be a class that includes Rage::Deferred::Task"
798
+ end
799
+ @tasks << { interval:, task: }
800
+ end
710
801
  end
711
802
 
712
803
  # Returns the backend instance used by `Rage::Deferred`.
@@ -988,6 +1079,42 @@ class Rage::Configuration
988
1079
  attr_accessor :key
989
1080
  end
990
1081
 
1082
+ class Router
1083
+ # @!attribute form_actions
1084
+ # Enable the automatic generation of `new` and `edit` routes via resource helpers.
1085
+ # @return [Boolean]
1086
+ # @example Enable form actions
1087
+ # Rage.configure do
1088
+ # config.router.form_actions = true
1089
+ # end
1090
+ attr_accessor :form_actions
1091
+ end
1092
+
1093
+ class BlockingOperationPool
1094
+ # @!attribute enabled
1095
+ # Enable a background thread pool for offloading native calls that can be executed outside the GVL, freeing
1096
+ # the main server thread to continue processing other requests. This acts as a preemption mechanism: native calls
1097
+ # won't stall requests, as the OS will context-switch between the server thread and the worker threads.
1098
+ # Defaults to `false`.
1099
+ # @return [Boolean]
1100
+ # @example Enable the thread pool
1101
+ # Rage.configure do
1102
+ # config.blocking_operation_pool.enabled = true
1103
+ # end
1104
+ #
1105
+ # @!attribute size
1106
+ # Specify the number of threads in the pool. Defaults to `1`. A single thread is sufficient in most cases
1107
+ # because the pool's goal is context switching, not parallelization.
1108
+ # @return [Integer]
1109
+ attr_accessor :enabled, :size
1110
+
1111
+ # @private
1112
+ def initialize
1113
+ @enabled = false
1114
+ @size = 1
1115
+ end
1116
+ end
1117
+
991
1118
  # @private
992
1119
  class PubSub
993
1120
  attr_reader :adapter
@@ -1004,6 +1131,7 @@ class Rage::Configuration
1004
1131
  def config
1005
1132
  @config ||= begin
1006
1133
  config_file = Rage.root.join("config/pubsub.yml")
1134
+ config_file = Rage.root.join("config/cable.yml") unless config_file.exist?
1007
1135
 
1008
1136
  config = if config_file.exist?
1009
1137
  yaml = ERB.new(config_file.read).result
@@ -1053,7 +1181,7 @@ class Rage::Configuration
1053
1181
  end
1054
1182
 
1055
1183
  # @private
1056
- def __finalize
1184
+ def __finalize(before_boot = false)
1057
1185
  if @logger
1058
1186
  @logger.formatter = @log_formatter if @log_formatter
1059
1187
  @logger.level = @log_level if @log_level
@@ -1075,6 +1203,15 @@ class Rage::Configuration
1075
1203
  @logger.dynamic_tags = Rage.__log_processor.dynamic_tags
1076
1204
  end
1077
1205
 
1206
+ if before_boot && @blocking_operation_pool&.enabled
1207
+ if defined?(Rage::FiberScheduler::BlockingOperationWait)
1208
+ Iodine.on_state(:pre_start) { puts "INFO: Using blocking operation pool" }
1209
+ Rage::FiberScheduler.include(Rage::FiberScheduler::BlockingOperationWait)
1210
+ else
1211
+ puts "WARNING: Blocking operation pool is not supported on Ruby #{RUBY_VERSION}"
1212
+ end
1213
+ end
1214
+
1078
1215
  if defined?(::Rack::Events) && middleware.include?(::Rack::Events)
1079
1216
  middleware.delete(Rage::BodyFinalizer)
1080
1217
  middleware.insert_before(::Rack::Events, Rage::BodyFinalizer)
@@ -185,32 +185,6 @@ class RageController::API
185
185
  klass.__wrap_parameters_options = __wrap_parameters_options
186
186
  end
187
187
 
188
- # @private
189
- @@__dynamic_name_seed = ("a".."i").to_a.permutation
190
-
191
- # @private
192
- # define a method based on a block
193
- def define_dynamic_method(block)
194
- name = @@__dynamic_name_seed.next.join
195
- define_method("__rage_dynamic_#{name}", block)
196
- end
197
-
198
- # @private
199
- # define a method that will call a specified method if a condition is `true` or yield if `false`
200
- def define_maybe_yield(method_name)
201
- name = @@__dynamic_name_seed.next.join
202
-
203
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
204
- def __rage_dynamic_#{name}(condition)
205
- if condition
206
- #{method_name} { yield }
207
- else
208
- yield
209
- end
210
- end
211
- RUBY
212
- end
213
-
214
188
  # @private
215
189
  def __register_renderer(name, block)
216
190
  prepend(RageController::Renderers) unless ancestors.include?(RageController::Renderers)
@@ -240,7 +214,7 @@ class RageController::API
240
214
  def rescue_from(*klasses, with: nil, &block)
241
215
  unless with
242
216
  if block_given?
243
- with = define_dynamic_method(block)
217
+ with = Rage::Internal.define_dynamic_method(self, block)
244
218
  else
245
219
  raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
246
220
  end
@@ -314,7 +288,7 @@ class RageController::API
314
288
  # end
315
289
  def around_action(action_name = nil, **opts, &block)
316
290
  action = prepare_action_params(action_name, **opts, &block)
317
- action.merge!(around: true, wrapper: define_maybe_yield(action[:name]))
291
+ action.merge!(around: true, wrapper: Rage::Internal.define_maybe_yield(self, action[:name]))
318
292
 
319
293
  if @__before_actions && @__before_actions.frozen?
320
294
  @__before_actions = @__before_actions.dup
@@ -429,7 +403,7 @@ class RageController::API
429
403
  # used by `before_action` and `after_action`
430
404
  def prepare_action_params(action_name = nil, **opts, &block)
431
405
  if block_given?
432
- action_name = define_dynamic_method(block)
406
+ action_name = Rage::Internal.define_dynamic_method(self, block)
433
407
  elsif action_name.nil?
434
408
  raise ArgumentError, "No handler provided. Pass the `action_name` parameter or provide a block."
435
409
  end
@@ -444,8 +418,8 @@ class RageController::API
444
418
  unless: _unless
445
419
  }
446
420
 
447
- action[:if] = define_dynamic_method(action[:if]) if action[:if].is_a?(Proc)
448
- action[:unless] = define_dynamic_method(action[:unless]) if action[:unless].is_a?(Proc)
421
+ action[:if] = Rage::Internal.define_dynamic_method(self, action[:if]) if action[:if].is_a?(Proc)
422
+ action[:unless] = Rage::Internal.define_dynamic_method(self, action[:unless]) if action[:unless].is_a?(Proc)
449
423
 
450
424
  action
451
425
  end
@@ -584,13 +558,13 @@ class RageController::API
584
558
  def authenticate_with_http_token
585
559
  auth_header = @__env["HTTP_AUTHORIZATION"]
586
560
 
587
- payload = if auth_header&.start_with?("Bearer")
561
+ payload = if auth_header&.start_with?("Bearer ")
588
562
  auth_header[7..]
589
- elsif auth_header&.start_with?("Token")
563
+ elsif auth_header&.start_with?("Token ")
590
564
  auth_header[6..]
591
565
  end
592
566
 
593
- return unless payload
567
+ return if payload.nil? || payload.empty?
594
568
 
595
569
  token = if payload.start_with?("token=")
596
570
  payload[6..]
@@ -601,6 +575,8 @@ class RageController::API
601
575
  token.delete_prefix!('"')
602
576
  token.delete_suffix!('"')
603
577
 
578
+ return if token.empty?
579
+
604
580
  yield token
605
581
  end
606
582
 
data/lib/rage/cookies.rb CHANGED
@@ -201,7 +201,7 @@ class Rage::Cookies
201
201
  end
202
202
 
203
203
  if (domain = value[:domain])
204
- host = @env["HTTP_HOST"]
204
+ host = @env["HTTP_HOST"]&.sub(/:\d+\z/, "")
205
205
 
206
206
  processed_domain = if domain.is_a?(String)
207
207
  domain
@@ -82,10 +82,16 @@ module Rage::Deferred
82
82
  )
83
83
  end
84
84
 
85
+ # @private
86
+ def self.__start_scheduler
87
+ Rage::Deferred::Scheduler.start(Rage.config.deferred.scheduled_tasks)
88
+ end
89
+
85
90
  # @private
86
91
  def self.__initialize
87
92
  __middleware_chain
88
93
  __load_tasks
94
+ __start_scheduler
89
95
  end
90
96
 
91
97
  module Backends
@@ -96,6 +102,7 @@ module Rage::Deferred
96
102
  end
97
103
 
98
104
  require_relative "task"
105
+ require_relative "scheduler"
99
106
  require_relative "queue"
100
107
  require_relative "proxy"
101
108
  require_relative "context"
@@ -30,6 +30,14 @@ class Rage::Deferred::Metadata
30
30
  !!task.__next_retry_in(attempts, nil)
31
31
  end
32
32
 
33
+ # Returns the number of seconds until the next retry, or `nil` if no retry will occur.
34
+ # The result is memoized per attempt so that the value reported here matches what the queue uses to schedule the retry.
35
+ # @return [Numeric, nil] retry delay in seconds, or `nil` if the task won't be retried
36
+ def will_retry_in
37
+ task = Rage::Deferred::Context.get_task(context)
38
+ task.__next_retry_in(attempts, nil)
39
+ end
40
+
33
41
  private
34
42
 
35
43
  def context
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ class Rage::Deferred::Scheduler
5
+ def self.start(tasks)
6
+ return if tasks.empty?
7
+
8
+ Rage::Internal.pick_a_worker(purpose: "deferred-scheduler") do
9
+ puts("INFO: #{Process.pid} is managing scheduled tasks.") if Rage.logger.info?
10
+ register_timers(tasks)
11
+ end
12
+ end
13
+
14
+ def self.register_timers(tasks)
15
+ tasks.each do |entry|
16
+ interval = (entry[:interval] * 1000).to_i
17
+
18
+ if Rage.env.development?
19
+ Iodine.run_every(interval) { Object.const_get(entry[:task].name).enqueue }
20
+ else
21
+ Iodine.run_every(interval) { entry[:task].enqueue }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -31,15 +31,16 @@
31
31
  # ```
32
32
  #
33
33
  module Rage::Deferred::Task
34
- MAX_ATTEMPTS = 5
34
+ MAX_ATTEMPTS = 20
35
35
  private_constant :MAX_ATTEMPTS
36
36
 
37
- BACKOFF_INTERVAL = 5
38
- private_constant :BACKOFF_INTERVAL
39
-
40
37
  # @private
41
38
  CONTEXT_KEY = :__rage_deferred_execution_context
42
39
 
40
+ # @private
41
+ RETRY_IN_CACHE_VAR = :@__rage_deferred_retry_in
42
+ private_constant :RETRY_IN_CACHE_VAR
43
+
43
44
  def perform
44
45
  end
45
46
 
@@ -80,6 +81,8 @@ module Rage::Deferred::Task
80
81
 
81
82
  true
82
83
  rescue Exception => e
84
+ Rage::Errors.report(e)
85
+
83
86
  unless respond_to?(:__deferred_suppress_exception_logging?, true) && __deferred_suppress_exception_logging?
84
87
  Rage.logger.with_context(task_log_context) do
85
88
  Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
@@ -175,6 +178,17 @@ module Rage::Deferred::Task
175
178
 
176
179
  # @private
177
180
  def __next_retry_in(attempts, exception)
181
+ f = Fiber.current
182
+
183
+ if f.instance_variable_defined?(RETRY_IN_CACHE_VAR)
184
+ f.instance_variable_get(RETRY_IN_CACHE_VAR)
185
+ else
186
+ f.instance_variable_set(RETRY_IN_CACHE_VAR, __calculate_next_retry_in(attempts, exception))
187
+ end
188
+ end
189
+
190
+ # @private
191
+ def __calculate_next_retry_in(attempts, exception)
178
192
  max = @__max_retries || MAX_ATTEMPTS
179
193
  return if attempts > max
180
194
 
@@ -191,7 +205,7 @@ module Rage::Deferred::Task
191
205
 
192
206
  # @private
193
207
  def __default_backoff(attempt)
194
- rand(BACKOFF_INTERVAL * 2**attempt) + 1
208
+ (attempt**4) + 10 + (rand(15) * attempt)
195
209
  end
196
210
  end
197
211
  end
data/lib/rage/errors.rb CHANGED
@@ -1,4 +1,87 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rage::Errors
4
+ ReporterEntry = Struct.new(:reporter, :method_name)
5
+ private_constant :ReporterEntry
6
+
7
+ @reporters = []
8
+ @next_reporter_id = 0
9
+
10
+ class << self
11
+ # Forward an exception to all registered reporters.
12
+ #
13
+ # @param exception [Exception]
14
+ # @param context [Hash]
15
+ # @return [nil]
16
+ def report(exception, context: {})
17
+ return if @reporters.empty?
18
+ return if exception.instance_variable_defined?(:@_rage_error_reported)
19
+
20
+ ensure_backtrace(exception)
21
+
22
+ @reporters.each do |entry|
23
+ __send__(entry.method_name, entry.reporter, exception, context)
24
+ rescue => e
25
+ Rage.logger.error("Error reporter #{entry.reporter.class} failed while reporting #{exception.class}: #{e.class} (#{e.message})")
26
+ end
27
+
28
+ exception.instance_variable_set(:@_rage_error_reported, true) unless exception.frozen?
29
+
30
+ nil
31
+ end
32
+
33
+ # @private
34
+ def __register_reporter(reporter)
35
+ raise ArgumentError, "error reporter must respond to #call" unless reporter.respond_to?(:call)
36
+
37
+ reporter_id = @next_reporter_id
38
+ @next_reporter_id += 1
39
+ method_name = :"__report_#{reporter_id}"
40
+
41
+ arguments = Rage::Internal.build_arguments(
42
+ reporter.method(:call),
43
+ { context: "context" }
44
+ )
45
+ call_arguments = arguments.empty? ? "" : ", #{arguments}"
46
+
47
+ singleton_class.class_eval <<~RUBY, __FILE__, __LINE__ + 1
48
+ def #{method_name}(reporter, exception, context)
49
+ reporter.call(exception#{call_arguments})
50
+ end
51
+ RUBY
52
+
53
+ @reporters << ReporterEntry.new(reporter, method_name)
54
+
55
+ self
56
+ end
57
+
58
+ # @private
59
+ def __unregister_reporter(reporter)
60
+ @reporters.delete_if do |entry|
61
+ next false unless entry.reporter == reporter
62
+
63
+ singleton_class.remove_method(entry.method_name) if singleton_class.method_defined?(entry.method_name)
64
+ true
65
+ end
66
+
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ def ensure_backtrace(exception)
73
+ return if exception.frozen?
74
+ return unless exception.backtrace.nil?
75
+
76
+ begin
77
+ raise exception
78
+ rescue exception.class
79
+ end
80
+ end
81
+
82
+ private :__register_reporter, :__unregister_reporter
83
+ end
84
+
2
85
  class BadRequest < StandardError
3
86
  end
4
87
 
@@ -90,7 +90,12 @@ module Rage::Events::Subscriber
90
90
  Rage.logger.with_context(self.class.__log_context) do
91
91
  Rage.logger.error("Subscriber failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
92
92
  end
93
- raise e if self.class.__is_deferred
93
+
94
+ if self.class.__is_deferred
95
+ raise e
96
+ else
97
+ Rage::Errors.report(e)
98
+ end
94
99
  end
95
100
 
96
101
  private
data/lib/rage/fiber.rb CHANGED
@@ -111,23 +111,7 @@ class Fiber
111
111
  end
112
112
 
113
113
  # @private
114
- def __block_channel(force = false)
115
- @__block_channel_i ||= 0
116
- @__block_channel_i += 1 if force
117
-
118
- "block:#{object_id}:#{@__block_channel_i}"
119
- end
120
-
121
- # @private
122
- def __await_channel(force = false)
123
- @__fiber_channel_i ||= 0
124
- @__fiber_channel_i += 1 if force
125
-
126
- "await:#{object_id}:#{@__fiber_channel_i}"
127
- end
128
-
129
- # @private
130
- attr_accessor :__awaited_fileno
114
+ attr_accessor :__awaited_fileno, :__wait_generation, :__block_channel, :__await_channel
131
115
 
132
116
  # @private
133
117
  # pause a fiber and resume in the next iteration of the event loop
@@ -156,7 +140,10 @@ class Fiber
156
140
  # @note This method should only be used when multiple fibers have to be processed in parallel. There's no need to use `Fiber.await` for single IO calls.
157
141
  def self.await(fibers)
158
142
  f, fibers = Fiber.current, Array(fibers)
159
- await_channel = f.__await_channel(true)
143
+
144
+ f.__wait_generation ||= 0
145
+ gen = (f.__wait_generation += 1)
146
+ channel = f.__await_channel = "await:#{f.object_id}:#{gen}"
160
147
 
161
148
  Rage::Telemetry.tracer.span_core_fiber_await(fibers:) do
162
149
  # check which fibers are alive (i.e. have yielded) and which have errored out
@@ -179,17 +166,21 @@ class Fiber
179
166
  end
180
167
 
181
168
  # wait on async fibers; resume right away if one of the fibers errors out
182
- Iodine.subscribe(await_channel) do |_, err|
183
- if err == AWAIT_ERROR_MESSAGE
184
- f.resume
169
+ Iodine.subscribe(channel) do |_, err|
170
+ done = if err == AWAIT_ERROR_MESSAGE
171
+ true
185
172
  else
186
173
  num_wait_for -= 1
187
- f.resume if num_wait_for == 0
174
+ num_wait_for == 0
175
+ end
176
+
177
+ if done
178
+ Iodine.defer { Iodine.unsubscribe(channel) }
179
+ f.resume if f.alive? && gen == f.__wait_generation
188
180
  end
189
181
  end
190
182
 
191
183
  Fiber.defer(-1)
192
- Iodine.defer { Iodine.unsubscribe(await_channel) }
193
184
 
194
185
  # if num_wait_for is not 0 means we exited prematurely because of an error
195
186
  if num_wait_for > 0