timex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +11 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/LICENSE.txt +4 -0
  6. data/README.md +112 -0
  7. data/Rakefile +28 -0
  8. data/lib/generators/timex/install_generator.rb +21 -0
  9. data/lib/generators/timex/templates/install.rb +54 -0
  10. data/lib/timex/auto_check.rb +59 -0
  11. data/lib/timex/cancellation_token.rb +84 -0
  12. data/lib/timex/clock.rb +113 -0
  13. data/lib/timex/composers/adaptive.rb +222 -0
  14. data/lib/timex/composers/base.rb +20 -0
  15. data/lib/timex/composers/hedged.rb +146 -0
  16. data/lib/timex/composers/two_phase.rb +97 -0
  17. data/lib/timex/configuration.rb +163 -0
  18. data/lib/timex/deadline.rb +458 -0
  19. data/lib/timex/expired.rb +77 -0
  20. data/lib/timex/named_component.rb +33 -0
  21. data/lib/timex/on_timeout.rb +15 -0
  22. data/lib/timex/propagation/http_header.rb +49 -0
  23. data/lib/timex/propagation/rack_middleware.rb +180 -0
  24. data/lib/timex/registry.rb +132 -0
  25. data/lib/timex/result.rb +137 -0
  26. data/lib/timex/strategies/base.rb +88 -0
  27. data/lib/timex/strategies/closeable.rb +81 -0
  28. data/lib/timex/strategies/cooperative.rb +27 -0
  29. data/lib/timex/strategies/io.rb +247 -0
  30. data/lib/timex/strategies/ractor.rb +84 -0
  31. data/lib/timex/strategies/subprocess.rb +267 -0
  32. data/lib/timex/strategies/unsafe.rb +54 -0
  33. data/lib/timex/strategies/wakeup.rb +154 -0
  34. data/lib/timex/telemetry/adapters.rb +173 -0
  35. data/lib/timex/telemetry.rb +119 -0
  36. data/lib/timex/test/virtual_clock.rb +51 -0
  37. data/lib/timex/timeout_handling.rb +39 -0
  38. data/lib/timex/version.rb +8 -0
  39. data/lib/timex.rb +79 -0
  40. data/mkdocs.yml +193 -0
  41. metadata +239 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 004cb51ebbe6c5acb07cf3085e0fec11a544a2d6eb29a953ff897b37727051bf
4
+ data.tar.gz: ead577ccb6e1a5cc28c99619afa6c074bb76f6345340edd4aba4ee5162d81a80
5
+ SHA512:
6
+ metadata.gz: a05964e1431ce5fc7e1b78a722dcd51cf0beacc746ab8637178d545a57e5e8fcb32d1317330c12f45a62deb40b5ac64ccb0368b893abf6ec7e271f628d762124
7
+ data.tar.gz: 7d68892730d9a424c0213617be0e06d04bdf25481bb431e545058703b84b2b716092482e030ee5d25d71a3629cb4d35a6fdf2307ed5f66b54b60ee5e3f571635
data/.DS_Store ADDED
Binary file
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - UNRELEASED
8
+
9
+ ### Added
10
+
11
+ - Init commit
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,4 @@
1
+ Copyright (c) Drexed
2
+
3
+ CMDx is an Open Source project licensed under the terms of the LGPLv3 license.
4
+ Please see <http://www.gnu.org/licenses/lgpl-3.0.html> for license text.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ <div align="center">
2
+ <img src="./src/timex-light-logo.png#gh-light-mode-only" width="200" alt="TIMEx Light Logo">
3
+ <img src="./src/timex-dark-logo.png#gh-dark-mode-only" width="200" alt="TIMEx Dark Logo">
4
+
5
+ ---
6
+
7
+ Deadlines, budgets, and cancellation you can reason about in production.
8
+
9
+ [Home](https://drexed.github.io/timex) ·
10
+ [Documentation](https://drexed.github.io/timex/getting_started) ·
11
+ [Blog](https://drexed.github.io/timex/blog) ·
12
+ [Changelog](./CHANGELOG.md) ·
13
+ [Report Bug](https://github.com/drexed/timex/issues) ·
14
+ [Request Feature](https://github.com/drexed/timex/issues) ·
15
+ [AI Skills](https://github.com/drexed/timex/blob/main/skills) ·
16
+ [llms.txt](https://drexed.github.io/timex/llms.txt) ·
17
+ [llms-full.txt](https://drexed.github.io/timex/llms-full.txt)
18
+
19
+ <img alt="Version" src="https://img.shields.io/gem/v/timex">
20
+ <img alt="Build" src="https://github.com/drexed/timex/actions/workflows/ci.yml/badge.svg">
21
+ <img alt="License" src="https://img.shields.io/badge/license-LGPL%20v3-blue.svg">
22
+ </div>
23
+
24
+ # TIMEx
25
+
26
+ TIMEx is a **deadline engine** for Ruby: one facade runs your code under a `Deadline`, picks an execution strategy (cooperative checks, thread wakeup, IO deadlines, subprocesses, and more), and routes expiry through consistent `on_timeout` semantics—without pulling in a framework.
27
+
28
+ > [!NOTE]
29
+ > [Documentation](https://drexed.github.io/timex/getting_started/) reflects the latest code on `main`. For version-specific documentation, refer to the `docs/` directory within that version's tag.
30
+
31
+ ## What you get
32
+
33
+ - **`TIMEx.deadline` / `TIMEx.call`** — single entrypoint with `strategy:`, `on_timeout:`, `auto_check:`, and strategy-specific options
34
+ - **`Deadline`** — monotonic + wall alignment, narrowing (`#min`), skew-aware header encoding (`X-TIMEx-Deadline`)
35
+ - **Strategies** — `:cooperative`, `:unsafe`, `:io`, `:wakeup`, `:subprocess`, `:closeable`, `:ractor` (when `Ractor` is defined), each registered on `TIMEx::Registry`
36
+ - **Composers** — `TwoPhase`, `Hedged`, `Adaptive` for multi-attempt and staged execution
37
+ - **`on_timeout`** — `:raise` (default), `:raise_standard`, `:return_nil`, `:result`, or a custom `Proc` with shared dispatch in `TimeoutHandling`
38
+ - **`Result`** — discriminated `:ok` / `:timeout` / `:error` outcomes when you opt out of raising
39
+ - **Propagation** — `Deadline#to_header` / `Deadline.from_header` plus optional Rack middleware for cross-service budgets
40
+ - **Telemetry & clocks** — pluggable `Telemetry.adapter`, injectable monotonic/wall `Clock`, and `TIMEx::Test::VirtualClock` for tests
41
+ - **Rails (opt-in)** — install generator adds initializer hooks without loading Rails from the core require
42
+
43
+ See the [feature comparison](https://drexed.github.io/timex/comparison/) for how TIMEx compares to `Timeout.timeout` and other patterns.
44
+
45
+ ## Requirements
46
+
47
+ - Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release
48
+ - Runtime dependencies: none beyond the standard library (no ActiveSupport required)
49
+
50
+ Rails middleware and generators load only when you opt in after `bundle install`.
51
+
52
+ ## Installation
53
+
54
+ ```sh
55
+ gem install timex
56
+ # - or -
57
+ bundle add timex
58
+ ```
59
+
60
+ ## Quick example
61
+
62
+ ### 1. Budget
63
+
64
+ Pass seconds, a `Deadline`, or `nil` for an infinite budget. The block receives a frozen `Deadline` you can thread through helpers.
65
+
66
+ ```ruby
67
+ deadline = TIMEx::Deadline.in(2.5)
68
+ TIMEx.deadline(deadline) { |d| process!(d) }
69
+ ```
70
+
71
+ ### 2. Run
72
+
73
+ The default `:cooperative` strategy runs your block and performs a final `check!` so CPU-bound work still observes expiry at cooperative points.
74
+
75
+ ```ruby
76
+ TIMEx.deadline(1.0) do |deadline|
77
+ rows = fetch_rows
78
+ deadline.check!
79
+ summarize(rows)
80
+ end
81
+ ```
82
+
83
+ ### 3. On expiry
84
+
85
+ Override per call or via `TIMEx.configure`. Use `:result` when you want a `TIMEx::Result` instead of an exception.
86
+
87
+ ```ruby
88
+ outcome = TIMEx.deadline(0.01, on_timeout: :result, strategy: :unsafe) do
89
+ sleep 5 # interrupted when the budget is exhausted
90
+ end
91
+
92
+ outcome.timeout? # => true
93
+ ```
94
+
95
+ ### 4. Propagate
96
+
97
+ Serialize remaining budget into an outbound request so downstream services share the same cap.
98
+
99
+ ```ruby
100
+ req["X-TIMEx-Deadline"] = TIMEx::Deadline.in(3.0).to_header
101
+ # or use TIMEx::Propagation::RackMiddleware on the server (see docs)
102
+ ```
103
+
104
+ Ready to go deeper? Start with [Getting Started](https://drexed.github.io/timex/getting_started/) and [Migrating from stdlib `Timeout`](https://drexed.github.io/timex/migrating_from_stdlib_timeout/).
105
+
106
+ ## Contributing
107
+
108
+ Bug reports and pull requests are welcome at <https://github.com/drexed/timex>. We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](CODE_OF_CONDUCT.md).
109
+
110
+ ## License
111
+
112
+ The gem is available as open source under the terms of the [LGPLv3 License](https://www.gnu.org/licenses/lgpl-3.0.html).
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ desc "Generate YARD API documentation"
13
+ task :yard do
14
+ require "yard"
15
+ require "fileutils"
16
+
17
+ YARD::CLI::Yardoc.run(*File.readlines(".yardopts", chomp: true).reject(&:empty?))
18
+
19
+ api_dir = "docs/api"
20
+
21
+ # Remove unwanted files
22
+ FileUtils.rm_f("#{api_dir}/Timex_.html")
23
+
24
+ # Make TIMEx.html the default index
25
+ FileUtils.cp("#{api_dir}/TIMEx.html", "#{api_dir}/index.html") if File.exist?("#{api_dir}/TIMEx.html")
26
+ end
27
+
28
+ task default: %i[spec rubocop]
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails generator namespace is +Timex+ (not +TIMEx+) to follow Rails::Generators
4
+ # naming conventions and avoid constant clashes with the +TIMEx+ library module.
5
+ module Timex
6
+ # Copies the TIMEx initializer template into the host application.
7
+ #
8
+ # @see Rails::Generators::Base
9
+ class InstallGenerator < Rails::Generators::Base
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates TIMEx initializer with global configuration settings"
14
+
15
+ # @return [void]
16
+ def copy_initializer_file
17
+ copy_file("install.rb", "config/initializers/timex.rb")
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # TIMEx global defaults for this Rails app. Edit and uncomment options as needed.
5
+ # Full reference: https://drexed.github.io/timex/configuration
6
+
7
+ TIMEx.configure do |c|
8
+ # ===========================================================================
9
+ # Default strategy
10
+ # ===========================================================================
11
+ # Registry name passed to strategies when +strategy:+ is omitted on
12
+ # +TIMEx.deadline+ (e.g. +:cooperative+, +:io+, +:unsafe+).
13
+ #
14
+ # c.default_strategy = :cooperative
15
+
16
+ # ===========================================================================
17
+ # On timeout
18
+ # ===========================================================================
19
+ # +:raise+, +:return_nil+, +:result+, or a +Proc+ invoked with the exception.
20
+ #
21
+ # c.default_on_timeout = :raise
22
+
23
+ # ===========================================================================
24
+ # Auto-check (TracePoint)
25
+ # ===========================================================================
26
+ # When +true+, +TIMEx.deadline+ behaves like +auto_check: true+ unless overridden
27
+ # per call. +auto_check_interval+ is VM events between deadline polls.
28
+ #
29
+ # c.auto_check_default = false
30
+ # c.auto_check_interval = 1_000
31
+
32
+ # ===========================================================================
33
+ # Telemetry
34
+ # ===========================================================================
35
+ # +nil+ uses the built-in null adapter. In Rails you often wire Logger or
36
+ # Active Support Notifications — see docs/telemetry.md.
37
+ #
38
+ # c.telemetry_adapter = TIMEx::Telemetry::Adapters::Logger.new(Rails.logger)
39
+ # c.telemetry_adapter = TIMEx::Telemetry::Adapters::ActiveSupportNotifications.new
40
+
41
+ # ===========================================================================
42
+ # Clock
43
+ # ===========================================================================
44
+ # Override monotonic/wall time sources (tests, virtual clocks).
45
+ #
46
+ # c.clock = nil
47
+
48
+ # ===========================================================================
49
+ # Deadline header skew
50
+ # ===========================================================================
51
+ # Milliseconds of tolerated wall-clock drift when parsing propagated headers.
52
+ #
53
+ # c.skew_tolerance_ms = 250
54
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ # Best-effort cooperative deadline checks driven by +TracePoint+ (+:line+ and
5
+ # +:b_return+). Injects periodic {#Deadline#check!} calls without requiring manual
6
+ # probes inside the block.
7
+ #
8
+ # @note +:line+ events are expensive; expect noticeable overhead on tight loops.
9
+ # Prefer explicit {#Deadline#check!} for hot paths.
10
+ #
11
+ # @note TracePoint does not fire inside C-native methods (+Mutex#synchronize+,
12
+ # blocking +IO+, etc.); pair with an IO-aware strategy for those regions.
13
+ #
14
+ # @note Only +target_thread:+ is traced; child threads need their own setup or
15
+ # explicit deadline checks.
16
+ #
17
+ # @see Strategies::Cooperative
18
+ # @see Configuration#auto_check_interval
19
+ module AutoCheck
20
+
21
+ # Only Ruby-level events: +:line+ already polls between every Ruby
22
+ # statement, and +:b_return+ adds coverage at block boundaries so we
23
+ # interrupt promptly between iterations. +:c_return+ was tempting but
24
+ # fires on every C method return (Hash#[], String#+, …) — the dispatch
25
+ # cost dwarfs the latency win on any non-trivial loop body.
26
+ EVENTS = %i[line b_return].freeze
27
+
28
+ extend self
29
+
30
+ # Runs +block+ with TracePoint-driven deadline checks every +interval+ Ruby events.
31
+ #
32
+ # @param deadline [Deadline]
33
+ # @param interval [Integer] line/block events between checks (from config by default)
34
+ # @yieldparam deadline [Deadline]
35
+ # @return [Object] the block's return value
36
+ # @raise [Expired] when the deadline elapses between checks
37
+ def run(deadline, interval: TIMEx.config.auto_check_interval)
38
+ return yield(deadline) if deadline.infinite?
39
+
40
+ counter = 0
41
+ tp = TracePoint.new(*EVENTS) do |_event|
42
+ counter += 1
43
+ next unless counter >= interval
44
+
45
+ counter = 0
46
+ next if Thread.current.thread_variable_get(:timex_shielded)
47
+ next unless deadline.expired?
48
+
49
+ tp.disable
50
+ deadline.check!(strategy: :cooperative)
51
+ end
52
+
53
+ tp.enable(target_thread: Thread.current) do
54
+ yield(deadline)
55
+ end
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ # Thread-safe manual cancellation signal for long-running or hedged work.
5
+ #
6
+ # Observers registered via {#on_cancel} run outside the mutex after the
7
+ # transition to cancelled; observer exceptions are swallowed and reported
8
+ # through {Telemetry}.
9
+ #
10
+ # @see Telemetry.emit
11
+ class CancellationToken
12
+
13
+ # @return [void]
14
+ def initialize
15
+ @mutex = Mutex.new
16
+ @cancelled = false
17
+ @reason = nil
18
+ @observers = []
19
+ end
20
+
21
+ # @return [Boolean] +true+ after {#cancel} succeeds
22
+ def cancelled?
23
+ @mutex.synchronize { @cancelled }
24
+ end
25
+
26
+ attr_reader :reason
27
+
28
+ # Marks the token cancelled and notifies observers (once).
29
+ #
30
+ # @param reason [Object, nil] opaque payload passed to observers
31
+ # @return [Boolean] +true+ when this call performed the transition, +false+ if already cancelled
32
+ def cancel(reason: nil) # rubocop:disable Naming/PredicateMethod
33
+ observers_to_notify = nil
34
+ @mutex.synchronize do
35
+ return false if @cancelled
36
+
37
+ @cancelled = true
38
+ @reason = reason
39
+ observers_to_notify = @observers.dup
40
+ end
41
+ observers_to_notify.each { |o| safe_call(o, reason) }
42
+ true
43
+ end
44
+
45
+ # Registers a callback invoked on cancellation (immediately if already cancelled).
46
+ #
47
+ # @yield [reason] invoked when cancelled
48
+ # @yieldparam reason [Object, nil] the reason passed to {#cancel}
49
+ # @return [self] for chaining
50
+ def on_cancel(&block)
51
+ fire_now = false
52
+ @mutex.synchronize do
53
+ if @cancelled
54
+ fire_now = true
55
+ else
56
+ @observers << block
57
+ end
58
+ end
59
+ safe_call(block, @reason) if fire_now
60
+ self
61
+ end
62
+
63
+ private
64
+
65
+ # @param observer [Proc, #call]
66
+ # @param reason [Object, nil]
67
+ # @return [void]
68
+ def safe_call(observer, reason)
69
+ observer.call(reason)
70
+ rescue StandardError => e
71
+ # Observers must not break the cancellation chain. Surface the failure
72
+ # via telemetry so it's debuggable instead of vanishing.
73
+ begin
74
+ TIMEx::Telemetry.emit(
75
+ event: "cancellation.observer_error",
76
+ error_class: e.class.name
77
+ )
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ end
82
+
83
+ end
84
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ # Thread-scoped clock abstraction for monotonic and wall time in nanoseconds.
5
+ #
6
+ # Production code reads {RealClock} unless {TIMEx::Configuration#clock=} or
7
+ # {TIMEx::Test.with_virtual_clock} overrides the current binding via
8
+ # +thread_variable_*+ (not fiber-local storage).
9
+ #
10
+ # @note Uses +Thread.current.thread_variable_get/set(:timex_clock)+ so every fiber
11
+ # in a thread shares one clock context (deadline shielding, {AutoCheck}, tests).
12
+ #
13
+ # @see TIMEx::Configuration#clock=
14
+ # @see TIMEx::Test.with_virtual_clock
15
+ module Clock
16
+
17
+ NS_PER_SECOND = 1_000_000_000
18
+
19
+ extend self
20
+
21
+ # @return [Integer] monotonic time in nanoseconds from the active clock
22
+ def monotonic_ns
23
+ current.monotonic_ns
24
+ end
25
+
26
+ # @return [Integer] wall-clock time in nanoseconds from the active clock
27
+ def wall_ns
28
+ current.wall_ns
29
+ end
30
+
31
+ # @return [Float] monotonic time expressed in fractional seconds
32
+ def now_seconds
33
+ monotonic_ns / NS_PER_SECOND.to_f
34
+ end
35
+
36
+ # Returns the clock object consulted by {.monotonic_ns} and {.wall_ns}.
37
+ #
38
+ # @return [#monotonic_ns, #wall_ns] {RealClock}, {VirtualClock}, or a custom clock
39
+ def current
40
+ Thread.current.thread_variable_get(:timex_clock) || TIMEx.config.clock || RealClock
41
+ end
42
+
43
+ # Binds +clock+ for the duration of the block on the current thread.
44
+ #
45
+ # @param clock [#monotonic_ns, #wall_ns] clock implementation to install
46
+ # @yield runs with the thread-local clock swapped
47
+ # @return [Object] the block's return value
48
+ def with(clock)
49
+ previous = Thread.current.thread_variable_get(:timex_clock)
50
+ Thread.current.thread_variable_set(:timex_clock, clock)
51
+ yield
52
+ ensure
53
+ Thread.current.thread_variable_set(:timex_clock, previous)
54
+ end
55
+
56
+ # Default clock backed by the process monotonic and realtime clocks.
57
+ module RealClock
58
+
59
+ extend self
60
+
61
+ # @return [Integer] nanoseconds from +CLOCK_MONOTONIC+
62
+ def monotonic_ns
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
64
+ end
65
+
66
+ # @return [Integer] nanoseconds from +CLOCK_REALTIME+
67
+ def wall_ns
68
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
69
+ end
70
+
71
+ # Sleeps the OS scheduler for up to +seconds+ (no-op when non-positive).
72
+ #
73
+ # @param seconds [Numeric] duration in seconds
74
+ # @return [void]
75
+ def sleep(seconds)
76
+ Kernel.sleep(seconds) if seconds.positive?
77
+ end
78
+
79
+ end
80
+
81
+ # Mutable monotonic/wall pair used in tests to advance time without sleeping.
82
+ class VirtualClock
83
+
84
+ attr_accessor :monotonic_ns, :wall_ns
85
+
86
+ # @param monotonic_ns [Integer] starting monotonic nanoseconds
87
+ # @param wall_ns [Integer] starting wall nanoseconds (defaults to realtime now)
88
+ def initialize(monotonic_ns: 0, wall_ns: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond))
89
+ @monotonic_ns = monotonic_ns
90
+ @wall_ns = wall_ns
91
+ end
92
+
93
+ # Advances both monotonic and wall by +seconds+ (no sleep).
94
+ #
95
+ # @param seconds [Numeric] delta in seconds
96
+ # @return [self] for chaining
97
+ def advance(seconds)
98
+ delta = (seconds * Clock::NS_PER_SECOND).to_i
99
+ @monotonic_ns += delta
100
+ @wall_ns += delta
101
+ self
102
+ end
103
+
104
+ # @param seconds [Numeric] virtual sleep; advances clocks like {.advance}
105
+ # @return [self] for chaining
106
+ def sleep(seconds)
107
+ advance(seconds)
108
+ end
109
+
110
+ end
111
+
112
+ end
113
+ end