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
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Propagation
5
+ # Rack middleware: parses inbound {HttpHeader::HEADER_NAME}, stores
6
+ # +env["timex.deadline"]+, optionally clamps and rejects abusive values, and
7
+ # can echo remaining budget on the response.
8
+ #
9
+ # @note The header is **untrusted** on public networks. Combine +max_seconds:+,
10
+ # +max_depth:+, and network controls; see class body for threat summary.
11
+ #
12
+ # @see HttpHeader
13
+ # @see Deadline.from_header
14
+ class RackMiddleware
15
+
16
+ ENV_KEY = "timex.deadline"
17
+ RAW_HEADER_KEY = HttpHeader::RACK_HEADER_KEY
18
+
19
+ # Rack 3 mandates lower-case response header names; Rack 2 (and many
20
+ # 3rd-party middlewares that haven't migrated) still emit canonical
21
+ # case. Pass `header_case: :canonical` to switch the response header
22
+ # names to `Content-Type` / `X-TIMEx-*` for Rack-2-era stacks.
23
+ HEADER_NAMES = {
24
+ rack3: {
25
+ remaining: "x-timex-remaining-ms",
26
+ outcome: "x-timex-outcome",
27
+ content_type: "content-type"
28
+ }.freeze,
29
+ canonical: {
30
+ remaining: "X-TIMEx-Remaining-Ms",
31
+ outcome: "X-TIMEx-Outcome",
32
+ content_type: "Content-Type"
33
+ }.freeze
34
+ }.freeze
35
+
36
+ # @param app [#call] inner Rack application
37
+ # @param default_seconds [Numeric, nil] installed when no header is present
38
+ # @param max_seconds [Numeric, nil] clamps inbound deadlines to at most this budget
39
+ # @param max_depth [Integer, nil] rejects requests whose parsed +depth+ exceeds this value
40
+ # @param expose_remaining [Boolean] when +true+, adds remaining-ms response header
41
+ # @param clamp_infinite_to_default [Boolean] when +true+ with +default_seconds+, maps inbound infinite to default
42
+ # @param header_case [:rack3, :canonical] response header casing
43
+ # @raise [ArgumentError] when +header_case+ is unknown
44
+ def initialize(app, default_seconds: nil, max_seconds: nil, max_depth: nil, # rubocop:disable Metrics/ParameterLists
45
+ expose_remaining: false, clamp_infinite_to_default: false,
46
+ header_case: :rack3)
47
+ raise ArgumentError, "header_case must be :rack3 or :canonical" unless HEADER_NAMES.key?(header_case)
48
+
49
+ @app = app
50
+ @default_seconds = default_seconds
51
+ @max_seconds = max_seconds
52
+ @max_depth = max_depth
53
+ @expose_remaining = expose_remaining
54
+ @clamp_infinite_to_default = clamp_infinite_to_default
55
+ @headers = HEADER_NAMES.fetch(header_case)
56
+ end
57
+
58
+ # Security: this header is taken from the inbound HTTP request without
59
+ # authentication. An attacker who can reach this endpoint can send
60
+ # `ms=0` to force an immediate 503, or a large `ms=` value to extend a
61
+ # request's allowed processing window beyond what your server intended.
62
+ # Only mount this middleware on networks where the upstream is trusted
63
+ # (e.g. internal service mesh, signed/authenticated requests).
64
+ #
65
+ # For internet-facing deployments, **always** pass `max_seconds:` so any
66
+ # incoming deadline is clamped to that ceiling, and `max_depth:` to bound
67
+ # propagation hops (example: `use TIMEx::Propagation::RackMiddleware, max_seconds: 30, max_depth: 8`).
68
+ #
69
+ # `Deadline.from_header` also caps untrusted input length at
70
+ # `Deadline::MAX_HEADER_BYTESIZE`, rejects non-finite/negative/very large
71
+ # `ms=` values, and clamps `depth=` at `Deadline::MAX_DEPTH`.
72
+ #
73
+ # `max_depth` is enforced on the *parsed inbound* deadline before
74
+ # `max_seconds` clamping. Clamping via {Deadline#min} can yield a fresh
75
+ # deadline without propagation metadata; checking depth only after clamp
76
+ # would let a client bypass the hop limit with an oversized `ms=`.
77
+ #
78
+ # @param env [Hash{String => Object}]
79
+ # @return [Array(Integer, Hash, #each)] Rack triplet
80
+ def call(env)
81
+ # Distinguish "no header sent" from "header present but unparseable":
82
+ # the latter is suspicious (truncation, smuggling attempt) and
83
+ # deserves a telemetry signal even though we still fall through to
84
+ # `default_seconds` / unbounded handling.
85
+ raw = env[RAW_HEADER_KEY]
86
+ deadline = HttpHeader.from_rack_env(env)
87
+ if raw && !raw.empty? && deadline.nil?
88
+ TIMEx::Telemetry.emit(
89
+ event: "rack.deadline.unparseable",
90
+ bytesize: raw.bytesize
91
+ )
92
+ end
93
+
94
+ if depth_exceeded?(deadline)
95
+ TIMEx::Telemetry.emit(
96
+ event: "rack.deadline.rejected",
97
+ reason: :max_depth_exceeded,
98
+ depth: deadline.depth,
99
+ origin: deadline.origin
100
+ )
101
+ return reject_response("max-depth-exceeded", "Deadline propagation depth exceeded")
102
+ end
103
+
104
+ deadline = nil if deadline&.infinite? && @clamp_infinite_to_default && @default_seconds
105
+ deadline = clamp(deadline)
106
+ deadline ||= Deadline.in(@default_seconds) if @default_seconds
107
+
108
+ if deadline
109
+ env[ENV_KEY] = deadline
110
+ env[RAW_HEADER_KEY] = deadline.to_header
111
+ else
112
+ env.delete(RAW_HEADER_KEY)
113
+ end
114
+
115
+ if deadline&.expired?
116
+ TIMEx::Telemetry.emit(
117
+ event: "rack.deadline.rejected",
118
+ reason: :expired_on_arrival,
119
+ origin: deadline.origin
120
+ )
121
+ return reject_response("expired-on-arrival", "Deadline expired before request handling")
122
+ end
123
+
124
+ status, headers, body = @app.call(env)
125
+ headers = inject_remaining(headers, deadline) if @expose_remaining
126
+ [status, headers, body]
127
+ end
128
+
129
+ private
130
+
131
+ # @param outcome [String] value for the outcome response header
132
+ # @param body [String] plain-text body (wrapped in a single-element array)
133
+ # @return [Array(Integer, Hash, Array<String>)]
134
+ def reject_response(outcome, body)
135
+ [
136
+ 503,
137
+ {
138
+ @headers[:content_type] => "text/plain",
139
+ @headers[:outcome] => outcome
140
+ },
141
+ [body]
142
+ ]
143
+ end
144
+
145
+ # @param deadline [Deadline, nil]
146
+ # @return [Deadline, nil]
147
+ def clamp(deadline)
148
+ return deadline unless deadline && @max_seconds
149
+
150
+ deadline.min(Deadline.in(@max_seconds))
151
+ end
152
+
153
+ # @param deadline [Deadline, nil]
154
+ # @return [Boolean]
155
+ def depth_exceeded?(deadline)
156
+ return false unless deadline && @max_depth
157
+
158
+ deadline.depth > @max_depth
159
+ end
160
+
161
+ # @param headers [Hash, Object]
162
+ # @param deadline [Deadline, nil]
163
+ # @return [Hash, Object]
164
+ def inject_remaining(headers, deadline)
165
+ return headers unless deadline && !deadline.infinite?
166
+ return headers unless headers.is_a?(Hash) || headers.respond_to?(:merge)
167
+
168
+ value = deadline.remaining_ms.round.to_s
169
+ key = @headers[:remaining]
170
+ if headers.frozen?
171
+ headers.merge(key => value)
172
+ else
173
+ headers[key] = value
174
+ headers
175
+ end
176
+ end
177
+
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ # Copy-on-write registry of named strategies.
5
+ #
6
+ # Reads are lock-free against the frozen backing hash; writes synchronize and
7
+ # replace the hash atomically. Suited for boot-time registration and per-call
8
+ # resolution on the hot path.
9
+ #
10
+ # @see TIMEx.deadline
11
+ # @see Registry.resolve_for_call
12
+ module Registry
13
+
14
+ @strategies = {}.freeze
15
+ @write_mutex = Mutex.new
16
+ @default_selector = nil
17
+ @cached_default = nil
18
+ @cached_default_key = nil
19
+
20
+ extend self
21
+
22
+ # @param name [Symbol, String] registration key
23
+ # @param strategy [#call] callable strategy object
24
+ # @return [void]
25
+ # @raise [ArgumentError] when +strategy+ does not respond to +#call+
26
+ def register(name, strategy)
27
+ raise ArgumentError, "strategy must respond to :call, got #{strategy.inspect}" unless strategy.respond_to?(:call)
28
+
29
+ @write_mutex.synchronize do
30
+ @strategies = @strategies.merge(name.to_sym => strategy).freeze
31
+ invalidate_default_cache
32
+ end
33
+ end
34
+
35
+ # @param name [Symbol, String]
36
+ # @return [#call]
37
+ # @raise [StrategyNotFoundError] when unknown
38
+ def fetch(name)
39
+ sym = name.to_sym
40
+ strategies = @strategies
41
+ strategies.fetch(sym) do
42
+ raise StrategyNotFoundError, "no strategy registered as #{name.inspect}; " \
43
+ "available: #{strategies.keys.inspect}"
44
+ end
45
+ end
46
+
47
+ # @return [Array<Symbol>] known registration keys (snapshot)
48
+ def known
49
+ @strategies.keys
50
+ end
51
+
52
+ # Resolves a symbol to a registered strategy; passes callables through; +nil+ stays +nil+.
53
+ #
54
+ # @param strategy_or_name [Symbol, #call, nil]
55
+ # @return [#call, nil]
56
+ # @raise [StrategyNotFoundError] when a symbol names an unknown strategy
57
+ def resolve(strategy_or_name)
58
+ case strategy_or_name
59
+ when Symbol then fetch(strategy_or_name)
60
+ when nil then nil
61
+ else strategy_or_name
62
+ end
63
+ end
64
+
65
+ # Resolves the callable used by {TIMEx.deadline}: the configured default when
66
+ # +strategy+ is +nil+, a registered entry when it is a {Symbol}, otherwise
67
+ # the object must respond to +#call+ (strategy class or instance).
68
+ #
69
+ # @param strategy [Symbol, #call, nil]
70
+ # @return [#call]
71
+ # @raise [StrategyNotFoundError] when +strategy+ names an unknown registration
72
+ # @raise [ArgumentError] when +strategy+ is neither +nil+, a {Symbol}, nor +#call+-able
73
+ def resolve_for_call(strategy)
74
+ case strategy
75
+ when nil then select_default
76
+ when Symbol then fetch(strategy)
77
+ else
78
+ if strategy.respond_to?(:call)
79
+ strategy
80
+ else
81
+ raise ArgumentError,
82
+ "strategy must be a Symbol, Class, or instance responding to " \
83
+ "#call, got #{strategy.inspect}"
84
+ end
85
+ end
86
+ end
87
+
88
+ # Installs or returns the optional default-strategy selector block.
89
+ #
90
+ # @yieldreturn [Symbol, nil] strategy key to resolve, or +nil+ to fall through to config
91
+ # @return [Proc, nil] current selector when called without a block
92
+ def default_selector(&block)
93
+ if block
94
+ @write_mutex.synchronize do
95
+ @default_selector = block
96
+ invalidate_default_cache
97
+ end
98
+ end
99
+ @default_selector
100
+ end
101
+
102
+ # Resolves {TIMEx.config} default strategy with optional selector and caching.
103
+ #
104
+ # @return [#call]
105
+ #
106
+ # @note Hot path: caches static {Configuration#default_strategy} resolutions; a
107
+ # configured selector disables the cache because results may vary per call.
108
+ def select_default
109
+ if @default_selector
110
+ sym = @default_selector.call
111
+ return fetch(sym) if sym
112
+ end
113
+
114
+ key = TIMEx.config.default_strategy
115
+ return @cached_default if key == @cached_default_key && @cached_default
116
+
117
+ resolved = fetch(key)
118
+ @cached_default_key = key
119
+ @cached_default = resolved
120
+ resolved
121
+ end
122
+
123
+ private
124
+
125
+ # @return [void]
126
+ def invalidate_default_cache
127
+ @cached_default = nil
128
+ @cached_default_key = nil
129
+ end
130
+
131
+ end
132
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ # Discriminated outcome from work executed under a deadline when errors are
5
+ # captured instead of raised (+on_timeout: :result+, non-raising strategies, etc.).
6
+ #
7
+ # Instances are frozen at construction and support pattern matching via
8
+ # {#deconstruct} / {#deconstruct_keys}.
9
+ #
10
+ # @see TIMEx.deadline
11
+ class Result
12
+
13
+ OK = :ok
14
+ TIMEOUT = :timeout
15
+ ERROR = :error
16
+
17
+ attr_reader :outcome, :value, :strategy, :elapsed_ms, :error, :deadline_ms
18
+
19
+ # @param outcome [Symbol] one of +OK+, +TIMEOUT+, or +ERROR+
20
+ # @param value [Object, nil] success payload when +outcome == OK+
21
+ # @param strategy [Symbol, nil] component name when known
22
+ # @param elapsed_ms [Numeric, nil] elapsed time in ms when known
23
+ # @param error [Exception, nil] underlying exception for timeout/error paths
24
+ # @param deadline_ms [Numeric, nil] original budget in ms for timeout paths
25
+ # @return [void]
26
+ #
27
+ # @note Freezes +self+ before returning.
28
+ def initialize(outcome:, value: nil, strategy: nil, elapsed_ms: nil, error: nil, deadline_ms: nil) # rubocop:disable Metrics/ParameterLists
29
+ @outcome = outcome
30
+ @value = value
31
+ @strategy = strategy
32
+ @elapsed_ms = elapsed_ms
33
+ @error = error
34
+ @deadline_ms = deadline_ms
35
+ freeze
36
+ end
37
+
38
+ # @param value [Object] success value
39
+ # @param strategy [Symbol, nil]
40
+ # @param elapsed_ms [Numeric, nil]
41
+ # @return [Result] frozen OK result
42
+ def self.ok(value, strategy: nil, elapsed_ms: nil)
43
+ new(outcome: OK, value:, strategy:, elapsed_ms:)
44
+ end
45
+
46
+ # Builds a timeout result, always carrying an {Expired} in +#error+ so {#value!}
47
+ # can re-raise with uniform metadata.
48
+ #
49
+ # @param strategy [Symbol, nil]
50
+ # @param expired [Expired, nil] existing {Expired} (preferred)
51
+ # @param elapsed_ms [Numeric, nil] used with +deadline_ms+ to synthesize {Expired} when +expired+ omitted
52
+ # @param deadline_ms [Numeric, nil] used with +elapsed_ms+ to synthesize {Expired} when +expired+ omitted
53
+ # @return [Result] frozen timeout result
54
+ def self.timeout(strategy:, expired: nil, elapsed_ms: nil, deadline_ms: nil)
55
+ expired ||= Expired.new(
56
+ "deadline expired",
57
+ strategy:,
58
+ deadline_ms:,
59
+ elapsed_ms:
60
+ )
61
+ new(
62
+ outcome: TIMEOUT,
63
+ strategy:,
64
+ error: expired,
65
+ elapsed_ms: elapsed_ms || expired.elapsed_ms,
66
+ deadline_ms: deadline_ms || expired.deadline_ms
67
+ )
68
+ end
69
+
70
+ # @param error [Exception]
71
+ # @param strategy [Symbol, nil]
72
+ # @param elapsed_ms [Numeric, nil]
73
+ # @return [Result] frozen error result
74
+ # @raise [ArgumentError] when +error+ is not an +Exception+
75
+ def self.error(error, strategy: nil, elapsed_ms: nil)
76
+ raise ArgumentError, "error must be an Exception, got #{error.class}" unless error.is_a?(Exception)
77
+
78
+ new(outcome: ERROR, error:, strategy:, elapsed_ms:)
79
+ end
80
+
81
+ # @return [Boolean]
82
+ def ok? = @outcome == OK
83
+
84
+ # @return [Boolean]
85
+ def timeout? = @outcome == TIMEOUT
86
+
87
+ # @return [Boolean]
88
+ def error? = @outcome == ERROR
89
+
90
+ # Returns the success value or raises the captured exception.
91
+ #
92
+ # @return [Object] +@value+ when {#ok?}
93
+ # @raise [Expired, Exception] when {#timeout?} or {#error?}
94
+ # @raise [Error] when not OK and no +@error+ is present
95
+ def value!
96
+ return @value if ok?
97
+ raise @error if @error
98
+
99
+ raise Error, "result has no value"
100
+ end
101
+ alias unwrap value!
102
+
103
+ # Returns +@value+ when the result is OK, otherwise the +default+ (or the
104
+ # block's return value if a block is given).
105
+ #
106
+ # @param default [Object] fallback when not OK and no block given
107
+ # @yieldparam result [Result] +self+ for inspecting +error+, +strategy+, etc.
108
+ # @return [Object]
109
+ def value_or(default = nil)
110
+ if ok?
111
+ @value
112
+ else
113
+ (block_given? ? yield(self) : default)
114
+ end
115
+ end
116
+ alias unwrap_or value_or
117
+
118
+ # @return [Array(Symbol, Object, Exception, nil)] array shape for pattern matching
119
+ def deconstruct
120
+ [@outcome, @value, @error]
121
+ end
122
+
123
+ # @param _keys [Array<Symbol>, nil] ignored; present for Ruby pattern-matching protocol
124
+ # @return [Hash{Symbol => Object}] hash shape for pattern matching
125
+ def deconstruct_keys(_keys)
126
+ {
127
+ outcome: @outcome,
128
+ value: @value,
129
+ error: @error,
130
+ strategy: @strategy,
131
+ elapsed_ms: @elapsed_ms,
132
+ deadline_ms: @deadline_ms
133
+ }
134
+ end
135
+
136
+ end
137
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Abstract strategy: runs user code against a {Deadline} and maps {Expired}
6
+ # to +on_timeout:+ behavior via {TimeoutHandling}.
7
+ #
8
+ # Subclasses implement {#run}. Telemetry wraps each invocation when a
9
+ # non-null adapter is configured.
10
+ #
11
+ # @see TIMEx.deadline
12
+ # @see Registry
13
+ class Base
14
+
15
+ include TIMEx::NamedComponent
16
+ include TIMEx::TimeoutHandling
17
+
18
+ class << self
19
+
20
+ # Convenience constructor: +new(**opts).call(...)+.
21
+ #
22
+ # @param deadline [Deadline, Numeric, Time, nil] budget or absolute deadline
23
+ # @param on_timeout [Symbol, Proc] timeout dispatch mode
24
+ # @param opts [Hash{Symbol => Object}] subclass-specific options forwarded to +#initialize+
25
+ # @yieldparam deadline [Deadline] coerced deadline passed to user block
26
+ # @return [Object] block result or handler return (see {TimeoutHandling})
27
+ def call(deadline:, on_timeout: :raise, **opts, &block)
28
+ new(**opts).call(deadline:, on_timeout:, &block)
29
+ end
30
+
31
+ end
32
+
33
+ # Coerces +deadline+, optionally instruments, and runs {#run}.
34
+ #
35
+ # @param deadline [Deadline, Numeric, Time, nil]
36
+ # @param on_timeout [Symbol, Proc]
37
+ # @yieldparam deadline [Deadline]
38
+ # @return [Object]
39
+ def call(deadline:, on_timeout: :raise, &block)
40
+ deadline = Deadline.coerce(deadline)
41
+
42
+ # Resolve the adapter exactly once per call. `Telemetry.adapter` walks
43
+ # `Telemetry.@adapter || TIMEx.config.telemetry_adapter || ...` on
44
+ # every access; we hand the resolved object straight to `instrument`
45
+ # to avoid re-walking it under the hot-path null check.
46
+ adapter = TIMEx::Telemetry.adapter
47
+ return run_unobserved(deadline, on_timeout, &block) if adapter.is_a?(TIMEx::Telemetry::Adapters::Null)
48
+
49
+ deadline_ms = deadline.infinite? ? nil : deadline.remaining_ms.round
50
+ TIMEx::Telemetry.instrument(
51
+ event: "strategy.call",
52
+ strategy: self.class.name_symbol,
53
+ deadline_ms:
54
+ ) do |payload|
55
+ run(deadline, &block)
56
+ rescue Expired => e
57
+ payload[:outcome] = :timeout
58
+ handle_timeout(on_timeout, e)
59
+ end
60
+ end
61
+
62
+ protected
63
+
64
+ # @param deadline [Deadline]
65
+ # @yieldparam deadline [Deadline]
66
+ # @return [Object]
67
+ # @raise [NotImplementedError] on +Base+
68
+ def run(_deadline)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ private
73
+
74
+ # Telemetry-free path when the adapter is {Telemetry::Adapters::Null}.
75
+ #
76
+ # @param deadline [Deadline]
77
+ # @param on_timeout [Symbol, Proc]
78
+ # @yieldparam deadline [Deadline]
79
+ # @return [Object]
80
+ def run_unobserved(deadline, on_timeout, &)
81
+ run(deadline, &)
82
+ rescue Expired => e
83
+ handle_timeout(on_timeout, e)
84
+ end
85
+
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Invokes +close_method+ on a watchdog thread after the deadline so blocking
6
+ # I/O surfaces as +IOError+ / +EBADF+ / +EPIPE+ (etc.), then maps those to
7
+ # {Expired} when the close was timer-driven.
8
+ #
9
+ # @note +close_method+ runs **concurrently** with the user block. It must be
10
+ # safe to call while the block uses the resource; avoid mutual deadlocks on
11
+ # the same mutex.
12
+ #
13
+ # @see Base
14
+ class Closeable < Base
15
+
16
+ # @param resource [Object] object receiving +close_method+
17
+ # @param close_method [Symbol] method name invoked to unblock I/O
18
+ def initialize(resource:, close_method: :close)
19
+ super()
20
+ @resource = resource
21
+ @close_method = close_method
22
+ end
23
+
24
+ protected
25
+
26
+ # @param deadline [Deadline]
27
+ # @yieldparam resource [Object] the configured resource
28
+ # @yieldparam deadline [Deadline]
29
+ # @return [Object]
30
+ # @raise [Expired] when I/O errors follow a timer-driven close
31
+ def run(deadline)
32
+ return yield(@resource, deadline) if deadline.infinite?
33
+
34
+ state = { closed_by_timer: false, block_done: false, mutex: Mutex.new }
35
+ timer = Thread.new do
36
+ remaining = deadline.remaining
37
+ ::Kernel.sleep(remaining) if remaining.positive?
38
+ # We claim the right to close under the mutex, but invoke
39
+ # `close_method` OUTSIDE it. A blocking `close` implementation
40
+ # would otherwise hold the mutex while the user's block reaches
41
+ # its ensure clause (which also acquires the mutex), causing
42
+ # deadlock.
43
+ should_close = state[:mutex].synchronize do
44
+ next false if state[:block_done]
45
+
46
+ state[:closed_by_timer] = true
47
+ true
48
+ end
49
+ if should_close
50
+ begin
51
+ @resource.public_send(@close_method)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ begin
58
+ yield(@resource, deadline)
59
+ rescue IOError,
60
+ Errno::EBADF, Errno::EPIPE, Errno::ECONNRESET,
61
+ Errno::ENOTCONN, Errno::ESHUTDOWN => e
62
+ raise unless state[:closed_by_timer]
63
+
64
+ raise deadline.expired_error(
65
+ strategy: :closeable,
66
+ message: "closeable deadline expired (#{e.class})"
67
+ )
68
+ ensure
69
+ state[:mutex].synchronize { state[:block_done] = true }
70
+ if timer.alive?
71
+ timer.kill
72
+ timer.join(0.1)
73
+ end
74
+ end
75
+ end
76
+
77
+ end
78
+ end
79
+ end
80
+
81
+ TIMEx::Registry.register(:closeable, TIMEx::Strategies::Closeable)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Default strategy: runs the block, then performs a final {#Deadline#check!}
6
+ # so purely CPU-bound work still observes expiry at cooperative points only.
7
+ #
8
+ # @see Base
9
+ class Cooperative < Base
10
+
11
+ protected
12
+
13
+ # @param deadline [Deadline]
14
+ # @yieldparam deadline [Deadline]
15
+ # @return [Object] block result after post-check
16
+ # @raise [Expired] when past deadline after the block returns
17
+ def run(deadline)
18
+ result = yield(deadline)
19
+ deadline.check!(strategy: :cooperative)
20
+ result
21
+ end
22
+
23
+ end
24
+ end
25
+ end
26
+
27
+ TIMEx::Registry.register(:cooperative, TIMEx::Strategies::Cooperative)