farce 0.2.0 → 0.3.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.
@@ -4,108 +4,554 @@
4
4
 
5
5
  module Farce
6
6
  module Abstract
7
- # @abstract A shared super class for all scheduler implementations.
7
+ # A task scheduler for one worker or an installed fiber scheduler.
8
8
  #
9
- # @!method schedule(*args, mode: :smart, auto_local: true)
10
- # @abstract This method must be implemented by concrete scheduler subclasses.
9
+ # Use {.create} to start a worker, or install an instance on the current
10
+ # thread with `Fiber.set_scheduler`. {#close} rejects new submissions and
11
+ # drains queued tasks. A scheduler can only be launched or installed once.
11
12
  #
12
- # Schedules a block for execution.
13
- # What actually happens depends on the concrete scheduler implementation.
14
- # Some schedulers may ignore the `auto_local` option. Some might treat every mode as `:local`.
15
- #
16
- # The scheduler may choose to block until the task has been executed, but most will add it to a queue of pending
17
- # tasks, only blocking when that queue's capacity has been reached.
18
- #
19
- # @!macro modes
20
- #
21
- # @param args [Array]
22
- # Arguments to pass to the scheduled block.
23
- # May be handled according to `mode`.
24
- #
25
- # @param mode [Symbol]
26
- # The mode in which to schedule the task. Defaults to `:smart`.
27
- # May be ignored by some schedulers.
28
- #
29
- # @param auto_local [Boolean]
30
- # Whether to automatically use a local mode when possible. Defaults to `true`.
31
- # May be ignored by some schedulers.
32
- #
33
- # @yield [*args] The block to be executed by the scheduler.
34
- # @yieldparam args [Array] The arguments passed to the block.
35
- # @yieldreceiver [BasicObject, nil]
36
- # Local mode will not touch the block's binding (self stays the same), but other modes may change it to `nil`.
37
- #
38
- # @return [self]
39
- #
40
- # @!method close
41
- # @abstract This method must be implemented by concrete scheduler subclasses.
42
- # Closes the scheduler, releasing any resources it holds.
43
- # Once closed, the scheduler cannot be used to schedule new tasks.
44
- # Will drain any pending tasks before fully closing. If tasks are running indefinitely, it may block.
45
- # @return [self]
46
- #
47
- # @!method closed?
48
- # @abstract This method must be implemented by concrete scheduler subclasses.
49
- # @return [Boolean] Whether the scheduler is closed.
50
- #
51
- # @!method state
52
- # @abstract This method must be implemented by concrete scheduler subclasses.
53
- # @return [Symbol]
54
- # The current state of the scheduler.
55
- #
56
- # Common states include:
57
- # * `:initialized`, `:launching` and `:setup` for the setup phase.
58
- # * `:running` for the execution phase.
59
- # * `:closing`, `:closed`, and `:error` for the termination phase.
60
- #
61
- # Not all schedulers will implement every state.
62
- #
63
- # @!method ractor_safe?
64
- # @abstract Schedulers should include {Shareable} or {Unshareable}, which implement this method.
65
- # @return [Boolean] Whether the scheduler is safe to use across Ractors.
66
- class Scheduler
67
- include Internal::Noncopyable
68
-
69
- # An error that occurred during the scheduler's operation, if any.
70
- # This is primarily for scheduling errors, not execution errors.
71
- # You can check this if the {#state} is `:error`.
72
- def error = nil
73
-
74
- # @param wait [Boolean] Whether to wait for the owner to be non-nil before checking if the scheduler is local.
75
- # @return [Boolean] Whether the scheduler is local for the current Ractor.
76
- def local?(wait: true) = false # rubocop:disable Lint/UnusedMethodArgument
77
-
78
- # Like {#schedule}, with two differences:
79
- # 1. If {#local?} returns true, and it would run in local mode (either due to `auto_local` or explicitly set
80
- # `mode: :local`), it executes the block directly without enqueuing it.
81
- # 2. It blocks until the block has been executed, either immediately in local mode or after being scheduled.
82
- #
83
- # @param (see #schedule)
84
- # @return [self]
85
- def execute(*, mode: :smart, auto_local: true, &callback)
86
- raise LocalJumpError, "Cannot yield without a block" unless block_given?
87
- raise SchedulerClosedError, "cannot execute task on a closed scheduler" if closed?
88
-
89
- if (auto_local || mode == :local) && local?
90
- raise SchedulerClosedError, "cannot execute task on a closed scheduler" if closed?
91
- yield(*)
92
- return self
13
+ # @abstract Use {Farce::Scheduler}, {Unshared::Scheduler}, or {Local::Scheduler}.
14
+ class Scheduler < Executor
15
+ include Internal::Inspect
16
+
17
+ CLOSED_STATES = ::Set[:closed, :closing, :error].freeze
18
+ private_constant :CLOSED_STATES
19
+
20
+ # Returns a Farce scheduler for the current thread's installed fiber scheduler.
21
+ # Returns `nil` when no fiber scheduler is installed.
22
+ #
23
+ # Creates and installs a wrapper when necessary, starting its dispatcher on
24
+ # that scheduler.
25
+ #
26
+ # Additional keyword arguments are forwarded to {#initialize} when wrapping.
27
+ #
28
+ # @example Create a scheduler for Async, and send it to a different Ractor
29
+ # require "async"
30
+ # require "farce"
31
+ #
32
+ # # No fiber scheduler set here
33
+ # Farce::Scheduler.current # => nil
34
+ #
35
+ # Async do
36
+ # # A scheduler that will hand tasks to Async::Scheduler
37
+ # scheduler = Farce::Scheduler.current # => #<Farce::Scheduler>
38
+ #
39
+ # # This looks like doing the same as nested Async { ... } calls or
40
+ # # Fiber.schedule { ... }, …
41
+ # scheduler.schedule { puts "Scheduled from the main Ractor" }
42
+ #
43
+ # # … but it can be called from other threads and ractors
44
+ # Ractor.new(scheduler) do |scheduler|
45
+ # scheduler.schedule { puts "Scheduled from a background Ractor" }
46
+ # end
47
+ # end
48
+ #
49
+ # @example Full circle
50
+ # scheduler = Farce::Scheduler.new
51
+ # Farce::Scheduler.current # => nil
52
+ # Fiber.set_scheduler(scheduler)
53
+ # Farce::Scheduler.current == scheduler # => true
54
+ # @return [Scheduler, nil]
55
+ def self.current(**)
56
+ return unless Fiber.respond_to?(:scheduler)
57
+ return unless scheduler = Fiber.scheduler
58
+ register = Internal::Storage.store_if_absent(self) { Internal::Storage.new }
59
+ register.store_if_absent(scheduler) do
60
+ wrapper = new(**, external: true)
61
+ wrapper.__send__(:set_scheduler!, scheduler:)
62
+ wrapper
63
+ end
64
+ end
65
+
66
+ # Creates a new scheduler and starts it in a background ractor, thread, or custom executor.
67
+ #
68
+ # @overload create(executor = nil, name: nil, priority: nil, capacity: 1024, backend: Farce.config.io_backend)
69
+ # Runs tasks on the configured fiber scheduler. The backend option selects
70
+ # the IO driver when using Farce's built-in scheduler.
71
+ #
72
+ # @example
73
+ # scheduler = Farce::Scheduler.create
74
+ # scheduler.schedule { puts "Running on the built-in scheduler" }
75
+ #
76
+ # @param executor [Class, nil] Ractor or Thread, nil selects the runtime default.
77
+ # @param name [String, nil] Optional worker name.
78
+ # @param priority [Integer, nil] Optional worker thread priority.
79
+ # @param capacity [Integer, nil] Pending-task capacity, nil creates an unbounded queue.
80
+ # @param backend [Symbol] IO backend for the built-in scheduler, defaults to Farce configuration.
81
+ # @return [Scheduler]
82
+ #
83
+ # @overload create(executor = nil, name: nil, priority: nil, capacity: 1024, &block)
84
+ # Runs tasks on a fiber scheduler supplied by your block. Use this to send
85
+ # work through Farce to another library's event loop, such as Async.
86
+ #
87
+ # Construct and return the scheduler inside the block: Farce calls it once
88
+ # in the worker, where that event loop will run, and installs the result.
89
+ # The block configures the scheduler. Submit actual tasks with {#schedule}.
90
+ # Configure any IO backend on the returned scheduler itself. Farce's backend
91
+ # option is ignored when a block is supplied.
92
+ #
93
+ # {Farce::Scheduler} requires a Ractor-shareable block. Require the library before calling
94
+ # create and avoid capturing a scheduler created on another thread or Ractor.
95
+ #
96
+ # @example Run tasks on Async in a worker thread
97
+ # require "async"
98
+ # require "farce"
99
+ #
100
+ # # Using Thread as an executor as at the moment Async cannot run on a non-main Ractor
101
+ # scheduler = Farce::Scheduler.create(Thread) { Async::Scheduler.new }
102
+ # scheduler.schedule { puts "Running on Async" }
103
+ #
104
+ # @example Run tasks on CarbonFiber in a worker ractor
105
+ # require "carbon_fiber"
106
+ # require "farce"
107
+ #
108
+ # scheduler = Farce::Scheduler.create(Ractor) { CarbonFiber::Scheduler.new }
109
+ # scheduler.schedule { puts "Running on CarbonFiber" }
110
+ #
111
+ # @param executor [Class, nil]
112
+ # Ractor or Thread. `nil` selects the runtime default.
113
+ # Choose an executor supported by the supplied scheduler and its dependencies.
114
+ # @param name [String, nil] Optional worker name.
115
+ # @param priority [Integer, nil] Optional worker thread priority.
116
+ # @param capacity [Integer, nil] Pending-task capacity, nil creates an unbounded queue.
117
+ # @yieldreturn [Object] The fiber scheduler to install in the worker.
118
+ # @return [Scheduler]
119
+ # @see #launch
120
+ def self.create(executor = nil, name: nil, priority: nil, **, &)
121
+ new(**, &).tap { it.launch(executor, name:, priority:) }
122
+ end
123
+
124
+ # Creates a scheduler without starting a worker. Use this instead of {.create}
125
+ # when you want to launch it later with {#launch}, or install it on the current
126
+ # thread with `Fiber.set_scheduler(scheduler)`.
127
+ #
128
+ # @overload initialize(capacity: 1024, backend: Farce.config.io_backend)
129
+ # Uses the configured fiber scheduler when launched or installed.
130
+ #
131
+ # @param capacity [Integer, nil] Pending-task capacity, nil creates an unbounded queue.
132
+ # @param backend [Symbol] IO backend for the built-in scheduler, defaults to Farce configuration.
133
+ #
134
+ # @overload initialize(capacity: 1024, &block)
135
+ # Uses another library's fiber scheduler while retaining Farce's ability to
136
+ # accept tasks from other threads and Ractors.
137
+ #
138
+ # Supply a block that constructs and returns that scheduler. The block runs
139
+ # once when this handle is launched or installed, in the thread that will
140
+ # run the tasks, rather than during new. {Farce::Scheduler} requires a
141
+ # Ractor-shareable block. {Unshared::Scheduler} may capture local state.
142
+ # Farce installs its result. Submit tasks separately with {#schedule}.
143
+ # Configure the supplied scheduler in the block. Farce's backend option is
144
+ # ignored for this overload.
145
+ #
146
+ # @example Choose Async, then launch the worker later
147
+ # require "async"
148
+ # require "farce"
149
+ #
150
+ # scheduler = Farce::Scheduler.new { Async::Scheduler.new }
151
+ # worker = scheduler.launch_thread
152
+ # scheduler.schedule { puts "Running on Async" }
153
+ #
154
+ # @param capacity [Integer, nil] Pending-task capacity, nil creates an unbounded queue.
155
+ # @yieldreturn [Object] The fiber scheduler to install when launched or installed.
156
+ def initialize(capacity: 1024, backend: Internal::FROZEN_CONFIG.io_backend, queue: nil, external: false,
157
+ pool_worker: nil, queue_owner: true, &constructor)
158
+ constructor ||= Internal::FROZEN_CONFIG.fiber_scheduler_constructor
159
+ @capacity = capacity ? Integer(capacity) : nil
160
+ @backend = backend
161
+ @constructor = prepare_constructor(constructor)
162
+ @external = external || !constructor.nil?
163
+ @owner = Internal::Atom.new(nil)
164
+ @state = Internal::Atom.new(:initialized)
165
+ @error = Internal::Atom.new
166
+ @queue = queue || new_queue(capacity: @capacity)
167
+ @pool_worker = pool_worker
168
+ @queue_owner = queue_owner
169
+ super()
170
+ end
171
+
172
+ # @return [Exception, nil] The error encountered by the scheduler, if any.
173
+ def error
174
+ value = @error.value
175
+ value.is_a?(Envelope) ? value.value : value
176
+ end
177
+
178
+ # @overload launch_ractor(name: nil, priority: nil)
179
+ # Launches a Ractor worker. Accepts the keyword arguments of {#launch}.
180
+ # @param name [String, nil] Optional worker name.
181
+ # @param priority [Integer, nil] Optional worker thread priority.
182
+ # @return [Ractor] The worker.
183
+ def launch_ractor(...) = launch(Ractor, ...)
184
+
185
+ # @overload launch_thread(name: nil, priority: nil)
186
+ # Launches a Thread worker. Accepts the keyword arguments of {#launch}.
187
+ # @param name [String, nil] Optional worker name.
188
+ # @param priority [Integer, nil] Optional worker thread priority.
189
+ # @return [Thread] The worker.
190
+ def launch_thread(...) = launch(Thread, ...)
191
+
192
+ # Starts dispatching tasks in a new worker and waits for setup to leave its
193
+ # launching/setup states before returning. Returns the created worker instance.
194
+ #
195
+ # @example Using the return value to assign a ThreadGroup
196
+ # scheduler = Farce::Scheduler.new
197
+ # worker = scheduler.launch_thread
198
+ # group = ThreadGroup.new
199
+ #
200
+ # group.add(worker)
201
+ # group.enclose
202
+ #
203
+ # @example Using a custom worker
204
+ # class MyWorker
205
+ # def initialize(...)
206
+ # # flip a coin whether we're using Ractor or Thread
207
+ # @worker = [Ractor, Thread].sample.new(...)
208
+ # end
209
+ #
210
+ # def join = @worker.join
211
+ # end
212
+ #
213
+ # scheduler = Farce::Scheduler.new
214
+ # scheduler.launch(MyWorker)
215
+ #
216
+ # @param executor [Class, nil]
217
+ # Ractor or Thread. Defaults to Ractor when native Ractors are available, otherwise Thread.
218
+ # @param name [String, nil] Optional worker name.
219
+ # @param priority [Integer, nil] Optional worker thread priority.
220
+ # @return [Ractor, Thread, Object] The worker.
221
+ # @raise [RuntimeError] If this handle has already been launched, installed, or closed.
222
+ def launch(executor = nil, name: nil, priority: nil)
223
+ raise "Scheduler may not be reused" unless @state.compare_and_set(:initialized, :launching)
224
+
225
+ executor ||= Internal.native_ractors? ? Ractor : Thread
226
+ priority &&= Integer(priority)
227
+ name &&= -String(name)
228
+ callback = launch_callback
229
+
230
+ return executor.new(self, priority, name, &callback) unless executor <= Ractor
231
+ # Requiring an autoloaded implementation from a new Ractor can deadlock
232
+ # while its launcher waits for setup to finish.
233
+ Internal.const_get(:FiberScheduler, false)
234
+ Internal.const_get(:Storage, false)
235
+ Internal.const_get(:ThreadPool, false)
236
+ executor.new(self, priority, name, name:, &callback)
237
+ rescue Exception => e # rubocop:disable Lint/RescueException
238
+ @state.value = :error
239
+ @error.value = Envelope.new(e)
240
+ raise
241
+ ensure
242
+ # if we return the thread too early, someone could kill it while another threads blocks on a schedule call
243
+ if System.windows? && executor <= Ractor
244
+ sleep 0.001 while @state.value == :launching
245
+ sleep 0.001 while @state.value == :setup
246
+ else
247
+ @state.wait_until_changed(:launching)
248
+ @state.wait_until_changed(:setup)
249
+ end
250
+ end
251
+
252
+ # @return [Ractor, nil] The owning Ractor, or nil before ownership is assigned.
253
+ def owner = @owner.value
254
+
255
+ # Assigns ownership during setup. An existing owner cannot be changed.
256
+ # This is used by schedule to determine if a task is scheduled locally or remotely.
257
+ #
258
+ # If {#schedule} is called without setting `auto_local` to false, it will block until the owner can be determined.
259
+ # Setting the owner explicitly avoids blocking on ownership determination.
260
+ #
261
+ # @example
262
+ # scheduler = Farce::Scheduler.new
263
+ #
264
+ # # Scheduler hasn't been launched, so it could block.
265
+ # scheduler.owner = Ractor.current
266
+ #
267
+ # # Now this doesn't block
268
+ # scheduler.schedule { some_task }
269
+ #
270
+ # # Can use #launch_thread, but #launch_ractor would fail now that the owner is set.
271
+ # scheduler.launch_thread
272
+ #
273
+ # @param value [Ractor, nil]
274
+ # @raise [ArgumentError] If value is not a Ractor/nil or changes an assigned owner.
275
+ def owner=(value)
276
+ raise ArgumentError, "owner must be a Ractor" unless value.nil? || value.is_a?(Ractor)
277
+ return if @owner.compare_and_set(nil, value)
278
+ return if owner == value
279
+ raise ArgumentError, "owner has already been set"
280
+ end
281
+
282
+ # @return [Boolean] Whether a fiber scheduler constructor was supplied.
283
+ def wraps_external? = @external
284
+
285
+ # Retrieves the constructed external scheduler from its owning Ractor.
286
+ # @return [Object, nil]
287
+ # The external scheduler, or nil before setup or when using the built-in scheduler.
288
+ # @raise [Ractor::IsolationError]
289
+ # If called outside the owning Ractor for an external scheduler.
290
+ def external_scheduler
291
+ return unless wraps_external?
292
+ return Internal::Storage[self] if owner.nil? || owner == Ractor.current
293
+ raise Ractor::IsolationError, "external_scheduler is owned by a different Ractor"
294
+ end
295
+
296
+ # Enqueues a task, waiting for queue space if necessary. Returns after
297
+ # submission. It does not wait for task completion or return the task's value.
298
+ #
299
+ # With `auto_local` enabled, waits for ownership to be assigned and uses `:local`
300
+ # mode whenever the caller and owner are in the same Ractor, including different
301
+ # threads in that Ractor.
302
+ #
303
+ # Local tasks retain their block and arguments.
304
+ #
305
+ # Otherwise, the block must be convertible to a Ractor-shareable proc.
306
+ # Pass task data as arguments so the selected transfer mode can be applied to it.
307
+ #
308
+ # @overload schedule(*args, mode: :smart, auto_local: true)
309
+ # @!macro modes
310
+ #
311
+ # @param args [Array<Object>] Positional arguments passed to the task block.
312
+ # @param mode [Symbol]
313
+ # Argument transfer mode from {Farce::MODES}, including `:copy`, `:marshal`, and `:local`.
314
+ # See {ModeManager#wrap}.
315
+ # @param auto_local [Boolean] Whether to override mode with `:local` in the owning Ractor.
316
+ # @yield [*args] The task to execute.
317
+ # @return [Scheduler] self.
318
+ # @raise [SchedulerClosedError] If the handle is closing, closed, or in an error state.
319
+ # @see Ractor.shareable_proc
320
+ def schedule(...) = enqueue_task(false, ...)
321
+
322
+ # Enqueues a task without waiting for queue space.
323
+ # Arguments and transfer modes follow {#schedule}. With `auto_local` enabled,
324
+ # it still waits for ownership to be assigned.
325
+ #
326
+ # @overload try_schedule(*args, mode: :smart, auto_local: true)
327
+ # @param (see Farce::Abstract::Executor#schedule)
328
+ # @return [self, nil] self when submitted, or nil when the queue is full.
329
+ def try_schedule(...) = enqueue_task(true, ...)
330
+
331
+ # (see Farce::Abstract::Executor#local?)
332
+ def local?(wait: true)
333
+ owner = wait ? @owner.wait_until_non_nil : @owner.value
334
+ owner == Ractor.current
335
+ end
336
+
337
+ # Requests shutdown and rejects further submissions. Repeated calls are harmless.
338
+ # The dispatcher drains queued tasks before closing the queue. This method
339
+ # does not wait for dispatch or running tasks to finish.
340
+ #
341
+ # This wakes a dispatcher blocked on an empty queue. It does not directly
342
+ # close the underlying fiber scheduler.
343
+ # @return [Scheduler] self.
344
+ def close
345
+ until closed?
346
+ state = @state.value
347
+ @state.compare_and_set(state, :closing) unless CLOSED_STATES.include?(state)
348
+ end
349
+ # if the scheduler never acquired an owner, #schedule might be blocked
350
+ @owner.compare_and_set(nil, Ractor.current)
351
+ @queue_owner ? @queue.seal : wake_dispatcher
352
+ self
353
+ end
354
+
355
+ # @return [Symbol]
356
+ # The current state of the scheduler. One of:
357
+ # * `:initialized`: The scheduler has been created but not yet launched.
358
+ # * `:launching`: The scheduler is in the process of being {#launch launched} on a background worker.
359
+ # * `:setup`: The scheduler is in the process of being setting up a fiber scheduler.
360
+ # * `:running`: The scheduler is actively dispatching tasks.
361
+ # * `:closing`: The scheduler has been requested to shut down and is draining its queue.
362
+ # * `:closed`: The scheduler has finished shutting down.
363
+ # * `:error`: The scheduler encountered an error during dispatch.
364
+ def state = @state.value
365
+
366
+ # @return [Boolean]
367
+ # Whether new submissions are rejected, including while
368
+ # closing or after a dispatch error.
369
+ # Does not imply all tasks have finished.
370
+ # @see #state
371
+ def closed? = CLOSED_STATES.include?(@state.value)
372
+
373
+ # @return [Boolean] Whether the dispatcher is in its `:running` state.
374
+ # @see #state
375
+ def alive? = @state.value == :running
376
+
377
+ # @api private
378
+ def inspect_with(inspector)
379
+ super do
380
+ external = Internal::Storage[self]
381
+ inspector.attribute(:state, state)
382
+ inspector.attribute(:backend, @backend)
383
+ inspector.attribute(:external_scheduler, external) if external && !external.is_a?(Internal::FiberScheduler)
384
+ inspector.attribute(:local, local?) if @owner.value
385
+ yield if block_given?
93
386
  end
387
+ end
94
388
 
95
- ran = Farce::Flag.new(false)
96
- signal = Signal.new
97
- callback = Ractor.shareable_proc(&callback) unless Ractor.shareable?(callback)
389
+ protected
98
390
 
99
- schedule(callback, ran, signal, *, mode:, auto_local:) do |callback, ran, signal, *args|
100
- callback.call(*args)
101
- ensure
102
- ran.set
103
- signal.broadcast
391
+ # @api private
392
+ def set_scheduler!(expected_state = :initialized, scheduler: nil)
393
+ setup_owner(expected_state)
394
+ scheduler ||= @constructor&.call || Internal::FiberScheduler.new(backend: @backend)
395
+ register = Internal::Storage.store_if_absent(self.class) { Internal::Storage.new }
396
+ register[scheduler] = self
397
+ Internal::Storage[self] = scheduler
398
+ Fiber.set_scheduler(scheduler) unless Fiber.scheduler.equal?(scheduler)
399
+ runnable_count = :farce_runnable_count if @pool_worker && scheduler.respond_to?(:farce_runnable_count)
400
+ scheduler.fiber do
401
+ dispatch(scheduler, runnable_count) do |task|
402
+ if @pool_worker
403
+ scheduler.fiber(&task.to_proc(@pool_worker))
404
+ else
405
+ scheduler.fiber(&task)
406
+ end
407
+ end
104
408
  end
409
+ rescue Internal::SchedulerLifecycle::Cancelled
410
+ raise
411
+ rescue Exception => e # rubocop:disable Lint/RescueException
412
+ unless @state.value == :error
413
+ @error.value = Envelope.new(e)
414
+ @state.value = :error
415
+ end
416
+ raise
417
+ end
105
418
 
106
- signal.wait_until { ran.value }
419
+ private
420
+
421
+ def enqueue_task(non_block, *args, mode: :smart, auto_local: true, &block)
422
+ raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if closed?
423
+
424
+ raise LocalJumpError, "Cannot schedule without a block" unless block
425
+
426
+ task = prepare_task(args, block, mode, auto_local)
427
+ return unless non_block ? @queue.try_push(task) : @queue.push(task)
107
428
  self
429
+ rescue ClosedQueueError
430
+ raise unless closed?
431
+ raise SchedulerClosedError, "cannot schedule task on a closed scheduler"
432
+ end
433
+
434
+ def new_queue(capacity:) = Internal::Queue.new(capacity:)
435
+
436
+ def prepare_task(args, block, mode, auto_local)
437
+ if auto_local
438
+ owner = @owner.wait_until_non_nil
439
+ raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if closed?
440
+ mode = :local if owner == Ractor.current
441
+ elsif mode == :local && (owner = @owner.value) && owner != Ractor.current
442
+ raise Ractor::IsolationError, "cannot schedule local task outside the owning Ractor"
443
+ end
444
+
445
+ mode == :local ?
446
+ Envelope::Local.new(args.any? ? -> { block.call(*args) } : block) :
447
+ Internal::ScheduledTask.new(args, block, mode)
448
+ end
449
+
450
+ def prepare_constructor(constructor)
451
+ return constructor if !constructor || Ractor.shareable?(constructor)
452
+ Ractor.shareable_proc(&constructor)
453
+ end
454
+
455
+ def launch_callback = Ractor.shareable_proc { _1.__send__(:launch!, _3, _2) }
456
+
457
+ def launch!(name, priority)
458
+ Thread.current.name = name if name
459
+ Thread.current.priority = priority if priority
460
+
461
+ if Fiber.respond_to?(:set_scheduler)
462
+ begin
463
+ set_scheduler!(:launching)
464
+ ensure
465
+ Fiber.set_scheduler(nil) if Fiber.scheduler
466
+ end
467
+ else
468
+ setup_owner(:launching)
469
+ Internal.with_scheduler_thread_group do |group|
470
+ group.add(Thread.current)
471
+ dispatch do |task|
472
+ callback = @pool_worker ? task.to_proc(@pool_worker) : task.to_proc
473
+ group.add(Thread.new(&callback))
474
+ end
475
+ group.list.each { it.join unless it.equal?(Thread.current) }
476
+ end
477
+ end
478
+ end
479
+
480
+ def setup_owner(expected_state)
481
+ raise "Scheduler may not be reused" unless @state.compare_and_set(expected_state, :setup)
482
+ self.owner = Ractor.current
483
+ end
484
+
485
+ def wake_dispatcher
486
+ # A full queue already guarantees that the dispatcher will wake and observe the closing state.
487
+ @queue.try_push(nil)
488
+ rescue ClosedQueueError
489
+ nil
490
+ end
491
+
492
+ def dispatch(scheduler = nil, runnable_count = nil)
493
+ failure = nil
494
+ @state.compare_and_set(:setup, :running)
495
+ while keep_processing?
496
+ break if @pool_worker && !@pool_worker.wait_for_admission(scheduler, runnable_count)
497
+ timed_out = false
498
+ timeout = @pool_worker&.idle_timeout
499
+ task = @queue.pop(timeout:) { timed_out = true }
500
+ if timed_out
501
+ break if @pool_worker&.retire?
502
+ next
503
+ end
504
+ next unless task
505
+ task = task.value if task.is_a?(Envelope)
506
+ if task
507
+ @pool_worker&.task_started
508
+ yield task
509
+ end
510
+ end
511
+ @queue.close if @queue_owner
512
+ rescue Internal::SchedulerLifecycle::Cancelled => e
513
+ # Shutdown cancellation must not create envelope ports in a terminating Ractor.
514
+ failure = e
515
+ raise
516
+ rescue ClosedQueueError
517
+ raise unless @pool_worker || closed?
518
+ rescue Exception => e # rubocop:disable Lint/RescueException
519
+ failure = e
520
+ @state.value = :error
521
+ @error.value = Envelope.new(e)
522
+ raise
523
+ ensure
524
+ @pool_worker&.stopped(failure)
525
+ # make sure we're in a closed state
526
+ unless @state.compare_and_set(:closing, :closed)
527
+ state = @state.value
528
+ @state.compare_and_set(state, :closed) unless CLOSED_STATES.include?(state)
529
+ end
530
+ end
531
+
532
+ def next_task
533
+ task = @queue.pop
534
+ return task unless task.is_a?(Envelope)
535
+ task.value
536
+ end
537
+
538
+ def keep_processing?
539
+ case @state.value
540
+ when :running, :setup then true
541
+ when :closing then @queue.size.positive?
542
+ else false
543
+ end
108
544
  end
109
545
  end
110
546
  end
547
+
548
+ module FiberExtension
549
+ Fiber.singleton_class.prepend(self) if Fiber.respond_to?(:set_scheduler)
550
+ def set_scheduler(scheduler) # rubocop:disable Naming/AccessorMethodName
551
+ return super unless scheduler.is_a?(Farce::Abstract::Scheduler)
552
+ scheduler.__send__(:set_scheduler!)
553
+ end
554
+ end
555
+
556
+ private_constant :FiberExtension
111
557
  end
@@ -48,10 +48,13 @@ module Farce
48
48
  timeout = parse_timeout(timeout)
49
49
  observed ||= generation
50
50
 
51
- scheduler = Fiber.current_scheduler if Fiber.respond_to?(:current_scheduler)
52
- # JRuby may run a later scheduled fiber on a backing thread where this is nil.
53
- scheduler ||= Fiber.scheduler if !Fiber.current.blocking? && Fiber.respond_to?(:scheduler)
54
- return wait_with_scheduler(scheduler, observed, timeout) { yield if block_given? } if scheduler
51
+ # During shutdown JRuby can expose a scheduler to a blocking fiber.
52
+ unless Fiber.current.blocking?
53
+ scheduler = Fiber.current_scheduler if Fiber.respond_to?(:current_scheduler)
54
+ # JRuby may run a later scheduled fiber on a backing thread where this is nil.
55
+ scheduler ||= Fiber.scheduler if Fiber.respond_to?(:scheduler)
56
+ return wait_with_scheduler(scheduler, observed, timeout) { yield if block_given? } if scheduler
57
+ end
55
58
 
56
59
  wait_with_phaser(observed, timeout) { yield if block_given? }
57
60
  end