├── .editorconfig ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── config └── sus.rb ├── fixtures └── timer_quantum.rb ├── gems.rb ├── lib ├── timers.rb └── timers │ ├── events.rb │ ├── group.rb │ ├── interval.rb │ ├── priority_heap.rb │ ├── timer.rb │ ├── version.rb │ └── wait.rb ├── license.md ├── readme.md ├── release.cert ├── test └── timers │ ├── events.rb │ ├── group.rb │ ├── group │ ├── cancel.rb │ ├── every.rb │ └── pause.rb │ ├── performance.rb │ ├── priority_heap.rb │ ├── strict.rb │ ├── timer.rb │ └── wait.rb └── timers.gemspec /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 9180068e70c5b5c1fdb9a6c47f4d8f2553fc7104 2 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.4" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.4" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | - "3.4" 28 | 29 | experimental: [false] 30 | 31 | include: 32 | - os: ubuntu 33 | ruby: truffleruby 34 | experimental: true 35 | - os: ubuntu 36 | ruby: jruby 37 | experimental: true 38 | - os: ubuntu 39 | ruby: head 40 | experimental: true 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{matrix.ruby}} 47 | bundler-cache: true 48 | 49 | - name: Run tests 50 | timeout-minutes: 10 51 | run: bundle exec bake test 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Nicholas Evans 2 | Ron Evans 3 | Sean Gregory 4 | Utenmiki 5 | Donovan Keme 6 | Donovan Keme 7 | Donovan Keme 8 | Utenmiki 9 | Tommy Ong Gia Phu 10 | Ryunosuke Sato 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2025, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /fixtures/timer_quantum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2025, by Samuel Williams. 5 | 6 | class TimerQuantum 7 | def self.resolve 8 | self.new.to_f 9 | end 10 | 11 | def to_f 12 | precision 13 | end 14 | 15 | private 16 | 17 | def precision 18 | @precision ||= self.measure_host_precision 19 | end 20 | 21 | def measure_host_precision(repeats: 100, duration: 0.01) 22 | # Measure the precision sleep using the monotonic clock: 23 | start_time = self.now 24 | repeats.times do 25 | sleep(duration) 26 | end 27 | end_time = self.now 28 | 29 | actual_duration = end_time - start_time 30 | expected_duration = repeats * duration 31 | 32 | if actual_duration < expected_duration 33 | warn "Invalid precision measurement: #{actual_duration} < #{expected_duration}" 34 | return 0.1 35 | end 36 | 37 | # This computes the overhead of sleep, called `repeats` times: 38 | return actual_duration - expected_duration 39 | end 40 | 41 | def now 42 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 43 | end 44 | end 45 | 46 | TIMER_QUANTUM = TimerQuantum.resolve 47 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2016, by Tony Arcieri. 5 | # Copyright, 2014-2025, by Samuel Williams. 6 | # Copyright, 2015, by Donovan Keme. 7 | 8 | source "https://rubygems.org" 9 | 10 | gemspec 11 | 12 | group :maintenance, optional: true do 13 | gem "bake-modernize" 14 | gem "bake-gem" 15 | end 16 | 17 | group :test do 18 | gem "sus" 19 | gem "covered" 20 | gem "decode" 21 | gem "rubocop" 22 | 23 | gem "bake-test" 24 | gem "bake-test-external" 25 | 26 | gem "benchmark-ips" 27 | gem "ruby-prof", platform: :mri 28 | end 29 | -------------------------------------------------------------------------------- /lib/timers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2016, by Tony Arcieri. 5 | # Copyright, 2012, by Ryan LeCompte. 6 | # Copyright, 2012, by Nicholas Evans. 7 | # Copyright, 2012, by Dimitrij Denissenko. 8 | # Copyright, 2013, by Chuck Remes. 9 | # Copyright, 2013, by Ron Evans. 10 | # Copyright, 2013, by Sean Gregory. 11 | # Copyright, 2013, by Utenmiki. 12 | # Copyright, 2013, by Jeremy Hinegardner. 13 | # Copyright, 2014, by Larry Lv. 14 | # Copyright, 2014, by Bruno Enten. 15 | # Copyright, 2014-2022, by Samuel Williams. 16 | # Copyright, 2014, by Mike Bourgeous. 17 | 18 | require_relative "timers/version" 19 | 20 | require_relative "timers/group" 21 | require_relative "timers/wait" 22 | -------------------------------------------------------------------------------- /lib/timers/events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2022, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2014, by Lavir the Whiolet. 7 | # Copyright, 2015, by Utenmiki. 8 | # Copyright, 2015, by Donovan Keme. 9 | # Copyright, 2021, by Wander Hillen. 10 | 11 | require_relative "timer" 12 | require_relative "priority_heap" 13 | 14 | module Timers 15 | # Maintains a PriorityHeap of events ordered on time, which can be cancelled. 16 | class Events 17 | # Represents a cancellable handle for a specific timer event. 18 | class Handle 19 | include Comparable 20 | 21 | def initialize(time, callback) 22 | @time = time 23 | @callback = callback 24 | end 25 | 26 | # The absolute time that the handle should be fired at. 27 | attr_reader :time 28 | 29 | # Cancel this timer, O(1). 30 | def cancel! 31 | # The simplest way to keep track of cancelled status is to nullify the 32 | # callback. This should also be optimal for garbage collection. 33 | @callback = nil 34 | end 35 | 36 | # Has this timer been cancelled? Cancelled timer's don't fire. 37 | def cancelled? 38 | @callback.nil? 39 | end 40 | 41 | def <=> other 42 | @time <=> other.time 43 | end 44 | 45 | # Fire the callback if not cancelled with the given time parameter. 46 | def fire(time) 47 | @callback.call(time) if @callback 48 | end 49 | end 50 | 51 | def initialize 52 | # A sequence of handles, maintained in sorted order, future to present. 53 | # @sequence.last is the next event to be fired. 54 | @sequence = PriorityHeap.new 55 | @queue = [] 56 | end 57 | 58 | # Add an event at the given time. 59 | def schedule(time, callback) 60 | flush! 61 | 62 | handle = Handle.new(time.to_f, callback) 63 | 64 | @queue << handle 65 | 66 | return handle 67 | end 68 | 69 | # Returns the first non-cancelled handle. 70 | def first 71 | merge! 72 | 73 | while (handle = @sequence.peek) 74 | return handle unless handle.cancelled? 75 | @sequence.pop 76 | end 77 | end 78 | 79 | # Returns the number of pending (possibly cancelled) events. 80 | def size 81 | @sequence.size + @queue.size 82 | end 83 | 84 | # Fire all handles for which Handle#time is less than the given time. 85 | def fire(time) 86 | merge! 87 | 88 | while handle = @sequence.peek and handle.time <= time 89 | @sequence.pop 90 | handle.fire(time) 91 | end 92 | end 93 | 94 | private 95 | 96 | # Move all non-cancelled timers from the pending queue to the priority heap 97 | def merge! 98 | while handle = @queue.pop 99 | next if handle.cancelled? 100 | 101 | @sequence.push(handle) 102 | end 103 | end 104 | 105 | def flush! 106 | while @queue.last&.cancelled? 107 | @queue.pop 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/timers/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2015, by Donovan Keme. 7 | # Copyright, 2015, by Tommy Ong Gia Phu. 8 | 9 | require "set" 10 | require "forwardable" 11 | 12 | require_relative "interval" 13 | require_relative "timer" 14 | require_relative "events" 15 | 16 | module Timers 17 | # A collection of timers which may fire at different times 18 | class Group 19 | include Enumerable 20 | 21 | extend Forwardable 22 | def_delegators :@timers, :each, :empty? 23 | 24 | def initialize 25 | @events = Events.new 26 | 27 | @timers = Set.new 28 | @paused_timers = Set.new 29 | 30 | @interval = Interval.new 31 | @interval.start 32 | end 33 | 34 | # Scheduled events: 35 | attr_reader :events 36 | 37 | # Active timers: 38 | attr_reader :timers 39 | 40 | # Paused timers: 41 | attr_reader :paused_timers 42 | 43 | # Call the given block after the given interval. The first argument will be 44 | # the time at which the group was asked to fire timers for. 45 | def after(interval, &block) 46 | Timer.new(self, interval, false, &block) 47 | end 48 | 49 | # Call the given block immediately, and then after the given interval. The first 50 | # argument will be the time at which the group was asked to fire timers for. 51 | def now_and_after(interval, &block) 52 | yield 53 | after(interval, &block) 54 | end 55 | 56 | # Call the given block periodically at the given interval. The first 57 | # argument will be the time at which the group was asked to fire timers for. 58 | def every(interval, recur = true, &block) 59 | Timer.new(self, interval, recur, &block) 60 | end 61 | 62 | # Call the given block immediately, and then periodically at the given interval. The first 63 | # argument will be the time at which the group was asked to fire timers for. 64 | def now_and_every(interval, recur = true, &block) 65 | yield 66 | every(interval, recur, &block) 67 | end 68 | 69 | # Wait for the next timer and fire it. Can take a block, which should behave 70 | # like sleep(n), except that n may be nil (sleep forever) or a negative 71 | # number (fire immediately after return). 72 | def wait 73 | if block_given? 74 | yield wait_interval 75 | 76 | while (interval = wait_interval) && interval > 0 77 | yield interval 78 | end 79 | else 80 | while (interval = wait_interval) && interval > 0 81 | # We cannot assume that sleep will wait for the specified time, it might be +/- a bit. 82 | sleep interval 83 | end 84 | end 85 | 86 | fire 87 | end 88 | 89 | # Interval to wait until when the next timer will fire. 90 | # - nil: no timers 91 | # - -ve: timers expired already 92 | # - 0: timers ready to fire 93 | # - +ve: timers waiting to fire 94 | def wait_interval(offset = current_offset) 95 | if handle = @events.first 96 | handle.time - Float(offset) 97 | end 98 | end 99 | 100 | # Fire all timers that are ready. 101 | def fire(offset = current_offset) 102 | @events.fire(offset) 103 | end 104 | 105 | # Pause all timers. 106 | def pause 107 | @timers.dup.each(&:pause) 108 | end 109 | 110 | # Resume all timers. 111 | def resume 112 | @paused_timers.dup.each(&:resume) 113 | end 114 | 115 | alias continue resume 116 | 117 | # Delay all timers. 118 | def delay(seconds) 119 | @timers.each do |timer| 120 | timer.delay(seconds) 121 | end 122 | end 123 | 124 | # Cancel all timers. 125 | def cancel 126 | @timers.dup.each(&:cancel) 127 | end 128 | 129 | # The group's current time. 130 | def current_offset 131 | @interval.to_f 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/timers/interval.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2022, by Samuel Williams. 5 | 6 | module Timers 7 | # A collection of timers which may fire at different times 8 | class Interval 9 | # Get the current elapsed monotonic time. 10 | def initialize 11 | @total = 0.0 12 | @current = nil 13 | end 14 | 15 | def start 16 | return if @current 17 | 18 | @current = now 19 | end 20 | 21 | def stop 22 | return unless @current 23 | 24 | @total += duration 25 | 26 | @current = nil 27 | end 28 | 29 | def to_f 30 | @total + duration 31 | end 32 | 33 | protected def duration 34 | now - @current 35 | end 36 | 37 | protected def now 38 | ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/timers/priority_heap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021, by Wander Hillen. 5 | # Copyright, 2021-2025, by Samuel Williams. 6 | 7 | module Timers 8 | # A priority queue implementation using a standard binary minheap. It uses straight comparison 9 | # of its contents to determine priority. This works because a Handle from Timers::Events implements 10 | # the '<' operation by comparing the expiry time. 11 | # See for explanations of the main methods. 12 | class PriorityHeap 13 | def initialize 14 | # The heap is represented with an array containing a binary tree. See 15 | # https://en.wikipedia.org/wiki/Binary_heap#Heap_implementation for how this array 16 | # is built up. 17 | @contents = [] 18 | end 19 | 20 | # Returns the earliest timer or nil if the heap is empty. 21 | def peek 22 | @contents[0] 23 | end 24 | 25 | # Returns the number of elements in the heap 26 | def size 27 | @contents.size 28 | end 29 | 30 | # Returns the earliest timer if the heap is non-empty and removes it from the heap. 31 | # Returns nil if the heap is empty. (and doesn't change the heap in that case) 32 | def pop 33 | # If the heap is empty: 34 | if @contents.empty? 35 | return nil 36 | end 37 | 38 | # If we have only one item, no swapping is required: 39 | if @contents.size == 1 40 | return @contents.pop 41 | end 42 | 43 | # Take the root of the tree: 44 | value = @contents[0] 45 | 46 | # Remove the last item in the tree: 47 | last = @contents.pop 48 | 49 | # Overwrite the root of the tree with the item: 50 | @contents[0] = last 51 | 52 | # Bubble it down into place: 53 | bubble_down(0) 54 | 55 | # validate! 56 | 57 | return value 58 | end 59 | 60 | # Inserts a new timer into the heap, then rearranges elements until the heap invariant is true again. 61 | def push(element) 62 | # Insert the item at the end of the heap: 63 | @contents.push(element) 64 | 65 | # Bubble it up into position: 66 | bubble_up(@contents.size - 1) 67 | 68 | # validate! 69 | 70 | return self 71 | end 72 | 73 | # Empties out the heap, discarding all elements 74 | def clear! 75 | @contents = [] 76 | end 77 | 78 | # Validate the heap invariant. Every element except the root must not be smaller than 79 | # its parent element. Note that it MAY be equal. 80 | def valid? 81 | # notice we skip index 0 on purpose, because it has no parent 82 | (1..(@contents.size - 1)).all? { |e| @contents[e] >= @contents[(e - 1) / 2] } 83 | end 84 | 85 | private 86 | 87 | # Left here for reference, but unused. 88 | # def swap(i, j) 89 | # @contents[i], @contents[j] = @contents[j], @contents[i] 90 | # end 91 | 92 | def bubble_up(index) 93 | parent_index = (index - 1) / 2 # watch out, integer division! 94 | 95 | while index > 0 && @contents[index] < @contents[parent_index] 96 | # if the node has a smaller value than its parent, swap these nodes 97 | # to uphold the minheap invariant and update the index of the 'current' 98 | # node. If the node is already at index 0, we can also stop because that 99 | # is the root of the heap. 100 | # swap(index, parent_index) 101 | @contents[index], @contents[parent_index] = @contents[parent_index], @contents[index] 102 | 103 | index = parent_index 104 | parent_index = (index - 1) / 2 # watch out, integer division! 105 | end 106 | end 107 | 108 | def bubble_down(index) 109 | swap_value = 0 110 | swap_index = nil 111 | 112 | while true 113 | left_index = (2 * index) + 1 114 | left_value = @contents[left_index] 115 | 116 | if left_value.nil? 117 | # This node has no children so it can't bubble down any further. 118 | # We're done here! 119 | return 120 | end 121 | 122 | # Determine which of the child nodes has the smallest value: 123 | right_index = left_index + 1 124 | right_value = @contents[right_index] 125 | 126 | if right_value.nil? or right_value > left_value 127 | swap_value = left_value 128 | swap_index = left_index 129 | else 130 | swap_value = right_value 131 | swap_index = right_index 132 | end 133 | 134 | if @contents[index] < swap_value 135 | # No need to swap, the minheap invariant is already satisfied: 136 | return 137 | else 138 | # 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: 139 | # swap(index, swap_index) 140 | @contents[index], @contents[swap_index] = @contents[swap_index], @contents[index] 141 | 142 | index = swap_index 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/timers/timer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2017, by Tony Arcieri. 6 | # Copyright, 2014, by Utenmiki. 7 | # Copyright, 2014, by Lin Jen-Shin. 8 | # Copyright, 2017, by Vít Ondruch. 9 | # Copyright, 2025, by Patrik Wenger. 10 | 11 | module Timers 12 | # An individual timer set to fire a given proc at a given time. A timer is 13 | # always connected to a Timer::Group but it would ONLY be in @group.timers 14 | # if it also has a @handle specified. Otherwise it is either PAUSED or has 15 | # been FIRED and is not recurring. You can manually enter this state by 16 | # calling #cancel and resume normal operation by calling #reset. 17 | class Timer 18 | include Comparable 19 | attr_reader :interval, :offset, :recurring 20 | 21 | def initialize(group, interval, recurring = false, offset = nil, &block) 22 | @group = group 23 | 24 | @interval = interval 25 | @recurring = recurring 26 | @block = block 27 | @offset = nil 28 | @handle = nil 29 | 30 | # If a start offset was supplied, use that, otherwise use the current timers offset. 31 | reset(offset || @group.current_offset) 32 | end 33 | 34 | def paused? 35 | @group.paused_timers.include? self 36 | end 37 | 38 | def pause 39 | return if paused? 40 | 41 | @group.timers.delete self 42 | @group.paused_timers.add self 43 | 44 | @handle.cancel! if @handle 45 | @handle = nil 46 | end 47 | 48 | def resume 49 | return unless paused? 50 | 51 | @group.paused_timers.delete self 52 | 53 | # This will add us back to the group: 54 | reset 55 | end 56 | 57 | alias continue resume 58 | 59 | # Extend this timer 60 | def delay(seconds) 61 | @handle.cancel! if @handle 62 | 63 | @offset += seconds 64 | 65 | @handle = @group.events.schedule(@offset, self) 66 | end 67 | 68 | # Cancel this timer. Do not call while paused. 69 | def cancel 70 | return unless @handle 71 | 72 | @handle.cancel! if @handle 73 | @handle = nil 74 | 75 | # This timer is no longer valid: 76 | @group.timers.delete(self) if @group 77 | end 78 | 79 | # Reset this timer. Do not call while paused. 80 | # @param offset [Numeric] the duration to add to the timer. 81 | def reset(offset = @group.current_offset) 82 | # This logic allows us to minimise the interaction with @group.timers. 83 | # A timer with a handle is always registered with the group. 84 | if @handle 85 | @handle.cancel! 86 | else 87 | @group.timers << self 88 | end 89 | 90 | @offset = Float(offset) + @interval 91 | 92 | @handle = @group.events.schedule(@offset, self) 93 | end 94 | 95 | # Fire the block. 96 | def fire(offset = @group.current_offset) 97 | if recurring == :strict 98 | # ... make the next interval strictly the last offset + the interval: 99 | reset(@offset) 100 | elsif recurring 101 | reset(offset) 102 | else 103 | @offset = offset 104 | end 105 | 106 | result = @block.call(offset, self) 107 | cancel unless recurring 108 | result 109 | end 110 | 111 | alias call fire 112 | 113 | # Number of seconds until next fire / since last fire 114 | def fires_in 115 | @offset - @group.current_offset if @offset 116 | end 117 | 118 | # Inspect a timer 119 | def inspect 120 | buffer = to_s[0..-2] 121 | 122 | if @offset 123 | delta_offset = @offset - @group.current_offset 124 | 125 | if delta_offset > 0 126 | buffer << " fires in #{delta_offset} seconds" 127 | else 128 | buffer << " fired #{delta_offset.abs} seconds ago" 129 | end 130 | 131 | buffer << ", recurs every #{interval}" if recurring 132 | end 133 | 134 | buffer << ">" 135 | 136 | return buffer 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/timers/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2016, by Tony Arcieri. 5 | # Copyright, 2014-2022, by Samuel Williams. 6 | # Copyright, 2015, by Donovan Keme. 7 | 8 | module Timers 9 | VERSION = "4.4.0" 10 | end 11 | -------------------------------------------------------------------------------- /lib/timers/wait.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2015, by Utenmiki. 7 | # Copyright, 2015, by Donovan Keme. 8 | 9 | require_relative "interval" 10 | 11 | module Timers 12 | # An exclusive, monotonic timeout class. 13 | class Wait 14 | def self.for(duration, &block) 15 | if duration 16 | timeout = new(duration) 17 | 18 | timeout.while_time_remaining(&block) 19 | else 20 | # If there is no "duration" to wait for, we wait forever. 21 | loop do 22 | yield(nil) 23 | end 24 | end 25 | end 26 | 27 | def initialize(duration) 28 | @duration = duration 29 | @remaining = true 30 | end 31 | 32 | attr_reader :duration 33 | attr_reader :remaining 34 | 35 | # Yields while time remains for work to be done: 36 | def while_time_remaining 37 | @interval = Interval.new 38 | @interval.start 39 | 40 | yield @remaining while time_remaining? 41 | ensure 42 | @interval.stop 43 | @interval = nil 44 | end 45 | 46 | private 47 | 48 | def time_remaining? 49 | @remaining = (@duration - @interval.to_f) 50 | 51 | @remaining > 0 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2012-2017, by Tony Arcieri. 4 | Copyright, 2012, by Ryan LeCompte. 5 | Copyright, 2012, by Jesse Cooke. 6 | Copyright, 2012, by Nicholas Evans. 7 | Copyright, 2012, by Dimitrij Denissenko. 8 | Copyright, 2013, by Chuck Remes. 9 | Copyright, 2013, by Ron Evans. 10 | Copyright, 2013, by Sean Gregory. 11 | Copyright, 2013-2015, by Utenmiki. 12 | Copyright, 2013, by Jeremy Hinegardner. 13 | Copyright, 2014, by Larry Lv. 14 | Copyright, 2014, by Bruno Enten. 15 | Copyright, 2014-2025, by Samuel Williams. 16 | Copyright, 2014, by Mike Bourgeous. 17 | Copyright, 2014, by Klaus Trainer. 18 | Copyright, 2014, by Lin Jen-Shin. 19 | Copyright, 2014, by Lavir the Whiolet. 20 | Copyright, 2015-2016, by Donovan Keme. 21 | Copyright, 2015, by Tommy Ong Gia Phu. 22 | Copyright, 2015, by Will Jessop. 23 | Copyright, 2016, by Ryunosuke Sato. 24 | Copyright, 2016, by Atul Bhosale. 25 | Copyright, 2017, by Vít Ondruch. 26 | Copyright, 2017-2020, by Olle Jonsson. 27 | Copyright, 2020, by Tim Smith. 28 | Copyright, 2021, by Wander Hillen. 29 | Copyright, 2022, by Yoshiki Takagi. 30 | Copyright, 2023, by Peter Goldstein. 31 | Copyright, 2025, by Patrik Wenger. 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy 34 | of this software and associated documentation files (the "Software"), to deal 35 | in the Software without restriction, including without limitation the rights 36 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 37 | copies of the Software, and to permit persons to whom the Software is 38 | furnished to do so, subject to the following conditions: 39 | 40 | The above copyright notice and this permission notice shall be included in all 41 | copies or substantial portions of the Software. 42 | 43 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 44 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 45 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 46 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 47 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 48 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 49 | SOFTWARE. 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Timers 2 | 3 | Collections of one-shot and periodic timers, intended for use with event loops such as [async](https://github.com/socketry/async). 4 | 5 | [![Development Status](https://github.com/socketry/timers/workflows/Test/badge.svg)](https://github.com/socketry/timers/actions?workflow=Test) 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ``` ruby 12 | gem 'timers' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install timers 22 | 23 | ## Usage 24 | 25 | Create a new timer group with `Timers::Group.new`: 26 | 27 | ``` ruby 28 | require 'timers' 29 | 30 | timers = Timers::Group.new 31 | ``` 32 | 33 | Schedule a proc to run after 5 seconds with `Timers::Group#after`: 34 | 35 | ``` ruby 36 | five_second_timer = timers.after(5) { puts "Take five" } 37 | ``` 38 | 39 | The `five_second_timer` variable is now bound to a Timers::Timer object. To 40 | cancel a timer, use `Timers::Timer#cancel` 41 | 42 | Once you've scheduled a timer, you can wait until the next timer fires with `Timers::Group#wait`: 43 | 44 | ``` ruby 45 | # Waits 5 seconds 46 | timers.wait 47 | 48 | # The script will now print "Take five" 49 | ``` 50 | 51 | You can schedule a block to run periodically with `Timers::Group#every`: 52 | 53 | ``` ruby 54 | every_five_seconds = timers.every(5) { puts "Another 5 seconds" } 55 | 56 | loop { timers.wait } 57 | ``` 58 | 59 | You can also schedule a block to run immediately and periodically with `Timers::Group#now_and_every`: 60 | 61 | ``` ruby 62 | now_and_every_five_seconds = timers.now_and_every(5) { puts "Now and in another 5 seconds" } 63 | 64 | loop { timers.wait } 65 | ``` 66 | 67 | If you'd like another method to do the waiting for you, e.g. `Kernel.select`, 68 | you can use `Timers::Group#wait_interval` to obtain the amount of time to wait. When 69 | a timeout is encountered, you can fire all pending timers with `Timers::Group#fire`: 70 | 71 | ``` ruby 72 | loop do 73 | interval = timers.wait_interval 74 | ready_readers, ready_writers = select readers, writers, nil, interval 75 | 76 | if ready_readers || ready_writers 77 | # Handle IO 78 | ... 79 | else 80 | # Timeout! 81 | timers.fire 82 | end 83 | end 84 | ``` 85 | 86 | You can also pause and continue individual timers, or all timers: 87 | 88 | ``` ruby 89 | paused_timer = timers.every(5) { puts "I was paused" } 90 | 91 | paused_timer.pause 92 | 10.times { timers.wait } # will not fire paused timer 93 | 94 | paused_timer.resume 95 | 10.times { timers.wait } # will fire timer 96 | 97 | timers.pause 98 | 10.times { timers.wait } # will not fire any timers 99 | 100 | timers.resume 101 | 10.times { timers.wait } # will fire all timers 102 | ``` 103 | 104 | ## Contributing 105 | 106 | We welcome contributions to this project. 107 | 108 | 1. Fork it. 109 | 2. Create your feature branch (`git checkout -b my-new-feature`). 110 | 3. Commit your changes (`git commit -am 'Add some feature'`). 111 | 4. Push to the branch (`git push origin my-new-feature`). 112 | 5. Create new Pull Request. 113 | 114 | ### Developer Certificate of Origin 115 | 116 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 117 | 118 | ### Community Guidelines 119 | 120 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 121 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /test/timers/events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | 7 | require "timers/events" 8 | 9 | describe Timers::Events do 10 | let(:events) {subject.new} 11 | 12 | it "should register an event" do 13 | fired = false 14 | 15 | callback = proc do |_time| 16 | fired = true 17 | end 18 | 19 | events.schedule(0.1, callback) 20 | 21 | expect(events.size).to be == 1 22 | 23 | events.fire(0.15) 24 | 25 | expect(events.size).to be == 0 26 | 27 | expect(fired).to be == true 28 | end 29 | 30 | it "should register events in order" do 31 | fired = [] 32 | 33 | times = [0.95, 0.1, 0.3, 0.5, 0.4, 0.2, 0.01, 0.9] 34 | 35 | times.each do |requested_time| 36 | callback = proc do |_time| 37 | fired << requested_time 38 | end 39 | 40 | events.schedule(requested_time, callback) 41 | end 42 | 43 | events.fire(0.5) 44 | expect(fired).to be == times.sort.first(6) 45 | 46 | events.fire(1.0) 47 | expect(fired).to be == times.sort 48 | end 49 | 50 | it "should fire events with the time they were fired at" do 51 | fired_at = :not_fired 52 | 53 | callback = proc do |time| 54 | # The time we actually were fired at: 55 | fired_at = time 56 | end 57 | 58 | events.schedule(0.5, callback) 59 | 60 | events.fire(1.0) 61 | 62 | expect(fired_at).to be == 1.0 63 | end 64 | 65 | it "should flush cancelled events" do 66 | callback = proc{} 67 | 68 | 10.times do 69 | handle = events.schedule(0.1, callback) 70 | handle.cancel! 71 | end 72 | 73 | expect(events.size).to be == 1 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/timers/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2017, by Tony Arcieri. 5 | # Copyright, 2012, by Jesse Cooke. 6 | # Copyright, 2012, by Dimitrij Denissenko. 7 | # Copyright, 2013, by Chuck Remes. 8 | # Copyright, 2013, by Ron Evans. 9 | # Copyright, 2013, by Sean Gregory. 10 | # Copyright, 2013-2014, by Utenmiki. 11 | # Copyright, 2013, by Jeremy Hinegardner. 12 | # Copyright, 2014, by Bruno Enten. 13 | # Copyright, 2014-2025, by Samuel Williams. 14 | # Copyright, 2017, by Vít Ondruch. 15 | 16 | require "timers/group" 17 | require "timer_quantum" 18 | 19 | describe Timers::Group do 20 | let(:group) {subject.new} 21 | 22 | with "#wait" do 23 | it "calls the wait block with nil" do 24 | called = false 25 | 26 | group.wait do |interval| 27 | expect(interval).to be_nil 28 | called = true 29 | end 30 | 31 | expect(called).to be == true 32 | end 33 | 34 | it "calls the wait block with an interval" do 35 | called = false 36 | fired = false 37 | 38 | group.after(0.1) { fired = true } 39 | 40 | group.wait do |interval| 41 | expect(interval).to be_within(TIMER_QUANTUM).of(0.1) 42 | called = true 43 | sleep 0.2 44 | end 45 | 46 | expect(called).to be == true 47 | expect(fired).to be == true 48 | end 49 | 50 | it "repeatedly calls the wait block if it sleeps less than the interval" do 51 | called = 0 52 | fired = false 53 | 54 | group.after(0.1) { fired = true } 55 | 56 | group.wait do |interval| 57 | called += 1 58 | sleep(0.01) 59 | end 60 | 61 | expect(called).to be > 1 62 | expect(fired).to be == true 63 | end 64 | end 65 | 66 | it "sleeps until the next timer" do 67 | interval = 0.1 68 | 69 | fired = false 70 | group.after(interval) {fired = true} 71 | group.wait 72 | 73 | expect(fired).to be == true 74 | end 75 | 76 | it "fires instantly when next timer is in the past" do 77 | fired = false 78 | group.after(TIMER_QUANTUM) { fired = true } 79 | sleep(TIMER_QUANTUM * 2) 80 | group.wait 81 | 82 | expect(fired).to be == true 83 | end 84 | 85 | it "calculates the interval until the next timer should fire" do 86 | interval = 0.1 87 | 88 | group.after(interval) 89 | expect(group.wait_interval).to be_within(TIMER_QUANTUM).of interval 90 | 91 | sleep(interval) 92 | expect(group.wait_interval).to be <= 0 93 | end 94 | 95 | it "fires timers in the correct order" do 96 | result = [] 97 | 98 | group.after(TIMER_QUANTUM * 2) { result << :two } 99 | group.after(TIMER_QUANTUM * 3) { result << :three } 100 | group.after(TIMER_QUANTUM * 1) { result << :one } 101 | 102 | sleep(TIMER_QUANTUM * 4) 103 | group.fire 104 | 105 | expect(result).to be == [:one, :two, :three] 106 | end 107 | 108 | it "raises TypeError if given an invalid time" do 109 | expect do 110 | group.after(nil) { nil } 111 | end.to raise_exception(TypeError) 112 | end 113 | 114 | with "#now_and_after" do 115 | it "fires the timer immediately" do 116 | result = [] 117 | 118 | group.now_and_after(TIMER_QUANTUM * 2) { result << :foo } 119 | 120 | expect(result).to be == [:foo] 121 | end 122 | 123 | it "fires the timer at the correct time" do 124 | result = [] 125 | 126 | group.now_and_after(TIMER_QUANTUM * 2) { result << :foo } 127 | 128 | group.wait 129 | 130 | expect(result).to be == [:foo, :foo] 131 | end 132 | end 133 | 134 | with "recurring timers" do 135 | it "continues to fire the timers at each interval" do 136 | result = [] 137 | 138 | group.every(TIMER_QUANTUM * 2) { result << :foo } 139 | 140 | sleep TIMER_QUANTUM * 3 141 | group.fire 142 | expect(result).to be == [:foo] 143 | 144 | sleep TIMER_QUANTUM * 5 145 | group.fire 146 | expect(result).to be == [:foo, :foo] 147 | end 148 | end 149 | 150 | it "calculates the proper interval to wait until firing" do 151 | interval_ms = 25 152 | 153 | group.after(interval_ms / 1000.0) 154 | 155 | expect(group.wait_interval).to be_within(TIMER_QUANTUM).of(interval_ms / 1000.0) 156 | end 157 | 158 | with "delay timer" do 159 | it "adds appropriate amount of time to timer" do 160 | timer = group.after(10) 161 | timer.delay(5) 162 | expect(timer.offset - group.current_offset).to be_within(TIMER_QUANTUM).of(15) 163 | end 164 | end 165 | 166 | with "delay timer collection" do 167 | it "delay on set adds appropriate amount of time to all timers" do 168 | timer = group.after(10) 169 | timer2 = group.after(20) 170 | group.delay(5) 171 | expect(timer.offset - group.current_offset).to be_within(TIMER_QUANTUM).of(15) 172 | expect(timer2.offset - group.current_offset).to be_within(TIMER_QUANTUM).of(25) 173 | end 174 | end 175 | 176 | with "on delaying a timer" do 177 | it "fires timers in the correct order" do 178 | result = [] 179 | 180 | group.after(TIMER_QUANTUM * 2) { result << :two } 181 | group.after(TIMER_QUANTUM * 3) { result << :three } 182 | first = group.after(TIMER_QUANTUM * 1) { result << :one } 183 | first.delay(TIMER_QUANTUM * 3) 184 | 185 | sleep TIMER_QUANTUM * 5 186 | group.fire 187 | 188 | expect(result).to be == [:two, :three, :one] 189 | end 190 | end 191 | 192 | with "#inspect" do 193 | it "before firing" do 194 | fired = false 195 | timer = group.after(TIMER_QUANTUM * 5) { fired = true } 196 | timer.pause 197 | expect(fired).not.to be == true 198 | expect(timer.inspect).to be =~ /\A#\Z/ 199 | end 200 | 201 | it "after firing" do 202 | fired = false 203 | timer = group.after(TIMER_QUANTUM) { fired = true } 204 | 205 | group.wait 206 | 207 | expect(fired).to be == true 208 | expect(timer.inspect).to be =~/\A#\Z/ 209 | end 210 | 211 | it "recurring firing" do 212 | result = [] 213 | timer = group.every(TIMER_QUANTUM) { result << :foo } 214 | 215 | group.wait 216 | expect(result).to be(:any?) 217 | regex = /\A#\Z/ 218 | expect(timer.inspect).to be =~ regex 219 | end 220 | end 221 | 222 | with "#fires_in" do 223 | let(:interval) {0.01} 224 | 225 | with "recurring timer" do 226 | let(:timer) {group.every(interval){true}} 227 | 228 | it "calculates the interval until the next fire if it's recurring" do 229 | expect(timer.fires_in).to be_within(TIMER_QUANTUM).of(interval) 230 | end 231 | end 232 | 233 | with "non-recurring timer" do 234 | let(:timer) {group.after(interval){true}} 235 | 236 | it "calculates the interval until the next fire if it hasn't already fired" do 237 | expect(timer.fires_in).to be_within(TIMER_QUANTUM).of(interval) 238 | end 239 | 240 | it "calculates the interval since last fire if already fired" do 241 | # Create the timer: 242 | timer 243 | 244 | group.wait 245 | 246 | sleep(TIMER_QUANTUM) 247 | 248 | expect(timer.fires_in).to be < 0.0 249 | end 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/timers/group/cancel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014, by Lin Jen-Shin. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2014-2025, by Samuel Williams. 7 | 8 | require "timers/group" 9 | 10 | describe Timers::Group do 11 | let(:group) {subject.new} 12 | 13 | it "can cancel a timer" do 14 | fired = false 15 | 16 | timer = group.after(0.1) { fired = true } 17 | timer.cancel 18 | 19 | group.wait 20 | 21 | expect(fired).to be == false 22 | end 23 | 24 | it "should be able to cancel twice" do 25 | fired = false 26 | 27 | timer = group.after(0.1) { fired = true } 28 | 29 | 2.times do 30 | timer.cancel 31 | group.wait 32 | end 33 | 34 | expect(fired).to be == false 35 | end 36 | 37 | it "should be possble to reset after cancel" do 38 | fired = false 39 | 40 | timer = group.after(0.1) { fired = true } 41 | timer.cancel 42 | 43 | group.wait 44 | 45 | timer.reset 46 | 47 | group.wait 48 | 49 | expect(fired).to be == true 50 | end 51 | 52 | it "should cancel and remove one shot timers after they fire" do 53 | x = 0 54 | 55 | Timers::Wait.for(2) do |_remaining| 56 | timer = group.every(0.2) { x += 1 } 57 | group.after(0.1) { timer.cancel } 58 | 59 | group.wait 60 | end 61 | 62 | expect(group.timers).to be(:empty?) 63 | expect(x).to be == 0 64 | end 65 | 66 | with "#cancel" do 67 | it "should cancel all timers" do 68 | timers = 3.times.map do 69 | group.every(0.1) {} 70 | end 71 | 72 | expect(group.timers).not.to be(:empty?) 73 | 74 | group.cancel 75 | 76 | expect(group.timers).to be(:empty?) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/timers/group/every.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2015, by Tommy Ong Gia Phu. 7 | # Copyright, 2015, by Donovan Keme. 8 | 9 | require "timers/group" 10 | 11 | describe Timers::Group do 12 | let(:group) {subject.new} 13 | 14 | it "should fire several times" do 15 | result = [] 16 | 17 | group.every(0.7) { result << :a } 18 | group.every(2.3) { result << :b } 19 | group.every(1.3) { result << :c } 20 | group.every(2.4) { result << :d } 21 | 22 | Timers::Wait.for(2.5) do |remaining| 23 | group.wait if group.wait_interval < remaining 24 | end 25 | 26 | expect(result).to be == [:a, :c, :a, :a, :b, :d] 27 | end 28 | 29 | it "should fire immediately and then several times later" do 30 | result = [] 31 | 32 | group.every(0.7) { result << :a } 33 | group.every(2.3) { result << :b } 34 | group.now_and_every(1.3) { result << :c } 35 | group.now_and_every(2.4) { result << :d } 36 | 37 | Timers::Wait.for(2.5) do |remaining| 38 | group.wait if group.wait_interval < remaining 39 | end 40 | 41 | expect(result).to be == [:c, :d, :a, :c, :a, :a, :b, :d] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/timers/group/pause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2025, by Samuel Williams. 5 | 6 | require "timers/group" 7 | 8 | describe Timers::Group do 9 | let(:group) {subject.new} 10 | let(:interval) {0.01} 11 | 12 | def before 13 | @fired = false 14 | @timer = group.after(interval) {@fired = true} 15 | 16 | @fired2 = false 17 | @timer2 = group.after(interval) {@fired2 = true} 18 | 19 | super 20 | end 21 | 22 | it "does not fire when paused" do 23 | @timer.pause 24 | group.wait 25 | expect(@fired).to be == false 26 | end 27 | 28 | it "fires when continued after pause" do 29 | @timer.pause 30 | group.wait 31 | @timer.resume 32 | 33 | sleep(interval) 34 | group.wait 35 | 36 | expect(@fired).to be == true 37 | end 38 | 39 | it "can pause all timers at once" do 40 | group.pause 41 | group.wait 42 | 43 | expect(@fired).to be == false 44 | expect(@fired2).to be == false 45 | end 46 | 47 | it "can continue all timers at once" do 48 | group.pause 49 | group.wait 50 | group.resume 51 | 52 | sleep(interval + TIMER_QUANTUM) 53 | group.wait 54 | 55 | expect(@fired).to be == true 56 | expect(@fired2).to be == true 57 | end 58 | 59 | it "can fire the timer directly" do 60 | @timer.pause 61 | 62 | group.wait 63 | expect(@fired).not.to be == true 64 | 65 | @timer.resume 66 | expect(@fired).not.to be == true 67 | 68 | @timer.fire 69 | expect(@fired).to be == true 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/timers/performance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | # Copyright, 2015, by Donovan Keme. 7 | # Copyright, 2021, by Wander Hillen. 8 | 9 | # Event based timers: 10 | 11 | # Serviced 31812 events in 2.39075272 seconds, 13306.320832794887 e/s. 12 | # Thread ID: 7336700 13 | # Fiber ID: 30106340 14 | # Total: 2.384043 15 | # Sort by: self_time 16 | 17 | # %self total self wait child calls name 18 | # 13.48 0.510 0.321 0.000 0.189 369133 Timers::Events::Handle#<=> 19 | # 8.12 0.194 0.194 0.000 0.000 427278 Timers::Events::Handle#to_f 20 | # 4.55 0.109 0.109 0.000 0.000 427278 Float#<=> 21 | # 4.40 1.857 0.105 0.000 1.752 466376 *Timers::Events#bsearch 22 | # 4.30 0.103 0.103 0.000 0.000 402945 Float#to_f 23 | # 2.65 0.063 0.063 0.000 0.000 33812 Array#insert 24 | # 2.64 1.850 0.063 0.000 1.787 33812 Timers::Events#schedule 25 | # 2.40 1.930 0.057 0.000 1.873 33812 Timers::Timer#reset 26 | # 1.89 1.894 0.045 0.000 1.849 31812 Timers::Timer#fire 27 | # 1.69 1.966 0.040 0.000 1.926 31812 Timers::Events::Handle#fire 28 | # 1.35 0.040 0.032 0.000 0.008 33812 Timers::Events::Handle#initialize 29 | # 1.29 0.044 0.031 0.000 0.013 44451 Timers::Group#current_offset 30 | 31 | # SortedSet based timers: 32 | 33 | # Serviced 32516 events in 66.753277275 seconds, 487.1072288781219 e/s. 34 | # Thread ID: 15995640 35 | # Fiber ID: 38731780 36 | # Total: 66.716394 37 | # Sort by: self_time 38 | 39 | # %self total self wait child calls name 40 | # 54.73 49.718 36.513 0.000 13.205 57084873 Timers::Timer#<=> 41 | # 23.74 65.559 15.841 0.000 49.718 32534 Array#sort! 42 | # 19.79 13.205 13.205 0.000 0.000 57084873 Float#<=> 43 | 44 | # Max out events performance (on my computer): 45 | # Serviced 1142649 events in 11.194903921 seconds, 102068.70405115146 e/s. 46 | 47 | require "timers/group" 48 | 49 | describe Timers::Group do 50 | let(:group) {subject.new} 51 | 52 | with "profiler" do 53 | if defined? RubyProf 54 | def before 55 | # Running RubyProf makes the code slightly slower. 56 | RubyProf.start 57 | puts "*** Running with RubyProf reduces performance ***" 58 | 59 | super 60 | end 61 | 62 | def after 63 | super 64 | 65 | if RubyProf.running? 66 | # file = arg.metadata[:description].gsub(/\s+/, '-') 67 | 68 | result = RubyProf.stop 69 | 70 | printer = RubyProf::FlatPrinter.new(result) 71 | printer.print($stderr, min_percent: 1.0) 72 | end 73 | end 74 | end 75 | 76 | it "runs efficiently" do 77 | result = [] 78 | range = (1..500) 79 | duration = 2.0 80 | 81 | total = 0 82 | range.each do |index| 83 | offset = index.to_f / range.max 84 | total += (duration / offset).floor 85 | 86 | group.every(index.to_f / range.max, :strict) { result << index } 87 | end 88 | 89 | group.wait while result.size < total 90 | 91 | rate = result.size.to_f / group.current_offset 92 | inform "Serviced #{result.size} events in #{group.current_offset} seconds, #{rate} e/s." 93 | 94 | expect(group.current_offset).to be_within(20).percent_of(duration) 95 | end 96 | end 97 | 98 | it "runs efficiently at high volume" do 99 | results = [] 100 | range = (1..300) 101 | groups = (1..20) 102 | duration = 2.0 103 | 104 | timers = [] 105 | @mutex = Mutex.new 106 | start = Time.now 107 | 108 | groups.each do 109 | timers << Thread.new do 110 | result = [] 111 | timer = Timers::Group.new 112 | total = 0 113 | 114 | range.each do |index| 115 | offset = index.to_f / range.max 116 | total += (duration / offset).floor 117 | timer.every(index.to_f / range.max, :strict) { result << index } 118 | end 119 | 120 | timer.wait while result.size < total 121 | @mutex.synchronize { results += result } 122 | end 123 | end 124 | 125 | timers.each { |t| t.join } 126 | finish = Time.now 127 | 128 | runtime = finish - start 129 | rate = results.size.to_f / runtime 130 | 131 | inform "Serviced #{results.size} events in #{runtime} seconds, #{rate} e/s; across #{groups.max} timers." 132 | 133 | expect(runtime).to be_within(20).percent_of(duration) 134 | end 135 | 136 | it "copes with very large amounts of timers" do 137 | # This spec tries to emulate (as best as possible) the timer characteristics of the 138 | # following scenario: 139 | # - a fairly busy Falcon server serving a constant stream of request that spend most of their time 140 | # in a long database call. Both the web request and the db call have a timeout attached 141 | # - there will already exist a lot of timers in the queue and more are added all the time 142 | # - the server is assumed to be busy so there are "always" new requests waiting to be accept()-ed 143 | # and thus the server spends relatively little time actually sleeping and most of its time in 144 | # either the reactor or an active fiber. 145 | # - On each loop of the reactor it will run any fibers in the ready queue, accept any waiting 146 | # requests on the server socket and then call wait_interval to see if there are any expired 147 | # timeouts that need to be handled. 148 | 149 | # Result for PriorityHeap based timer queue: Inserted 20k timers in 0.055050924 seconds 150 | # Result for Array based timer queue: Inserted 20k timers in 0.141001845 seconds 151 | 152 | results = [] 153 | 154 | # Prefill the timer queue with a lot of timers in the semidistant future 155 | 20000.times do 156 | group.after(10) { results << "yay!" } 157 | end 158 | 159 | # add one timer which is done immediately, to get the pending array into the queue 160 | group.after(-1) { results << "I am first!" } 161 | group.wait 162 | expect(results.size).to be == 1 163 | expect(results.first).to be == "I am first!" 164 | 165 | # 20k extra requests come in and get added into the queue 166 | start = Time.now 167 | 168 | 20000.times do 169 | # add new timer to the queue (later than all the others so far) 170 | group.after(15) { result << "yay again!" } 171 | # wait_interval in the reactor loop 172 | group.wait_interval() 173 | end 174 | 175 | expect(group.events.size).to be == 40_000 176 | inform "Inserted 20k timers in #{Time.now - start} seconds" 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/timers/priority_heap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021, by Wander Hillen. 5 | # Copyright, 2021-2025, by Samuel Williams. 6 | 7 | require "timers/priority_heap" 8 | 9 | describe Timers::PriorityHeap do 10 | let(:priority_heap) {subject.new} 11 | 12 | with "empty heap" do 13 | it "should return nil when the first element is requested" do 14 | expect(priority_heap.peek).to be_nil 15 | end 16 | 17 | it "should return nil when the first element is extracted" do 18 | expect(priority_heap.pop).to be_nil 19 | end 20 | 21 | it "should report its size as zero" do 22 | expect(priority_heap.size).to be(:zero?) 23 | end 24 | end 25 | 26 | it "returns the same element after inserting a single element" do 27 | priority_heap.push(1) 28 | expect(priority_heap.size).to be == 1 29 | expect(priority_heap.pop).to be == 1 30 | expect(priority_heap.size).to be(:zero?) 31 | end 32 | 33 | it "should return inserted elements in ascending order no matter the insertion order" do 34 | (1..10).to_a.shuffle.each do |e| 35 | priority_heap.push(e) 36 | end 37 | 38 | expect(priority_heap.size).to be == 10 39 | expect(priority_heap.peek).to be == 1 40 | 41 | result = [] 42 | 10.times do 43 | result << priority_heap.pop 44 | end 45 | 46 | expect(result.size).to be == 10 47 | expect(priority_heap.size).to be(:zero?) 48 | expect(result.sort).to be == result 49 | end 50 | 51 | with "maintaining the heap invariant" do 52 | it "for empty heaps" do 53 | expect(priority_heap).to be(:valid?) 54 | end 55 | 56 | it "for heap of size 1" do 57 | priority_heap.push(123) 58 | expect(priority_heap).to be(:valid?) 59 | end 60 | # Exhaustive testing of all permutations of [1..6] 61 | it "for all permutations of size 6" do 62 | [1,2,3,4,5,6].permutation do |arr| 63 | priority_heap.clear! 64 | arr.each { |e| priority_heap.push(e) } 65 | expect(priority_heap).to be(:valid?) 66 | end 67 | end 68 | 69 | # A few examples with more elements (but not ALL permutations) 70 | it "for larger amounts of values" do 71 | 5.times do 72 | priority_heap.clear! 73 | (1..1000).to_a.shuffle.each { |e| priority_heap.push(e) } 74 | expect(priority_heap).to be(:valid?) 75 | end 76 | end 77 | 78 | # What if we insert several of the same item along with others? 79 | it "with several elements of the same value" do 80 | test_values = (1..10).to_a + [4] * 5 81 | test_values.each { |e| priority_heap.push(e) } 82 | expect(priority_heap).to be(:valid?) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/timers/strict.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | 7 | require "timers/group" 8 | require "timer_quantum" 9 | 10 | describe Timers::Group do 11 | let(:group) {subject.new} 12 | 13 | it "should not diverge too much" do 14 | fired = :not_fired_yet 15 | count = 0 16 | quantum = 0.01 17 | 18 | start_offset = group.current_offset 19 | Timers::Timer.new(group, quantum, :strict, start_offset) do |offset| 20 | fired = offset 21 | count += 1 22 | end 23 | 24 | iterations = 100 25 | group.wait while count < iterations 26 | 27 | # In my testing on the JVM, without the :strict recurring, I noticed 60ms of error here. 28 | expect(fired - start_offset).to be_within(quantum + TIMER_QUANTUM).of(iterations * quantum) 29 | end 30 | 31 | it "should only fire 0-interval timer once per iteration" do 32 | count = 0 33 | 34 | start_offset = group.current_offset 35 | Timers::Timer.new(group, 0, :strict, start_offset) do |offset, timer| 36 | count += 1 37 | end 38 | 39 | group.wait 40 | 41 | expect(count).to be == 1 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/timers/timer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Patrik Wenger. 5 | # Copyright, 2025, by Samuel Williams. 6 | 7 | require "timers/timer" 8 | 9 | describe Timers::Timer do 10 | let(:group) {Timers::Group.new} 11 | 12 | it "should return the block value when fired" do 13 | timer = group.after(10) {:foo} 14 | result = timer.fire 15 | 16 | expect(result).to be == :foo 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/timers/wait.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2014-2025, by Samuel Williams. 5 | # Copyright, 2014-2016, by Tony Arcieri. 6 | 7 | require "timers/wait" 8 | require "timer_quantum" 9 | 10 | describe Timers::Wait do 11 | let(:interval) {0.1} 12 | let(:repeats) {10} 13 | 14 | it "repeats until timeout expired" do 15 | timeout = Timers::Wait.new(interval*repeats) 16 | count = 0 17 | previous_remaining = nil 18 | 19 | timeout.while_time_remaining do |remaining| 20 | if previous_remaining 21 | expect(remaining).to be_within(TIMER_QUANTUM).of(previous_remaining - interval) 22 | end 23 | 24 | previous_remaining = remaining 25 | 26 | count += 1 27 | sleep(interval) 28 | end 29 | 30 | expect(count).to be == repeats 31 | end 32 | 33 | it "yields results as soon as possible" do 34 | timeout = Timers::Wait.new(5) 35 | 36 | result = timeout.while_time_remaining do |_remaining| 37 | break :done 38 | end 39 | 40 | expect(result).to be == :done 41 | end 42 | 43 | with "#for" do 44 | with "no duration" do 45 | it "waits forever" do 46 | count = 0 47 | Timers::Wait.for(nil) do 48 | count += 1 49 | break if count > 10 50 | end 51 | 52 | expect(count).to be > 10 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /timers.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/timers/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "timers" 7 | spec.version = Timers::VERSION 8 | 9 | spec.summary = "Pure Ruby one-shot and periodic timers." 10 | spec.authors = ["Tony Arcieri", "Samuel Williams", "Donovan Keme", "Wander Hillen", "Utenmiki", "Jeremy Hinegardner", "Sean Gregory", "Chuck Remes", "Olle Jonsson", "Ron Evans", "Tommy Ong Gia Phu", "Larry Lv", "Lin Jen-Shin", "Ryunosuke Sato", "Atul Bhosale", "Bruno Enten", "Dimitrij Denissenko", "Jesse Cooke", "Klaus Trainer", "Lavir the Whiolet", "Mike Bourgeous", "Nicholas Evans", "Patrik Wenger", "Peter Goldstein", "Ryan LeCompte", "Tim Smith", "Vít Ondruch", "Will Jessop", "Yoshiki Takagi"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/timers" 17 | 18 | spec.metadata = { 19 | "source_code_uri" => "https://github.com/socketry/timers.git", 20 | } 21 | 22 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 23 | 24 | spec.required_ruby_version = ">= 3.1" 25 | end 26 | --------------------------------------------------------------------------------