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,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/wait"
5
+ require "resolv"
6
+
7
+ module TIMEx
8
+ module Strategies
9
+ # Deadline-aware helpers for non-blocking socket I/O and DNS resolution.
10
+ #
11
+ # The nested singleton methods implement reusable primitives; {#run} simply
12
+ # yields the {Deadline} to the caller for custom protocols.
13
+ #
14
+ # @see Base
15
+ class IO < Base
16
+
17
+ # Minimum seconds passed to SO_RCVTIMEO / SO_SNDTIMEO. A packed timeval
18
+ # of zero often means "disable timeout" on POSIX, which would leave
19
+ # blocking reads unbounded when the remaining budget rounds to 0.
20
+ MIN_SOCKET_TIMEOUT = 0.001
21
+
22
+ class << self
23
+
24
+ # Reads up to +len+ bytes using non-blocking reads bounded by +deadline+.
25
+ #
26
+ # @param io [::IO]
27
+ # @param len [Integer] maximum bytes to read
28
+ # @param deadline [Deadline, Numeric, Time, nil]
29
+ # @return [String] data read (may be shorter than +len+)
30
+ # @raise [Expired] when the wait exhausts the budget
31
+ def read(io, len, deadline:)
32
+ deadline = Deadline.coerce(deadline)
33
+ loop do
34
+ return io.read_nonblock(len)
35
+ rescue ::IO::WaitReadable
36
+ wait_for(io, :read, deadline)
37
+ end
38
+ end
39
+
40
+ # Writes the full +buffer+, retrying on +IO::WaitWritable+ until done or expired.
41
+ #
42
+ # @param io [::IO]
43
+ # @param buffer [String]
44
+ # @param deadline [Deadline, Numeric, Time, nil]
45
+ # @return [Integer] total bytes written
46
+ # @raise [Expired] when the wait exhausts the budget
47
+ # @raise [IOError] when +write_nonblock+ reports zero progress
48
+ def write(io, buffer, deadline:)
49
+ deadline = Deadline.coerce(deadline)
50
+ total = buffer.bytesize
51
+ offset = 0
52
+ while offset < total
53
+ begin
54
+ # Avoid the per-iteration `byteslice` alloc on the common path
55
+ # where we write the whole buffer in one go; only slice once we
56
+ # know the kernel took a partial write.
57
+ chunk = offset.zero? ? buffer : buffer.byteslice(offset, total - offset)
58
+ n = io.write_nonblock(chunk)
59
+ raise ::IOError, "write_nonblock returned 0 bytes (no progress)" if n.zero?
60
+
61
+ offset += n
62
+ rescue ::IO::WaitWritable
63
+ wait_for(io, :write, deadline)
64
+ end
65
+ end
66
+ total
67
+ end
68
+
69
+ # Resolves +host+ (respecting +deadline+) and connects via the first
70
+ # working address family. Avoids +getaddrinfo+ blocking past the
71
+ # deadline by delegating to +Resolv+ with the remaining time.
72
+ #
73
+ # The returned socket also has SO_{RCV,SND}TIMEO applied to the
74
+ # remaining deadline so that subsequent blocking reads don't outlive
75
+ # the budget if the caller forgets to use {.read} / {.write}. Use
76
+ # +apply_timeouts: false+ to opt out.
77
+ #
78
+ # @param host [String]
79
+ # @param port [Integer]
80
+ # @param deadline [Deadline, Numeric, Time, nil]
81
+ # @param apply_timeouts [Boolean] when +true+, sets socket read/write timeouts
82
+ # @return [::Socket] connected stream socket
83
+ # @raise [SocketError] when resolution yields no addresses
84
+ # @raise [Expired] when resolution or connect exceeds the deadline
85
+ def connect(host, port, deadline:, apply_timeouts: true)
86
+ deadline = Deadline.coerce(deadline)
87
+ addresses = resolve_host(host, deadline)
88
+ raise ::SocketError, "could not resolve #{host}" if addresses.empty?
89
+
90
+ last_error = nil
91
+ addresses.each do |addr|
92
+ sock = open_socket(addr, port, deadline)
93
+ apply_socket_timeouts(sock, deadline:) if apply_timeouts
94
+ return sock
95
+ rescue Expired
96
+ raise
97
+ rescue StandardError => e
98
+ last_error = e
99
+ next
100
+ end
101
+ raise last_error || Errno::ECONNREFUSED.new("could not connect to #{host}:#{port}")
102
+ end
103
+
104
+ # Best-effort SO_{RCV,SND}TIMEO setter. The native +pack+ format for
105
+ # +struct timeval+ differs by platform (64-bit POSIX uses two +long+
106
+ # fields; Windows uses +DWORD+ milliseconds). When +SO_RCVTIMEO_FLOAT+
107
+ # is exposed (Darwin, some BSDs) we prefer it because the option's
108
+ # value is a raw +Float+, avoiding the packed-timeval mismatch.
109
+ # Failures are swallowed so callers can rely on +wait_for+ as the
110
+ # primary deadline guard.
111
+ #
112
+ # @param sock [::Socket]
113
+ # @param deadline [Deadline, Numeric, Time, nil]
114
+ # @return [void]
115
+ def apply_socket_timeouts(sock, deadline:)
116
+ deadline = Deadline.coerce(deadline)
117
+ return if deadline.infinite?
118
+
119
+ remaining = deadline.remaining
120
+ return if remaining <= 0
121
+
122
+ remaining = [remaining, MIN_SOCKET_TIMEOUT].max
123
+ if ::Socket.const_defined?(:SO_RCVTIMEO_FLOAT) && ::Socket.const_defined?(:SO_SNDTIMEO_FLOAT)
124
+ sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO_FLOAT, remaining)
125
+ sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_SNDTIMEO_FLOAT, remaining)
126
+ else
127
+ tv = pack_timeval(remaining)
128
+ sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, tv)
129
+ sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_SNDTIMEO, tv)
130
+ end
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ private
136
+
137
+ # @param seconds [Numeric]
138
+ # @return [String] packed timeval or Windows DWORD milliseconds
139
+ def pack_timeval(seconds)
140
+ secs = seconds.to_i
141
+ usecs = ((seconds - secs) * 1_000_000).to_i
142
+ if ::RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
143
+ [(seconds * 1000).to_i].pack("L") # Windows: DWORD ms
144
+ else
145
+ [secs, usecs].pack("l_l_") # POSIX: struct timeval { long, long }
146
+ end
147
+ end
148
+
149
+ # Resolves +host+ with a per-call +Resolv::DNS+ configured with the
150
+ # remaining deadline as its per-query timeout. We deliberately do NOT
151
+ # share the +Resolv::DNS+ instance across threads: +dns.timeouts=+
152
+ # mutates state, so two concurrent callers with different budgets
153
+ # would race on the setter and one would observe the other's timeout.
154
+ # +Resolv::DNS.new+ is cheap (a config parse + UDP socket on first
155
+ # query); per-call construction also sidesteps the post-fork
156
+ # FD-sharing problem entirely.
157
+ #
158
+ # @param host [String]
159
+ # @param deadline [Deadline]
160
+ # @return [Array<Array(Integer, String)>>] list of +[family, ip]+ tuples
161
+ def resolve_host(host, deadline)
162
+ if literal_ip?(host)
163
+ family = host.include?(":") ? ::Socket::AF_INET6 : ::Socket::AF_INET
164
+ return [[family, host]]
165
+ end
166
+
167
+ remaining = deadline.infinite? ? nil : deadline.remaining
168
+ raise deadline.expired_error(strategy: :io, message: "DNS deadline expired") if remaining && remaining <= 0
169
+
170
+ dns = ::Resolv::DNS.new
171
+ dns.timeouts = [remaining] if remaining
172
+ begin
173
+ resolver = ::Resolv.new([::Resolv::Hosts.new, dns])
174
+ resolver.getaddresses(host).map do |ip|
175
+ [ip.include?(":") ? ::Socket::AF_INET6 : ::Socket::AF_INET, ip]
176
+ end
177
+ ensure
178
+ dns.close if dns.respond_to?(:close)
179
+ end
180
+ rescue ::Resolv::ResolvError, ::Resolv::ResolvTimeout
181
+ raise deadline.expired_error(strategy: :io, message: "DNS deadline expired") if remaining && deadline.expired?
182
+
183
+ []
184
+ end
185
+
186
+ # @param host [String]
187
+ # @return [Boolean]
188
+ def literal_ip?(host)
189
+ host.match?(::Resolv::IPv4::Regex) || host.match?(::Resolv::IPv6::Regex)
190
+ end
191
+
192
+ # @param addr [Array(Integer, String)] +[family, ip]+
193
+ # @param port [Integer]
194
+ # @param deadline [Deadline]
195
+ # @return [::Socket]
196
+ def open_socket(addr, port, deadline)
197
+ family, ip = addr
198
+ sock = ::Socket.new(family, ::Socket::SOCK_STREAM, 0)
199
+ sockaddr = ::Socket.sockaddr_in(port, ip)
200
+ begin
201
+ sock.connect_nonblock(sockaddr)
202
+ rescue ::IO::WaitWritable
203
+ wait_for(sock, :write, deadline)
204
+ begin
205
+ sock.connect_nonblock(sockaddr)
206
+ rescue Errno::EISCONN
207
+ # connected
208
+ end
209
+ end
210
+ sock
211
+ rescue StandardError
212
+ sock&.close
213
+ raise
214
+ end
215
+
216
+ # @param io [::IO]
217
+ # @param direction [:read, :write]
218
+ # @param deadline [Deadline]
219
+ # @return [void]
220
+ # @raise [Expired] when not ready before expiry
221
+ def wait_for(io, direction, deadline)
222
+ remaining = deadline.remaining
223
+ remaining = nil if deadline.infinite?
224
+ raise deadline.expired_error(strategy: :io, message: "IO #{direction} deadline expired") if remaining && remaining <= 0
225
+
226
+ ready = direction == :read ? io.wait_readable(remaining) : io.wait_writable(remaining)
227
+ return if ready
228
+
229
+ raise deadline.expired_error(strategy: :io, message: "IO #{direction} deadline expired")
230
+ end
231
+
232
+ end
233
+
234
+ protected
235
+
236
+ # @param deadline [Deadline]
237
+ # @yieldparam deadline [Deadline]
238
+ # @return [Object]
239
+ def run(deadline)
240
+ yield(deadline)
241
+ end
242
+
243
+ end
244
+ end
245
+ end
246
+
247
+ TIMEx::Registry.register(:io, TIMEx::Strategies::IO)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Runs the block inside a +Ractor+ for isolation from the caller's heap.
6
+ #
7
+ # The block and captured state must be Ractor-shareable; typical service
8
+ # objects that close over +self+ will fail at runtime. Prefer a small frozen
9
+ # lambda that only uses the yielded {Deadline}.
10
+ #
11
+ # @note On timeout the waiter thread is stopped but the Ractor may keep
12
+ # running; {Telemetry.emit} records a leak event for operators.
13
+ #
14
+ # @see ::Ractor
15
+ # @see Base
16
+ class Ractor < Base
17
+
18
+ protected
19
+
20
+ # @param deadline [Deadline]
21
+ # @yieldparam deadline [Deadline]
22
+ # @return [Object] Ractor result value
23
+ # @raise [TIMEx::Error] when Ruby lacks Ractor support, the block is missing,
24
+ # or the block cannot be made shareable
25
+ # @raise [Expired] when the parent budget expires before the Ractor completes
26
+ def run(deadline, &block)
27
+ raise TIMEx::Error, "Ractor strategy requires a Ruby with Ractor support" unless defined?(::Ractor)
28
+ raise TIMEx::Error, "Ractor strategy requires a block" unless block
29
+
30
+ shareable_block = ensure_shareable(block)
31
+ shareable_deadline = ::Ractor.make_shareable(deadline)
32
+
33
+ ractor = ::Ractor.new(shareable_block, shareable_deadline) do |b, d|
34
+ b.call(d)
35
+ end
36
+
37
+ remaining = deadline.infinite? ? nil : deadline.remaining
38
+ waiter = Thread.new { ractor_value(ractor) }
39
+
40
+ if waiter.join(remaining)
41
+ waiter.value
42
+ else
43
+ # No public Ractor#kill — the Ractor will continue to completion
44
+ # in the background. Surface this leak via telemetry so operators
45
+ # can spot misbehaving children. Stop our own waiter so we don't
46
+ # leak the Thread too.
47
+ waiter.kill if waiter.alive?
48
+ TIMEx::Telemetry.emit(
49
+ event: "ractor.leak",
50
+ deadline_ms: deadline.initial_ms&.round
51
+ )
52
+ raise deadline.expired_error(
53
+ strategy: :ractor,
54
+ message: "ractor deadline expired"
55
+ )
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # @param block [Proc]
62
+ # @return [Proc]
63
+ # @raise [TIMEx::Error] when the block captures non-shareable state
64
+ def ensure_shareable(block)
65
+ return block if ::Ractor.shareable?(block)
66
+
67
+ ::Ractor.make_shareable(block)
68
+ rescue ::Ractor::IsolationError, ArgumentError, TypeError => e
69
+ raise TIMEx::Error,
70
+ "Ractor strategy requires a shareable block; the supplied block " \
71
+ "captures non-shareable state (#{e.class}: #{e.message})"
72
+ end
73
+
74
+ # @param ractor [Ractor]
75
+ # @return [Object]
76
+ def ractor_value(ractor)
77
+ ractor.respond_to?(:value) ? ractor.value : ractor.take
78
+ end
79
+
80
+ end
81
+ end
82
+ end
83
+
84
+ TIMEx::Registry.register(:ractor, TIMEx::Strategies::Ractor) if defined?(Ractor)
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Runs user code in a forked child and returns the marshalled result to the parent.
6
+ #
7
+ # @note The child inherits all open file descriptors; shared connections can
8
+ # corrupt the parent if reused in the child. Re-open resources in the child
9
+ # or close inherited FDs deliberately.
10
+ #
11
+ # @see Base
12
+ class Subprocess < Base
13
+
14
+ # Message used when the child exits without producing a marshalled result
15
+ # (segfault, OOM, exec, etc.) so the parent can distinguish from a
16
+ # legitimate `[:ok, nil]` return.
17
+ EMPTY_PAYLOAD = "the child exited without producing a result"
18
+
19
+ # Default ceiling on the marshalled child payload. A buggy or malicious
20
+ # block can otherwise drive the parent OOM by streaming arbitrary data
21
+ # through the pipe before the deadline fires.
22
+ DEFAULT_MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
23
+
24
+ # @param kill_after [Numeric] seconds to wait after TERM before KILL when reaping
25
+ # @param max_payload_bytes [Integer] hard cap on bytes read from the result pipe
26
+ # @raise [ArgumentError] when parameters are out of range
27
+ def initialize(kill_after: 0.5, max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES)
28
+ super()
29
+ raise ArgumentError, "kill_after must be a non-negative Numeric" unless kill_after.is_a?(Numeric) && !kill_after.negative?
30
+ raise ArgumentError, "max_payload_bytes must be a positive Integer" unless max_payload_bytes.is_a?(Integer) && max_payload_bytes.positive?
31
+
32
+ @kill_after = kill_after
33
+ @max_payload_bytes = max_payload_bytes
34
+ end
35
+
36
+ protected
37
+
38
+ # @param deadline [Deadline]
39
+ # @yieldparam deadline [Deadline]
40
+ # @return [Object] unmarshalled child return value
41
+ # @raise [TIMEx::Error] when +fork+ is unavailable
42
+ # @raise [Expired] when the parent budget expires waiting for the child
43
+ # @raise [Exception] when the child marshals +[:error, exception]+
44
+ def run(deadline)
45
+ raise TIMEx::Error, "Subprocess strategy requires fork (unavailable on this platform)" unless ::Process.respond_to?(:fork)
46
+
47
+ reader, writer = ::IO.pipe
48
+ # Mark pipe FDs close-on-exec so they are not inherited by unrelated
49
+ # programs started via `Process.spawn`/`exec` from this process. The
50
+ # parent and this fork still share the pipe by design (child closes
51
+ # reader, parent closes writer); `Marshal.load` reads only from our
52
+ # child, not from arbitrary inherited writers.
53
+ reader.close_on_exec = true
54
+ writer.close_on_exec = true
55
+ pid = begin
56
+ ::Process.fork do
57
+ reader.close
58
+ # If `setpgid` raises in the child we MUST NOT proceed: the child's
59
+ # pgid is still equal to the parent's, and `kill(-pid)` from the
60
+ # parent would target the parent's process group. Bail out rather
61
+ # than risk wiping the parent.
62
+ begin
63
+ ::Process.setpgid(0, 0) if ::Process.respond_to?(:setpgid)
64
+ rescue StandardError
65
+ ::Kernel.exit!(1)
66
+ end
67
+ run_child(writer, deadline) { yield(deadline) }
68
+ end
69
+ ensure
70
+ writer.close unless writer.closed?
71
+ end
72
+
73
+ # Race-free pgid setup: the child also calls setpgid(0, 0), but until
74
+ # that lands the child's pgid is the parent's. Setting it from the
75
+ # parent and confirming success closes the window during which
76
+ # `terminate(-pid)` would silently miss its target (or hit the
77
+ # parent's group). We carry the outcome into terminate/reap so a
78
+ # failed setpgid never leads to `kill(-pid)`.
79
+ pgid_established = false
80
+ if ::Process.respond_to?(:setpgid)
81
+ begin
82
+ ::Process.setpgid(pid, pid)
83
+ pgid_established = true
84
+ rescue Errno::EACCES
85
+ pgid_established = true # child already setpgid'd itself
86
+ rescue Errno::ESRCH, StandardError
87
+ pgid_established = false
88
+ end
89
+ end
90
+
91
+ completed = false
92
+ begin
93
+ value = wait_for_child(pid, reader, deadline, pgid_established:)
94
+ completed = true
95
+ value
96
+ ensure
97
+ reader.close unless reader.closed?
98
+ reap_async(pid, pgid_established:) unless completed
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def run_child(writer, deadline)
105
+ value = yield
106
+ ::Marshal.dump([:ok, value], writer)
107
+ rescue Exception => e # rubocop:disable Lint/RescueException
108
+ # Forward every exception (including {Expired}, +SystemExit+,
109
+ # +SignalException+, etc.) as `[:error, e]` so the parent can
110
+ # `rescue TIMEx::Expired` directly and observe the original
111
+ # strategy/deadline_ms metadata. `safe_dump` falls back to a plain
112
+ # RuntimeError if a particular exception class can't be marshalled.
113
+ safe_dump(writer, [:error, e])
114
+ ensure
115
+ writer.close unless writer.closed?
116
+ ::Kernel.exit!(0)
117
+ end
118
+
119
+ def safe_dump(writer, payload)
120
+ ::Marshal.dump(payload, writer)
121
+ rescue StandardError
122
+ fallback_dump(writer, payload)
123
+ end
124
+
125
+ def fallback_dump(writer, payload)
126
+ kind, value = payload
127
+ ::Marshal.dump([kind, RuntimeError.new(value.message)], writer)
128
+ rescue StandardError
129
+ nil
130
+ end
131
+
132
+ def wait_for_child(pid, reader, deadline, pgid_established: false)
133
+ deadline_remaining = deadline.infinite? ? nil : deadline.remaining
134
+ ready = ::IO.select([reader], nil, nil, deadline_remaining) # rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
135
+
136
+ if ready.nil?
137
+ reap_async(pid, pgid_established:)
138
+ raise deadline.expired_error(
139
+ strategy: :subprocess,
140
+ message: "subprocess deadline expired"
141
+ )
142
+ end
143
+
144
+ data = drain_reader(reader, deadline)
145
+ expired = deadline.expired?
146
+ # Reap synchronously only if we still have budget; otherwise hand off
147
+ # to `reap_async`. `safe_waitpid` swallows ECHILD so a concurrent
148
+ # signal handler (or the reaper raced with our drain) cannot crash
149
+ # the parent here.
150
+ expired ? reap_async(pid, pgid_established:) : safe_waitpid(pid, 0)
151
+
152
+ if data.nil? || data.empty?
153
+ # A child reaped before flushing typically means the deadline reaper
154
+ # killed it. Prefer Expired so callers can rescue it consistently.
155
+ if expired
156
+ raise deadline.expired_error(
157
+ strategy: :subprocess,
158
+ message: "subprocess deadline expired (no payload)"
159
+ )
160
+ end
161
+
162
+ raise TIMEx::Error, EMPTY_PAYLOAD
163
+ end
164
+
165
+ kind, value = begin
166
+ ::Marshal.load(data) # rubocop:disable Security/MarshalLoad
167
+ rescue ArgumentError, TypeError => e
168
+ # A truncated payload typically means the child was killed mid-write
169
+ # by our deadline reaper. Surface that as Expired rather than a
170
+ # generic Error so callers can rescue it consistently.
171
+ if expired
172
+ raise deadline.expired_error(
173
+ strategy: :subprocess,
174
+ message: "subprocess deadline expired (truncated payload)"
175
+ )
176
+ end
177
+
178
+ raise TIMEx::Error, "subprocess produced unreadable payload (#{e.class}: #{e.message})"
179
+ end
180
+ kind == :ok ? value : raise(value)
181
+ end
182
+
183
+ # Reads remaining bytes from `reader` without blocking past the parent
184
+ # deadline. If the child wrote a partial payload then crashed mid-cleanup,
185
+ # `reader.read` (blocking) could hang forever; loop on `read_nonblock`
186
+ # bounded by `deadline.remaining` instead. Caps the buffer at
187
+ # `@max_payload_bytes` so a runaway child cannot OOM the parent.
188
+ def drain_reader(reader, deadline)
189
+ buf = +""
190
+ cap = @max_payload_bytes
191
+ loop do
192
+ chunk = reader.read_nonblock(4096, exception: false)
193
+ case chunk
194
+ when :wait_readable
195
+ remaining = deadline.infinite? ? nil : [deadline.remaining, 0.0].max
196
+ return buf if remaining && remaining <= 0
197
+ return buf unless reader.wait_readable(remaining)
198
+ when nil # EOF
199
+ return buf
200
+ else
201
+ buf << chunk
202
+ raise TIMEx::Error, "subprocess payload exceeded #{cap} bytes" if buf.bytesize > cap
203
+ end
204
+ end
205
+ end
206
+
207
+ def terminate(pid, pgid_established: false)
208
+ signal_target = pgid_established ? -pid : pid
209
+ kill_signal(signal_target, "TERM")
210
+ wait_or_kill(pid, signal_target)
211
+ end
212
+
213
+ # Off-thread reaper so the request thread isn't blocked by `kill_after +
214
+ # waitpid`. We deliberately do NOT call `Process.detach` here: detaching
215
+ # lets the kernel reap the zombie as soon as it exits, freeing the PID
216
+ # for reuse. A subsequent `kill(KILL, pid)` could then signal an
217
+ # unrelated freshly-spawned process owned by the same uid. By owning
218
+ # `waitpid` ourselves we keep the zombie around until *we've* observed
219
+ # it, so the PID cannot be recycled out from under us.
220
+ def reap_async(pid, pgid_established: false)
221
+ signal_target = pgid_established ? -pid : pid
222
+ kill_signal(signal_target, "TERM")
223
+ Thread.new { wait_or_kill(pid, signal_target) }
224
+ nil
225
+ end
226
+
227
+ # Polls for the child's exit using `WNOHANG`; if it doesn't drain within
228
+ # `@kill_after`, escalates to `KILL` and waits synchronously. Either way
229
+ # we always own the final `waitpid`, so the PID cannot be recycled while
230
+ # we still hold a reference to it.
231
+ def wait_or_kill(pid, signal_target)
232
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @kill_after
233
+ loop do
234
+ reaped = safe_waitpid(pid, ::Process::WNOHANG)
235
+ return if reaped == pid
236
+
237
+ break if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
238
+
239
+ sleep 0.01
240
+ end
241
+ kill_signal(signal_target, "KILL")
242
+ safe_waitpid(pid, 0)
243
+ end
244
+
245
+ def safe_waitpid(pid, flags)
246
+ ::Process.waitpid(pid, flags)
247
+ rescue Errno::ECHILD, Errno::ESRCH
248
+ pid # treat "already gone" as reaped so callers stop signalling
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ def kill_signal(target, signal)
254
+ ::Process.kill(signal, target)
255
+ rescue StandardError
256
+ # ESRCH: target already exited. EPERM: target was reaped + the PID
257
+ # got handed to another uid before we got here (the very situation
258
+ # `wait_or_kill` exists to prevent, but we still don't want to crash
259
+ # the request thread). All other Errno::* swallowed for parity.
260
+ nil
261
+ end
262
+
263
+ end
264
+ end
265
+ end
266
+
267
+ TIMEx::Registry.register(:subprocess, TIMEx::Strategies::Subprocess)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TIMEx
4
+ module Strategies
5
+ # Watchdog thread that raises {Expired} into the caller via +Thread#raise+
6
+ # after the deadline. **Unsafe**: does not stop CPU work; can leave shared
7
+ # state inconsistent if the block is not written for asynchronous exceptions.
8
+ #
9
+ # @note Prefer {Cooperative}, {IO}, {Closeable}, or {Subprocess} unless you
10
+ # explicitly accept these semantics.
11
+ #
12
+ # @see Base
13
+ class Unsafe < Base
14
+
15
+ protected
16
+
17
+ # @param deadline [Deadline]
18
+ # @yieldparam deadline [Deadline]
19
+ # @return [Object]
20
+ # @raise [Expired] from the watcher thread when time elapses before completion
21
+ def run(deadline)
22
+ return yield(deadline) if deadline.infinite?
23
+
24
+ target = Thread.current
25
+ state = { block_done: false, mutex: Mutex.new }
26
+ watcher = Thread.new do
27
+ remaining = deadline.remaining
28
+ ::Kernel.sleep(remaining) if remaining.positive?
29
+ state[:mutex].synchronize do
30
+ next if state[:block_done]
31
+ next unless target.alive?
32
+
33
+ target.raise(
34
+ deadline.expired_error(
35
+ strategy: :unsafe,
36
+ message: "unsafe deadline expired"
37
+ )
38
+ )
39
+ end
40
+ end
41
+ begin
42
+ yield(deadline)
43
+ ensure
44
+ state[:mutex].synchronize { state[:block_done] = true }
45
+ watcher.kill if watcher.alive?
46
+ watcher.join(0.1)
47
+ end
48
+ end
49
+
50
+ end
51
+ end
52
+ end
53
+
54
+ TIMEx::Registry.register(:unsafe, TIMEx::Strategies::Unsafe)