This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2025, by Samuel Williams.
|
||||
|
||||
require_relative "event/version"
|
||||
require_relative "event/selector"
|
||||
require_relative "event/timers"
|
||||
require_relative "event/native"
|
||||
@@ -0,0 +1,171 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "../support"
|
||||
|
||||
module IO::Event
|
||||
# @namespace
|
||||
module Debug
|
||||
# Enforces the selector interface and delegates operations to a wrapped selector instance.
|
||||
#
|
||||
# You can enable this in the default selector by setting the `IO_EVENT_DEBUG_SELECTOR` environment variable. In addition, you can log all selector operations to a file by setting the `IO_EVENT_DEBUG_SELECTOR_LOG` environment variable. This is useful for debugging and understanding the behavior of the event loop.
|
||||
class Selector
|
||||
# Wrap the given selector with debugging.
|
||||
#
|
||||
# @parameter selector [Selector] The selector to wrap.
|
||||
# @parameter env [Hash] The environment to read configuration from.
|
||||
def self.wrap(selector, env = ENV)
|
||||
log = nil
|
||||
|
||||
if log_path = env["IO_EVENT_DEBUG_SELECTOR_LOG"]
|
||||
log = File.open(log_path, "w")
|
||||
end
|
||||
|
||||
return self.new(selector, log: log)
|
||||
end
|
||||
|
||||
# Initialize the debug selector with the given selector and optional log.
|
||||
#
|
||||
# @parameter selector [Selector] The selector to wrap.
|
||||
# @parameter log [IO] The log to write debug messages to.
|
||||
def initialize(selector, log: nil)
|
||||
@selector = selector
|
||||
|
||||
@readable = {}
|
||||
@writable = {}
|
||||
@priority = {}
|
||||
|
||||
unless Fiber.current == selector.loop
|
||||
Kernel::raise "Selector must be initialized on event loop fiber!"
|
||||
end
|
||||
|
||||
@log = log
|
||||
end
|
||||
|
||||
# The idle duration of the underlying selector.
|
||||
#
|
||||
# @returns [Numeric] The idle duration.
|
||||
def idle_duration
|
||||
@selector.idle_duration
|
||||
end
|
||||
|
||||
# The current time.
|
||||
#
|
||||
# @returns [Numeric] The current time.
|
||||
def now
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
# Log the given message.
|
||||
#
|
||||
# @asynchronous Will block the calling fiber and the entire event loop.
|
||||
def log(message)
|
||||
return unless @log
|
||||
|
||||
Fiber.blocking do
|
||||
@log.puts("T+%10.1f; %s" % [now, message])
|
||||
end
|
||||
end
|
||||
|
||||
# Wakeup the the selector.
|
||||
def wakeup
|
||||
@selector.wakeup
|
||||
end
|
||||
|
||||
# Close the selector.
|
||||
def close
|
||||
log("Closing selector")
|
||||
|
||||
if @selector.nil?
|
||||
Kernel::raise "Selector already closed!"
|
||||
end
|
||||
|
||||
@selector.close
|
||||
@selector = nil
|
||||
end
|
||||
|
||||
# Transfer from the calling fiber to the selector.
|
||||
def transfer
|
||||
log("Transfering to event loop")
|
||||
@selector.transfer
|
||||
end
|
||||
|
||||
# Resume the given fiber with the given arguments.
|
||||
def resume(*arguments)
|
||||
log("Resuming fiber with #{arguments.inspect}")
|
||||
@selector.resume(*arguments)
|
||||
end
|
||||
|
||||
# Yield to the selector.
|
||||
def yield
|
||||
log("Yielding to event loop")
|
||||
@selector.yield
|
||||
end
|
||||
|
||||
# Push the given fiber to the selector ready list, such that it will be resumed on the next call to {select}.
|
||||
#
|
||||
# @parameter fiber [Fiber] The fiber that is ready.
|
||||
def push(fiber)
|
||||
log("Pushing fiber #{fiber.inspect} to ready list")
|
||||
@selector.push(fiber)
|
||||
end
|
||||
|
||||
# Raise the given exception on the given fiber.
|
||||
#
|
||||
# @parameter fiber [Fiber] The fiber to raise the exception on.
|
||||
# @parameter arguments [Array] The arguments to use when raising the exception.
|
||||
def raise(fiber, *arguments)
|
||||
log("Raising exception on fiber #{fiber.inspect} with #{arguments.inspect}")
|
||||
@selector.raise(fiber, *arguments)
|
||||
end
|
||||
|
||||
# Check if the selector is ready.
|
||||
#
|
||||
# @returns [Boolean] Whether the selector is ready.
|
||||
def ready?
|
||||
@selector.ready?
|
||||
end
|
||||
|
||||
# Wait for the given process, forwarded to the underlying selector.
|
||||
def process_wait(*arguments)
|
||||
log("Waiting for process with #{arguments.inspect}")
|
||||
@selector.process_wait(*arguments)
|
||||
end
|
||||
|
||||
# Wait for the given IO, forwarded to the underlying selector.
|
||||
def io_wait(fiber, io, events)
|
||||
log("Waiting for IO #{io.inspect} for events #{events.inspect}")
|
||||
@selector.io_wait(fiber, io, events)
|
||||
end
|
||||
|
||||
# Read from the given IO, forwarded to the underlying selector.
|
||||
def io_read(fiber, io, buffer, length, offset = 0)
|
||||
log("Reading from IO #{io.inspect} with buffer #{buffer}; length #{length} offset #{offset}")
|
||||
@selector.io_read(fiber, io, buffer, length, offset)
|
||||
end
|
||||
|
||||
# Write to the given IO, forwarded to the underlying selector.
|
||||
def io_write(fiber, io, buffer, length, offset = 0)
|
||||
log("Writing to IO #{io.inspect} with buffer #{buffer}; length #{length} offset #{offset}")
|
||||
@selector.io_write(fiber, io, buffer, length, offset)
|
||||
end
|
||||
|
||||
# Forward the given method to the underlying selector.
|
||||
def respond_to?(name, include_private = false)
|
||||
@selector.respond_to?(name, include_private)
|
||||
end
|
||||
|
||||
# Select for the given duration, forwarded to the underlying selector.
|
||||
def select(duration = nil)
|
||||
log("Selecting for #{duration.inspect}")
|
||||
unless Fiber.current == @selector.loop
|
||||
Kernel::raise "Selector must be run on event loop fiber!"
|
||||
end
|
||||
|
||||
@selector.select(duration)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
module IO::Event
|
||||
# A thread safe synchronisation primative.
|
||||
class Interrupt
|
||||
def self.attach(selector)
|
||||
self.new(selector)
|
||||
end
|
||||
|
||||
def initialize(selector)
|
||||
@selector = selector
|
||||
@input, @output = ::IO.pipe
|
||||
|
||||
@fiber = Fiber.new do
|
||||
while true
|
||||
if @selector.io_wait(@fiber, @input, IO::READABLE)
|
||||
@input.read_nonblock(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@fiber.transfer
|
||||
end
|
||||
|
||||
# Send a sigle byte interrupt.
|
||||
def signal
|
||||
@output.write(".")
|
||||
@output.flush
|
||||
rescue IOError
|
||||
# Ignore.
|
||||
end
|
||||
|
||||
def close
|
||||
@input.close
|
||||
@output.close
|
||||
# @fiber.raise(::Interrupt)
|
||||
end
|
||||
end
|
||||
|
||||
private_constant :Interrupt
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2025, by Samuel Williams.
|
||||
|
||||
begin
|
||||
require "IO_Event"
|
||||
rescue LoadError => error
|
||||
warn "Could not load native event selector: #{error}"
|
||||
require_relative "selector/nonblock"
|
||||
end
|
||||
@@ -0,0 +1,147 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021, by Wander Hillen.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
class IO
|
||||
module Event
|
||||
# A priority queue implementation using a standard binary minheap. It uses straight comparison
|
||||
# of its contents to determine priority.
|
||||
# See <https://en.wikipedia.org/wiki/Binary_heap> for explanations of the main methods.
|
||||
class PriorityHeap
|
||||
# Initializes the heap.
|
||||
def initialize
|
||||
# The heap is represented with an array containing a binary tree. See
|
||||
# https://en.wikipedia.org/wiki/Binary_heap#Heap_implementation for how this array
|
||||
# is built up.
|
||||
@contents = []
|
||||
end
|
||||
|
||||
# @returns [Object | Nil] the smallest element in the heap without removing it, or nil if the heap is empty.
|
||||
def peek
|
||||
@contents[0]
|
||||
end
|
||||
|
||||
# @returns [Integer] the number of elements in the heap.
|
||||
def size
|
||||
@contents.size
|
||||
end
|
||||
|
||||
# Removes and returns the smallest element in the heap, or nil if the heap is empty.
|
||||
#
|
||||
# @returns [Object | Nil] The smallest element in the heap, or nil if the heap is empty.
|
||||
def pop
|
||||
# If the heap is empty:
|
||||
if @contents.empty?
|
||||
return nil
|
||||
end
|
||||
|
||||
# If we have only one item, no swapping is required:
|
||||
if @contents.size == 1
|
||||
return @contents.pop
|
||||
end
|
||||
|
||||
# Take the root of the tree:
|
||||
value = @contents[0]
|
||||
|
||||
# Remove the last item in the tree:
|
||||
last = @contents.pop
|
||||
|
||||
# Overwrite the root of the tree with the item:
|
||||
@contents[0] = last
|
||||
|
||||
# Bubble it down into place:
|
||||
bubble_down(0)
|
||||
|
||||
# validate!
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
# Add a new element to the heap, then rearrange elements until the heap invariant is true again.
|
||||
#
|
||||
# @parameter element [Object] The element to add to the heap.
|
||||
def push(element)
|
||||
# Insert the item at the end of the heap:
|
||||
@contents.push(element)
|
||||
|
||||
# Bubble it up into position:
|
||||
bubble_up(@contents.size - 1)
|
||||
|
||||
# validate!
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
# Empties out the heap, discarding all elements
|
||||
def clear!
|
||||
@contents = []
|
||||
end
|
||||
|
||||
# Validate the heap invariant. Every element except the root must not be smaller than its parent element. Note that it MAY be equal.
|
||||
def valid?
|
||||
# Notice we skip index 0 on purpose, because it has no parent
|
||||
(1..(@contents.size - 1)).all? { |e| @contents[e] >= @contents[(e - 1) / 2] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Left here for reference, but unused.
|
||||
# def swap(i, j)
|
||||
# @contents[i], @contents[j] = @contents[j], @contents[i]
|
||||
# end
|
||||
|
||||
def bubble_up(index)
|
||||
parent_index = (index - 1) / 2 # watch out, integer division!
|
||||
|
||||
while index > 0 && @contents[index] < @contents[parent_index]
|
||||
# If the node has a smaller value than its parent, swap these nodes to uphold the minheap invariant and update the index of the 'current' node. If the node is already at index 0, we can also stop because that is the root of the heap.
|
||||
# swap(index, parent_index)
|
||||
@contents[index], @contents[parent_index] = @contents[parent_index], @contents[index]
|
||||
|
||||
index = parent_index
|
||||
parent_index = (index - 1) / 2 # watch out, integer division!
|
||||
end
|
||||
end
|
||||
|
||||
def bubble_down(index)
|
||||
swap_value = 0
|
||||
swap_index = nil
|
||||
|
||||
while true
|
||||
left_index = (2 * index) + 1
|
||||
left_value = @contents[left_index]
|
||||
|
||||
if left_value.nil?
|
||||
# This node has no children so it can't bubble down any further. We're done here!
|
||||
return
|
||||
end
|
||||
|
||||
# Determine which of the child nodes has the smallest value:
|
||||
right_index = left_index + 1
|
||||
right_value = @contents[right_index]
|
||||
|
||||
if right_value.nil? or right_value > left_value
|
||||
swap_value = left_value
|
||||
swap_index = left_index
|
||||
else
|
||||
swap_value = right_value
|
||||
swap_index = right_index
|
||||
end
|
||||
|
||||
if @contents[index] < swap_value
|
||||
# No need to swap, the minheap invariant is already satisfied:
|
||||
return
|
||||
else
|
||||
# At least one of the child node has a smaller value than the current node, swap current node with that child and update current node for if it might need to bubble down even further:
|
||||
# swap(index, swap_index)
|
||||
@contents[index], @contents[swap_index] = @contents[swap_index], @contents[index]
|
||||
|
||||
index = swap_index
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2025, by Samuel Williams.
|
||||
|
||||
require_relative "native"
|
||||
|
||||
module IO::Event
|
||||
unless self.const_defined?(:Profiler)
|
||||
module Profiler
|
||||
# The default profiler, if the platform supports it.
|
||||
# Use `IO_EVENT_PROFILER=true` to enable it.
|
||||
def self.default
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "selector/select"
|
||||
require_relative "debug/selector"
|
||||
require_relative "support"
|
||||
|
||||
module IO::Event
|
||||
# @namespace
|
||||
module Selector
|
||||
# The default selector implementation, which is chosen based on the environment and available implementations.
|
||||
#
|
||||
# @parameter env [Hash] The environment to read configuration from.
|
||||
# @returns [Class] The default selector implementation.
|
||||
def self.default(env = ENV)
|
||||
if name = env["IO_EVENT_SELECTOR"]&.to_sym
|
||||
return const_get(name)
|
||||
end
|
||||
|
||||
if self.const_defined?(:URing)
|
||||
URing
|
||||
elsif self.const_defined?(:EPoll)
|
||||
EPoll
|
||||
elsif self.const_defined?(:KQueue)
|
||||
KQueue
|
||||
else
|
||||
Select
|
||||
end
|
||||
end
|
||||
|
||||
# Create a new selector instance, according to the best available implementation.
|
||||
#
|
||||
# @parameter loop [Fiber] The event loop fiber.
|
||||
# @parameter env [Hash] The environment to read configuration from.
|
||||
# @returns [Selector] The new selector instance.
|
||||
def self.new(loop, env = ENV)
|
||||
selector = default(env).new(loop)
|
||||
|
||||
if debug = env["IO_EVENT_DEBUG_SELECTOR"]
|
||||
selector = Debug::Selector.wrap(selector, env)
|
||||
end
|
||||
|
||||
return selector
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
require "io/nonblock"
|
||||
|
||||
module IO::Event
|
||||
module Selector
|
||||
# Execute the given block in non-blocking mode.
|
||||
#
|
||||
# @parameter io [IO] The IO object to operate on.
|
||||
# @yields {...} The block to execute.
|
||||
def self.nonblock(io, &block)
|
||||
io.nonblock(&block)
|
||||
rescue Errno::EBADF
|
||||
# Windows.
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,495 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
# Copyright, 2023, by Math Ieu.
|
||||
|
||||
require_relative "../interrupt"
|
||||
require_relative "../support"
|
||||
|
||||
module IO::Event
|
||||
module Selector
|
||||
# A pure-Ruby implementation of the event selector.
|
||||
class Select
|
||||
# Initialize the selector with the given event loop fiber.
|
||||
def initialize(loop)
|
||||
@loop = loop
|
||||
|
||||
@waiting = Hash.new.compare_by_identity
|
||||
|
||||
@blocked = false
|
||||
|
||||
@ready = Queue.new
|
||||
@interrupt = Interrupt.attach(self)
|
||||
|
||||
@idle_duration = 0.0
|
||||
end
|
||||
|
||||
# @attribute [Fiber] The event loop fiber.
|
||||
attr :loop
|
||||
|
||||
# @attribute [Float] This is the amount of time the event loop was idle during the last select call.
|
||||
attr :idle_duration
|
||||
|
||||
# Wake up the event loop if it is currently sleeping.
|
||||
def wakeup
|
||||
if @blocked
|
||||
@interrupt.signal
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
# Close the selector and release any resources.
|
||||
def close
|
||||
@interrupt.close
|
||||
|
||||
@loop = nil
|
||||
@waiting = nil
|
||||
end
|
||||
|
||||
Optional = Struct.new(:fiber) do
|
||||
def transfer(*arguments)
|
||||
fiber&.transfer(*arguments)
|
||||
end
|
||||
|
||||
def alive?
|
||||
fiber&.alive?
|
||||
end
|
||||
|
||||
def nullify
|
||||
self.fiber = nil
|
||||
end
|
||||
end
|
||||
|
||||
# Transfer from the current fiber to the event loop.
|
||||
def transfer
|
||||
@loop.transfer
|
||||
end
|
||||
|
||||
# Transfer from the current fiber to the specified fiber. Put the current fiber into the ready list.
|
||||
def resume(fiber, *arguments)
|
||||
optional = Optional.new(Fiber.current)
|
||||
@ready.push(optional)
|
||||
|
||||
fiber.transfer(*arguments)
|
||||
ensure
|
||||
optional.nullify
|
||||
end
|
||||
|
||||
# Yield from the current fiber back to the event loop. Put the current fiber into the ready list.
|
||||
def yield
|
||||
optional = Optional.new(Fiber.current)
|
||||
@ready.push(optional)
|
||||
|
||||
@loop.transfer
|
||||
ensure
|
||||
optional.nullify
|
||||
end
|
||||
|
||||
# Append the given fiber into the ready list.
|
||||
def push(fiber)
|
||||
@ready.push(fiber)
|
||||
end
|
||||
|
||||
# Transfer to the given fiber and raise an exception. Put the current fiber into the ready list.
|
||||
def raise(fiber, *arguments)
|
||||
optional = Optional.new(Fiber.current)
|
||||
@ready.push(optional)
|
||||
|
||||
fiber.raise(*arguments)
|
||||
ensure
|
||||
optional.nullify
|
||||
end
|
||||
|
||||
# @returns [Boolean] Whether the ready list is not empty, i.e. there are fibers ready to be resumed.
|
||||
def ready?
|
||||
!@ready.empty?
|
||||
end
|
||||
|
||||
Waiter = Struct.new(:fiber, :events, :tail) do
|
||||
def alive?
|
||||
self.fiber&.alive?
|
||||
end
|
||||
|
||||
# Dispatch the given events to the list of waiting fibers. If the fiber was not waiting for the given events, it is reactivated by calling the given block.
|
||||
def dispatch(events, &reactivate)
|
||||
# We capture the tail here, because calling reactivate might modify it:
|
||||
tail = self.tail
|
||||
|
||||
if fiber = self.fiber
|
||||
if fiber.alive?
|
||||
revents = events & self.events
|
||||
if revents.zero?
|
||||
reactivate.call(self)
|
||||
else
|
||||
self.fiber = nil
|
||||
fiber.transfer(revents)
|
||||
end
|
||||
else
|
||||
self.fiber = nil
|
||||
end
|
||||
end
|
||||
|
||||
tail&.dispatch(events, &reactivate)
|
||||
end
|
||||
|
||||
def invalidate
|
||||
self.fiber = nil
|
||||
end
|
||||
|
||||
def each(&block)
|
||||
if fiber = self.fiber
|
||||
yield fiber, self.events
|
||||
end
|
||||
|
||||
self.tail&.each(&block)
|
||||
end
|
||||
end
|
||||
|
||||
# Wait for the given IO to become readable or writable.
|
||||
#
|
||||
# @parameter fiber [Fiber] The fiber that is waiting.
|
||||
# @parameter io [IO] The IO object to wait on.
|
||||
# @parameter events [Integer] The events to wait for.
|
||||
def io_wait(fiber, io, events)
|
||||
waiter = @waiting[io] = Waiter.new(fiber, events, @waiting[io])
|
||||
|
||||
@loop.transfer
|
||||
ensure
|
||||
waiter&.invalidate
|
||||
end
|
||||
|
||||
# Wait for multiple IO objects to become readable or writable.
|
||||
#
|
||||
# @parameter readable [Array(IO)] The list of IO objects to wait for readability.
|
||||
# @parameter writable [Array(IO)] The list of IO objects to wait for writability.
|
||||
# @parameter priority [Array(IO)] The list of IO objects to wait for priority events.
|
||||
def io_select(readable, writable, priority, timeout)
|
||||
Thread.new do
|
||||
IO.select(readable, writable, priority, timeout)
|
||||
end.value
|
||||
end
|
||||
|
||||
EAGAIN = -Errno::EAGAIN::Errno
|
||||
EWOULDBLOCK = -Errno::EWOULDBLOCK::Errno
|
||||
|
||||
# Whether the given error code indicates that the operation should be retried.
|
||||
protected def again?(errno)
|
||||
errno == EAGAIN or errno == EWOULDBLOCK
|
||||
end
|
||||
|
||||
if Support.fiber_scheduler_v3?
|
||||
# Ruby 3.3+, full IO::Buffer support.
|
||||
|
||||
# Read from the given IO to the buffer.
|
||||
#
|
||||
# @parameter length [Integer] The minimum number of bytes to read.
|
||||
# @parameter offset [Integer] The offset into the buffer to read to.
|
||||
def io_read(fiber, io, buffer, length, offset = 0)
|
||||
total = 0
|
||||
|
||||
Selector.nonblock(io) do
|
||||
while true
|
||||
result = Fiber.blocking{buffer.read(io, 0, offset)}
|
||||
|
||||
if result < 0
|
||||
if again?(result)
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return result
|
||||
end
|
||||
elsif result == 0
|
||||
break
|
||||
else
|
||||
total += result
|
||||
break if total >= length
|
||||
offset += result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return total
|
||||
end
|
||||
|
||||
# Write to the given IO from the buffer.
|
||||
#
|
||||
# @parameter length [Integer] The minimum number of bytes to write.
|
||||
# @parameter offset [Integer] The offset into the buffer to write from.
|
||||
def io_write(fiber, io, buffer, length, offset = 0)
|
||||
total = 0
|
||||
|
||||
Selector.nonblock(io) do
|
||||
while true
|
||||
result = Fiber.blocking{buffer.write(io, 0, offset)}
|
||||
|
||||
if result < 0
|
||||
if again?(result)
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return result
|
||||
end
|
||||
elsif result == 0
|
||||
break result
|
||||
else
|
||||
total += result
|
||||
break if total >= length
|
||||
offset += result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return total
|
||||
end
|
||||
elsif Support.fiber_scheduler_v2?
|
||||
# Ruby 3.2, most IO::Buffer support, but slightly clunky read/write methods.
|
||||
def io_read(fiber, io, buffer, length, offset = 0)
|
||||
total = 0
|
||||
|
||||
Selector.nonblock(io) do
|
||||
maximum_size = buffer.size - offset
|
||||
while maximum_size > 0
|
||||
result = Fiber.blocking{buffer.read(io, maximum_size, offset)}
|
||||
|
||||
if again?(result)
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return result
|
||||
end
|
||||
elsif result < 0
|
||||
return result
|
||||
else
|
||||
total += result
|
||||
offset += result
|
||||
break if total >= length
|
||||
end
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
end
|
||||
end
|
||||
|
||||
return total
|
||||
end
|
||||
|
||||
def io_write(fiber, io, buffer, length, offset = 0)
|
||||
total = 0
|
||||
|
||||
Selector.nonblock(io) do
|
||||
maximum_size = buffer.size - offset
|
||||
while maximum_size > 0
|
||||
result = Fiber.blocking{buffer.write(io, maximum_size, offset)}
|
||||
|
||||
if again?(result)
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return result
|
||||
end
|
||||
elsif result < 0
|
||||
return result
|
||||
else
|
||||
total += result
|
||||
offset += result
|
||||
break if total >= length
|
||||
end
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
end
|
||||
end
|
||||
|
||||
return total
|
||||
end
|
||||
elsif Support.fiber_scheduler_v1?
|
||||
# Ruby <= 3.1, limited IO::Buffer support.
|
||||
def io_read(fiber, _io, buffer, length, offset = 0)
|
||||
# We need to avoid any internal buffering, so we use a duplicated IO object:
|
||||
io = IO.for_fd(_io.fileno, autoclose: false)
|
||||
|
||||
total = 0
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
while maximum_size > 0
|
||||
case result = blocking{io.read_nonblock(maximum_size, exception: false)}
|
||||
when :wait_readable
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return EWOULDBLOCK
|
||||
end
|
||||
when :wait_writable
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::WRITABLE)
|
||||
else
|
||||
return EWOULDBLOCK
|
||||
end
|
||||
when nil
|
||||
break
|
||||
else
|
||||
buffer.set_string(result, offset)
|
||||
|
||||
size = result.bytesize
|
||||
total += size
|
||||
offset += size
|
||||
break if size >= length
|
||||
length -= size
|
||||
end
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
end
|
||||
|
||||
return total
|
||||
rescue IOError => error
|
||||
return -Errno::EBADF::Errno
|
||||
rescue SystemCallError => error
|
||||
return -error.errno
|
||||
end
|
||||
|
||||
def io_write(fiber, _io, buffer, length, offset = 0)
|
||||
# We need to avoid any internal buffering, so we use a duplicated IO object:
|
||||
io = IO.for_fd(_io.fileno, autoclose: false)
|
||||
|
||||
total = 0
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
while maximum_size > 0
|
||||
chunk = buffer.get_string(offset, maximum_size)
|
||||
case result = blocking{io.write_nonblock(chunk, exception: false)}
|
||||
when :wait_readable
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::READABLE)
|
||||
else
|
||||
return EWOULDBLOCK
|
||||
end
|
||||
when :wait_writable
|
||||
if length > 0
|
||||
self.io_wait(fiber, io, IO::WRITABLE)
|
||||
else
|
||||
return EWOULDBLOCK
|
||||
end
|
||||
else
|
||||
total += result
|
||||
offset += result
|
||||
break if result >= length
|
||||
length -= result
|
||||
end
|
||||
|
||||
maximum_size = buffer.size - offset
|
||||
end
|
||||
|
||||
return total
|
||||
rescue IOError => error
|
||||
return -Errno::EBADF::Errno
|
||||
rescue SystemCallError => error
|
||||
return -error.errno
|
||||
end
|
||||
|
||||
def blocking(&block)
|
||||
fiber = Fiber.new(blocking: true, &block)
|
||||
return fiber.resume(fiber)
|
||||
end
|
||||
end
|
||||
|
||||
def process_wait(fiber, pid, flags)
|
||||
Thread.new do
|
||||
Process::Status.wait(pid, flags)
|
||||
end.value
|
||||
end
|
||||
|
||||
private def pop_ready
|
||||
unless @ready.empty?
|
||||
count = @ready.size
|
||||
|
||||
count.times do
|
||||
fiber = @ready.pop
|
||||
fiber.transfer if fiber.alive?
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
def select(duration = nil)
|
||||
if pop_ready
|
||||
# If we have popped items from the ready list, they may influence the duration calculation, so we don't delay the event loop:
|
||||
duration = 0
|
||||
end
|
||||
|
||||
readable = Array.new
|
||||
writable = Array.new
|
||||
priority = Array.new
|
||||
|
||||
@waiting.each do |io, waiter|
|
||||
waiter.each do |fiber, events|
|
||||
if (events & IO::READABLE) > 0
|
||||
readable << io
|
||||
end
|
||||
|
||||
if (events & IO::WRITABLE) > 0
|
||||
writable << io
|
||||
end
|
||||
|
||||
if (events & IO::PRIORITY) > 0
|
||||
priority << io
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
duration = 0 unless @ready.empty?
|
||||
error = nil
|
||||
|
||||
if duration&.>(0)
|
||||
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
else
|
||||
@idle_duration = 0.0
|
||||
end
|
||||
|
||||
# We need to handle interrupts on blocking IO. Every other implementation uses EINTR, but that doesn't work with `::IO.select` as it will retry the call on EINTR.
|
||||
Thread.handle_interrupt(::Exception => :on_blocking) do
|
||||
@blocked = true
|
||||
readable, writable, priority = ::IO.select(readable, writable, priority, duration)
|
||||
rescue ::Exception => error
|
||||
# Requeue below...
|
||||
ensure
|
||||
@blocked = false
|
||||
if start_time
|
||||
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
@idle_duration = end_time - start_time
|
||||
end
|
||||
end
|
||||
|
||||
if error
|
||||
# Requeue the error into the pending exception queue:
|
||||
Thread.current.raise(error)
|
||||
return 0
|
||||
end
|
||||
|
||||
ready = Hash.new(0).compare_by_identity
|
||||
|
||||
readable&.each do |io|
|
||||
ready[io] |= IO::READABLE
|
||||
end
|
||||
|
||||
writable&.each do |io|
|
||||
ready[io] |= IO::WRITABLE
|
||||
end
|
||||
|
||||
priority&.each do |io|
|
||||
ready[io] |= IO::PRIORITY
|
||||
end
|
||||
|
||||
ready.each do |io, events|
|
||||
@waiting.delete(io).dispatch(events) do |waiter|
|
||||
# Re-schedule the waiting IO:
|
||||
waiter.tail = @waiting[io]
|
||||
@waiting[io] = waiter
|
||||
end
|
||||
end
|
||||
|
||||
return ready.size
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
class IO
|
||||
module Event
|
||||
# Helper methods for detecting support for various features.
|
||||
module Support
|
||||
# Some features are only availble if the IO::Buffer class is available.
|
||||
#
|
||||
# @returns [Boolean] Whether the IO::Buffer class is available.
|
||||
def self.buffer?
|
||||
IO.const_defined?(:Buffer)
|
||||
end
|
||||
|
||||
# The basic fiber scheduler was introduced along side the IO::Buffer class.
|
||||
#
|
||||
# @returns [Boolean] Whether the IO::Buffer class is available.
|
||||
#
|
||||
# To be removed on 31 Mar 2025.
|
||||
def self.fiber_scheduler_v1?
|
||||
IO.const_defined?(:Buffer)
|
||||
end
|
||||
|
||||
# More advanced read/write methods and blocking controls were introduced in Ruby 3.2.
|
||||
#
|
||||
# To be removed on 31 Mar 2026.
|
||||
def self.fiber_scheduler_v2?
|
||||
# Some interface changes were back-ported incorrectly:
|
||||
# https://github.com/ruby/ruby/pull/10778
|
||||
# Specifically "Improvements to IO::Buffer read/write/pread/pwrite."
|
||||
# Missing correct size calculation.
|
||||
return false if RUBY_VERSION >= "3.2.5"
|
||||
|
||||
IO.const_defined?(:Buffer) and Fiber.respond_to?(:blocking) and IO::Buffer.instance_method(:read).arity == -1
|
||||
end
|
||||
|
||||
# Updated inferfaces for read/write and IO::Buffer were introduced in Ruby 3.3, including pread/pwrite.
|
||||
#
|
||||
# To become the default 31 Mar 2026.
|
||||
def self.fiber_scheduler_v3?
|
||||
if fiber_scheduler_v2?
|
||||
return true if RUBY_VERSION >= "3.3"
|
||||
|
||||
# Feature detection if required:
|
||||
begin
|
||||
IO::Buffer.new.slice(0, 0).write(STDOUT)
|
||||
return true
|
||||
rescue
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024, by Samuel Williams.
|
||||
|
||||
require_relative "priority_heap"
|
||||
|
||||
class IO
|
||||
module Event
|
||||
# An efficient sorted set of timers.
|
||||
class Timers
|
||||
# A handle to a scheduled timer.
|
||||
class Handle
|
||||
# Initialize the handle with the given time and block.
|
||||
#
|
||||
# @parameter time [Float] The time at which the block should be called.
|
||||
# @parameter block [Proc] The block to call.
|
||||
def initialize(time, block)
|
||||
@time = time
|
||||
@block = block
|
||||
end
|
||||
|
||||
# @attribute [Float] The time at which the block should be called.
|
||||
attr :time
|
||||
|
||||
# @attribute [Proc | Nil] The block to call when the timer fires.
|
||||
attr :block
|
||||
|
||||
# Compare the handle with another handle.
|
||||
#
|
||||
# @parameter other [Handle] The other handle to compare with.
|
||||
# @returns [Boolean] Whether the handle is less than the other handle.
|
||||
def < other
|
||||
@time < other.time
|
||||
end
|
||||
|
||||
# Compare the handle with another handle.
|
||||
#
|
||||
# @parameter other [Handle] The other handle to compare with.
|
||||
# @returns [Boolean] Whether the handle is greater than the other handle.
|
||||
def > other
|
||||
@time > other.time
|
||||
end
|
||||
|
||||
# Invoke the block.
|
||||
def call(...)
|
||||
@block.call(...)
|
||||
end
|
||||
|
||||
# Cancel the timer.
|
||||
def cancel!
|
||||
@block = nil
|
||||
end
|
||||
|
||||
# @returns [Boolean] Whether the timer has been cancelled.
|
||||
def cancelled?
|
||||
@block.nil?
|
||||
end
|
||||
end
|
||||
|
||||
# Initialize the timers.
|
||||
def initialize
|
||||
@heap = PriorityHeap.new
|
||||
@scheduled = []
|
||||
end
|
||||
|
||||
# @returns [Integer] The number of timers in the heap.
|
||||
def size
|
||||
flush!
|
||||
|
||||
return @heap.size
|
||||
end
|
||||
|
||||
# Schedule a block to be called at a specific time in the future.
|
||||
#
|
||||
# @parameter time [Float] The time at which the block should be called, relative to {#now}.
|
||||
# @parameter block [Proc] The block to call.
|
||||
def schedule(time, block)
|
||||
handle = Handle.new(time, block)
|
||||
|
||||
@scheduled << handle
|
||||
|
||||
return handle
|
||||
end
|
||||
|
||||
# Schedule a block to be called after a specific time offset, relative to the current time as returned by {#now}.
|
||||
#
|
||||
# @parameter offset [#to_f] The time offset from the current time at which the block should be called.
|
||||
# @yields {|now| ...} When the timer fires.
|
||||
def after(offset, &block)
|
||||
schedule(self.now + offset.to_f, block)
|
||||
end
|
||||
|
||||
|
||||
# Compute the time interval until the next timer fires.
|
||||
#
|
||||
# @parameter now [Float] The current time.
|
||||
# @returns [Float | Nil] The time interval until the next timer fires, if any.
|
||||
def wait_interval(now = self.now)
|
||||
flush!
|
||||
|
||||
while handle = @heap.peek
|
||||
if handle.cancelled?
|
||||
@heap.pop
|
||||
else
|
||||
return handle.time - now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @returns [Float] The current time.
|
||||
def now
|
||||
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
# Fire all timers that are ready to fire.
|
||||
#
|
||||
# @parameter now [Float] The current time.
|
||||
def fire(now = self.now)
|
||||
# Flush scheduled timers into the heap:
|
||||
flush!
|
||||
|
||||
# Get the earliest timer:
|
||||
while handle = @heap.peek
|
||||
if handle.cancelled?
|
||||
@heap.pop
|
||||
elsif handle.time <= now
|
||||
# Remove the earliest timer from the heap:
|
||||
@heap.pop
|
||||
|
||||
# Call the block:
|
||||
handle.call(now)
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Flush all scheduled timers into the heap.
|
||||
#
|
||||
# This is a small optimization which assumes that most timers (timeouts) will be cancelled.
|
||||
protected def flush!
|
||||
while handle = @scheduled.pop
|
||||
@heap.push(handle) unless handle.cancelled?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2025, by Samuel Williams.
|
||||
|
||||
# @namespace
|
||||
class IO
|
||||
# @namespace
|
||||
module Event
|
||||
VERSION = "1.9.0"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user