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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/CHANGELOG.md +11 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +4 -0
- data/README.md +112 -0
- data/Rakefile +28 -0
- data/lib/generators/timex/install_generator.rb +21 -0
- data/lib/generators/timex/templates/install.rb +54 -0
- data/lib/timex/auto_check.rb +59 -0
- data/lib/timex/cancellation_token.rb +84 -0
- data/lib/timex/clock.rb +113 -0
- data/lib/timex/composers/adaptive.rb +222 -0
- data/lib/timex/composers/base.rb +20 -0
- data/lib/timex/composers/hedged.rb +146 -0
- data/lib/timex/composers/two_phase.rb +97 -0
- data/lib/timex/configuration.rb +163 -0
- data/lib/timex/deadline.rb +458 -0
- data/lib/timex/expired.rb +77 -0
- data/lib/timex/named_component.rb +33 -0
- data/lib/timex/on_timeout.rb +15 -0
- data/lib/timex/propagation/http_header.rb +49 -0
- data/lib/timex/propagation/rack_middleware.rb +180 -0
- data/lib/timex/registry.rb +132 -0
- data/lib/timex/result.rb +137 -0
- data/lib/timex/strategies/base.rb +88 -0
- data/lib/timex/strategies/closeable.rb +81 -0
- data/lib/timex/strategies/cooperative.rb +27 -0
- data/lib/timex/strategies/io.rb +247 -0
- data/lib/timex/strategies/ractor.rb +84 -0
- data/lib/timex/strategies/subprocess.rb +267 -0
- data/lib/timex/strategies/unsafe.rb +54 -0
- data/lib/timex/strategies/wakeup.rb +154 -0
- data/lib/timex/telemetry/adapters.rb +173 -0
- data/lib/timex/telemetry.rb +119 -0
- data/lib/timex/test/virtual_clock.rb +51 -0
- data/lib/timex/timeout_handling.rb +39 -0
- data/lib/timex/version.rb +8 -0
- data/lib/timex.rb +79 -0
- data/mkdocs.yml +193 -0
- 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)
|