pitchfork 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0b28e12be38614fe1056d54a89d58882d59100d2982fac86d844574c0c3c196
4
- data.tar.gz: 64bf5dfc0d7fc843add452d7737137c96d466f550a95cfd50abe2d0e12b0caad
3
+ metadata.gz: ccd444fbdc89d5a253819e7be03e5d097c5aaf8b061980e15d4ec5326d2c54b5
4
+ data.tar.gz: f300bd3135e8d8d9e15f14dfe2780c75521ececeb72b8245fef0380e2ba338f5
5
5
  SHA512:
6
- metadata.gz: 6bf6a9ffeed660e5cbacf4a3d86c536f28472f9842cf79ad15cd1a1ac2fc7bab351c19040c775e080a2ab4fd0c83607c455b691701d30c308ac9050c968b9ebf
7
- data.tar.gz: 789e03e4b35df56c3eb91eb7836aa865f6948e12e959ea196c0fbd757dc6b69be802f005547a6e52ae6ca1932f8a4b0c9cba74a495297d9e138e915ebdc3937f
6
+ metadata.gz: b054a33d46f4b60c14e44fbac330bc4318a1b52259e89f2e8a1c16a58d8467f02eef981dbe018906a0c7a027c98db75a8efd483619d7ea5e2ece234b30b3846f
7
+ data.tar.gz: b12a622275fe638d49be5d9457d11309421119f9628044dec9898231f83425cab6aa6434b9f42372c5f8954b261f7e979e300a41fa75c39172fb8e1832cb0805
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Unreleased
2
2
 
3
+ - Added a soft timeout in addition to the historical Unicorn hard timeout.
4
+ On soft timeout, the `after_worker_timeout` callback is invoked.
5
+ - Implement `after_request_complete` callback.
6
+
3
7
  # 0.4.1
4
8
 
5
9
  - Avoid a Rack 3 deprecation warning.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pitchfork (0.4.1)
4
+ pitchfork (0.5.0)
5
5
  rack (>= 2.0)
6
6
  raindrops (~> 0.7)
7
7
 
@@ -70,5 +70,5 @@ handle network/server failures.
70
70
  The `timeout` mechanism in pitchfork is an extreme solution that should
71
71
  be avoided whenever possible.
72
72
  It will help preserve the platform if your application or a dependency
73
- has a bug that cause it to either get stuck or two slow, but it is not a
73
+ has a bug that cause it to either get stuck or be too slow, but it is not a
74
74
  solution to such bugs, merely a mitigation.
@@ -173,33 +173,55 @@ The following options may be specified (but are generally not needed):
173
173
  ### `timeout`
174
174
 
175
175
  ```ruby
176
- timeout 10
176
+ timeout 10, cleanup: 3
177
177
  ```
178
178
 
179
- Sets the timeout of worker processes to a number of seconds.
180
- Workers handling the request/app.call/response cycle taking longer than
181
- this time period will be forcibly killed (via `SIGKILL`).
179
+ Sets the timeout for worker processes to a number of seconds.
182
180
 
183
- This timeout mecanism shouldn't be routinely relying on, and should
181
+ Note that Pitchfork has two layers of timeout.
182
+
183
+ A first "soft" timeout will invoke the `after_worker_timeout` from
184
+ within the worker (but from a background thread) and then call `exit`
185
+ to terminate the worker cleanly.
186
+
187
+ The second "hard" timeout, is the sum of `timeout` and `cleanup`.
188
+ Workers taking longer than this time period to be ready to handle a new
189
+ request will be forcibly killed (via `SIGKILL`).
190
+
191
+ Neither of these timeout mecanisms should be routinely relied on, and should
184
192
  instead be considered as a last line of defense in case you application
185
193
  is impacted by bugs causing unexpectedly slow response time, or fully stuck
186
194
  processes.
187
195
 
196
+ If some of the application endpoints require an unreasonably large timeout,
197
+ rather than to increase the global application timeout, it is possible to
198
+ adjust it on a per request basis via the rack request environment:
199
+
200
+ ```ruby
201
+ class MyMiddleware
202
+ def call(env)
203
+ if slow_endpoint?(env)
204
+ # Give 10 more seconds
205
+ env["pitchfork.timeout"]&.extend_deadline(10)
206
+ end
207
+ @app.call(env)
208
+ end
209
+ end
210
+ ```
211
+
188
212
  Make sure to read the guide on [application timeouts](Application_Timeouts.md).
189
213
 
190
- This configuration defaults to a (too) generous 20 seconds, it is
191
- highly recommended to set a stricter one based on your application
192
- profile.
214
+ This configuration defaults to a (too) generous 20 seconds for the soft timeout
215
+ and an extra 2 seconds for the hard timeout. It is highly recommended to set a
216
+ stricter one based on your application profile.
193
217
 
194
- This timeout is enforced by the master process itself and not subject
195
- to the scheduling limitations by the worker process.
196
218
  Due the low-complexity, low-overhead implementation, timeouts of less
197
219
  than 3.0 seconds can be considered inaccurate and unsafe.
198
220
 
199
221
  For running Pitchfork behind nginx, it is recommended to set
200
222
  "fail_timeout=0" for in your nginx configuration like this
201
223
  to have nginx always retry backends that may have had workers
202
- SIGKILL-ed due to timeouts.
224
+ exit or be SIGKILL-ed due to timeouts.
203
225
 
204
226
  ```
205
227
  upstream pitchfork_backend {
@@ -284,6 +306,36 @@ after_worker_ready do |server, worker|
284
306
  end
285
307
  ```
286
308
 
309
+ ### `after_worker_timeout`
310
+
311
+ Called by the worker process when the request timeout is elapsed:
312
+
313
+ ```ruby
314
+ after_worker_timeout do |server, worker, timeout_info|
315
+ timeout_info.copy_thread_variables!
316
+ timeout_info.thread.kill
317
+ server.logger.error("Request timed out: #{timeout_info.rack_env.inspect}")
318
+ $stderr.puts timeout_info.thread.backtrace
319
+ end
320
+ ```
321
+
322
+ Note that this callback is invoked from a different thread. You can access the
323
+ main thread via `timeout_info.thread`, as well as the rack environment via `timeout_info.rack_env`.
324
+
325
+ If you need to invoke cleanup code that rely on thread local state, you can copy
326
+ that state with `timeout_info.copy_thread_variables!`, but it's best avoided as the
327
+ thread local state could contain thread unsafe objects.
328
+
329
+ Also note that at this stage, the thread is still alive, if your callback does
330
+ substantial work, you may want to kill the thread.
331
+
332
+ After the callback is executed the worker will exit with status `0`.
333
+
334
+ It is recommended not to do slow operations in this callback, but if you
335
+ really have to, make sure to configure the `cleanup` timeout so that the
336
+ callback has time to complete before the "hard" timeout triggers.
337
+ By default the cleanup timeout is 2 seconds.
338
+
287
339
  ### `after_worker_exit`
288
340
 
289
341
  Called in the master process after a worker exits.
@@ -297,6 +349,20 @@ after_worker_exit do |server, worker, status|
297
349
  end
298
350
  ```
299
351
 
352
+ ### `after_request_complete`
353
+
354
+ Called in the worker processes after a request has completed.
355
+
356
+ Can be used for out of band work, or to exit unhealthy workers.
357
+
358
+ ```ruby
359
+ after_request_complete do |server, worker|
360
+ if something_wrong?
361
+ exit
362
+ end
363
+ end
364
+ ```
365
+
300
366
  ## Reforking
301
367
 
302
368
  ### `refork_after`
@@ -27,7 +27,9 @@ module Pitchfork
27
27
 
28
28
  # Default settings for Pitchfork
29
29
  DEFAULTS = {
30
- :timeout => 20,
30
+ :soft_timeout => 20,
31
+ :cleanup_timeout => 2,
32
+ :timeout => 22,
31
33
  :logger => Logger.new($stderr),
32
34
  :worker_processes => 1,
33
35
  :after_worker_fork => lambda { |server, worker|
@@ -53,6 +55,8 @@ module Pitchfork
53
55
  :after_worker_ready => lambda { |server, worker|
54
56
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} ready")
55
57
  },
58
+ :after_worker_timeout => nil,
59
+ :after_request_complete => nil,
56
60
  :early_hints => false,
57
61
  :refork_condition => nil,
58
62
  :check_client_connection => false,
@@ -131,15 +135,22 @@ module Pitchfork
131
135
  set_hook(:after_worker_ready, block_given? ? block : args[0])
132
136
  end
133
137
 
138
+ def after_worker_timeout(*args, &block)
139
+ set_hook(:after_worker_timeout, block_given? ? block : args[0], 3)
140
+ end
141
+
134
142
  def after_worker_exit(*args, &block)
135
143
  set_hook(:after_worker_exit, block_given? ? block : args[0], 3)
136
144
  end
137
145
 
138
- def timeout(seconds)
139
- set_int(:timeout, seconds, 3)
140
- # POSIX says 31 days is the smallest allowed maximum timeout for select()
141
- max = 30 * 60 * 60 * 24
142
- set[:timeout] = seconds > max ? max : seconds
146
+ def after_request_complete(*args, &block)
147
+ set_hook(:after_request_complete, block_given? ? block : args[0])
148
+ end
149
+
150
+ def timeout(seconds, cleanup: 2)
151
+ soft_timeout = set_int(:soft_timeout, seconds, 3)
152
+ cleanup_timeout = set_int(:cleanup_timeout, cleanup, 2)
153
+ set_int(:timeout, soft_timeout + cleanup_timeout, 5)
143
154
  end
144
155
 
145
156
  def worker_processes(nr)
@@ -1,6 +1,7 @@
1
1
  # -*- encoding: binary -*-
2
2
  require 'pitchfork/pitchfork_http'
3
3
  require 'pitchfork/flock'
4
+ require 'pitchfork/soft_timeout'
4
5
 
5
6
  module Pitchfork
6
7
  # This is the process manager of Pitchfork. This manages worker
@@ -8,13 +9,75 @@ module Pitchfork
8
9
  # Listener sockets are started in the master process and shared with
9
10
  # forked worker children.
10
11
  class HttpServer
12
+ class TimeoutHandler
13
+ class Info
14
+ attr_reader :thread, :rack_env
15
+
16
+ def initialize(thread, rack_env)
17
+ @thread = thread
18
+ @rack_env = rack_env
19
+ end
20
+
21
+ def copy_thread_variables!
22
+ current_thread = Thread.current
23
+ @thread.keys.each do |key|
24
+ current_thread[key] = @thread[key]
25
+ end
26
+ @thread.thread_variables.each do |variable|
27
+ current_thread.thread_variable_set(variable, @thread.thread_variable_get(variable))
28
+ end
29
+ end
30
+ end
31
+
32
+ attr_writer :rack_env, :timeout_request # :nodoc:
33
+
34
+ def initialize(server, worker, callback) # :nodoc:
35
+ @server = server
36
+ @worker = worker
37
+ @callback = callback
38
+ @rack_env = nil
39
+ @timeout_request = nil
40
+ end
41
+
42
+ def inspect
43
+ "#<Pitchfork::HttpServer::TimeoutHandler##{object_id}>"
44
+ end
45
+
46
+ def call(original_thread) # :nodoc:
47
+ @server.logger.error("worker=#{@worker.nr} pid=#{@worker.pid} timed out, exiting")
48
+ if @callback
49
+ @callback.call(@server, @worker, Info.new(original_thread, @rack_env))
50
+ end
51
+ rescue => error
52
+ Pitchfork.log_error(@server.logger, "after_worker_timeout error", error)
53
+ ensure
54
+ exit
55
+ end
56
+
57
+ def finished # :nodoc:
58
+ @timeout_request.finished
59
+ end
60
+
61
+ def deadline
62
+ @timeout_request.deadline
63
+ end
64
+
65
+ def extend_deadline(extra_time)
66
+ extra_time = Integer(extra_time)
67
+ @worker.deadline += extra_time
68
+ @timeout_request.extend_deadline(extra_time)
69
+ self
70
+ end
71
+ end
72
+
11
73
  # :stopdoc:
12
- attr_accessor :app, :timeout, :worker_processes,
74
+ attr_accessor :app, :timeout, :soft_timeout, :cleanup_timeout, :worker_processes,
13
75
  :after_worker_fork, :after_mold_fork,
14
76
  :listener_opts, :children,
15
77
  :orig_app, :config, :ready_pipe,
16
78
  :default_middleware, :early_hints
17
- attr_writer :after_worker_exit, :after_worker_ready, :refork_condition
79
+ attr_writer :after_worker_exit, :after_worker_ready, :after_request_complete, :refork_condition,
80
+ :after_worker_timeout
18
81
 
19
82
  attr_reader :logger
20
83
  include Pitchfork::SocketHelper
@@ -394,25 +457,30 @@ module Pitchfork
394
457
 
395
458
  # forcibly terminate all workers that haven't checked in in timeout seconds. The timeout is implemented using an unlinked File
396
459
  def murder_lazy_workers
397
- next_sleep = @timeout - 1
398
460
  now = Pitchfork.time_now(true)
461
+ next_sleep = @timeout - 1
462
+
399
463
  @children.workers.each do |worker|
400
- tick = worker.tick
401
- 0 == tick and next # skip workers that haven't processed any clients
402
- diff = now - tick
403
- tmp = @timeout - diff
404
- if tmp >= 0
405
- next_sleep > tmp and next_sleep = tmp
464
+ deadline = worker.deadline
465
+ if 0 == deadline # worker is idle
406
466
  next
467
+ elsif deadline > now # worker still has time
468
+ time_left = deadline - now
469
+ if time_left < next_sleep
470
+ next_sleep = time_left
471
+ end
472
+ next
473
+ else # worker is out of time
474
+ next_sleep = 0
475
+ if worker.mold?
476
+ logger.error "mold pid=#{worker.pid} deadline=#{deadline} timed out, killing"
477
+ else
478
+ logger.error "worker=#{worker.nr} pid=#{worker.pid} deadline=#{deadline} timed out, killing"
479
+ end
480
+ kill_worker(:KILL, worker.pid) # take no prisoners for hard timeout violations
407
481
  end
408
- next_sleep = 0
409
- if worker.mold?
410
- logger.error "mold pid=#{worker.pid} timeout (#{diff}s > #{@timeout}s), killing"
411
- else
412
- logger.error "worker=#{worker.nr} pid=#{worker.pid} timeout (#{diff}s > #{@timeout}s), killing"
413
- end
414
- kill_worker(:KILL, worker.pid) # take no prisoners for timeout violations
415
482
  end
483
+
416
484
  next_sleep <= 0 ? 1 : next_sleep
417
485
  end
418
486
 
@@ -577,9 +645,12 @@ module Pitchfork
577
645
 
578
646
  # once a client is accepted, it is processed in its entirety here
579
647
  # in 3 easy steps: read request, call app, write app response
580
- def process_client(client)
648
+ def process_client(client, timeout_handler)
649
+ env = nil
581
650
  @request = Pitchfork::HttpParser.new
582
651
  env = @request.read(client)
652
+ timeout_handler.rack_env = env
653
+ env["pitchfork.timeout"] = timeout_handler
583
654
 
584
655
  if early_hints
585
656
  env["rack.early_hints"] = lambda do |headers|
@@ -616,6 +687,7 @@ module Pitchfork
616
687
  handle_error(client, e)
617
688
  ensure
618
689
  env["rack.after_reply"].each(&:call) if env
690
+ timeout_handler.finished
619
691
  end
620
692
 
621
693
  def nuke_listeners!(readers)
@@ -684,7 +756,7 @@ module Pitchfork
684
756
 
685
757
  while readers[0]
686
758
  begin
687
- worker.tick = Pitchfork.time_now(true)
759
+ worker.update_deadline(@timeout)
688
760
  while sock = ready.shift
689
761
  # Pitchfork::Worker#accept_nonblock is not like accept(2) at all,
690
762
  # but that will return false
@@ -697,15 +769,16 @@ module Pitchfork
697
769
  when Message
698
770
  worker.update(client)
699
771
  else
700
- process_client(client)
772
+ process_client(client, prepare_timeout(worker))
773
+ @after_request_complete&.call(self, worker)
701
774
  worker.increment_requests_count
702
775
  end
703
- worker.tick = Pitchfork.time_now(true)
776
+ worker.update_deadline(@timeout)
704
777
  end
705
778
  end
706
779
 
707
- # timeout so we can .tick and keep parent from SIGKILL-ing us
708
- worker.tick = Pitchfork.time_now(true)
780
+ # timeout so we can update .deadline and keep parent from SIGKILL-ing us
781
+ worker.update_deadline(@timeout)
709
782
 
710
783
  if @refork_condition && !worker.outdated?
711
784
  if @refork_condition.met?(worker, logger)
@@ -754,7 +827,7 @@ module Pitchfork
754
827
 
755
828
  while readers[0]
756
829
  begin
757
- mold.tick = Pitchfork.time_now(true)
830
+ mold.update_deadline(@timeout)
758
831
  while sock = ready.shift
759
832
  # Pitchfork::Worker#accept_nonblock is not like accept(2) at all,
760
833
  # but that will return false
@@ -774,7 +847,7 @@ module Pitchfork
774
847
  end
775
848
 
776
849
  # timeout so we can .tick and keep parent from SIGKILL-ing us
777
- mold.tick = Pitchfork.time_now(true)
850
+ mold.update_deadline(@timeout)
778
851
  waiter.get_readers(ready, readers, @timeout * 500) # to milliseconds, but halved
779
852
  rescue => e
780
853
  Pitchfork.log_error(@logger, "mold loop error", e) if readers[0]
@@ -832,5 +905,11 @@ module Pitchfork
832
905
  listeners.each { |addr| listen(addr) }
833
906
  raise ArgumentError, "no listeners" if LISTENERS.empty?
834
907
  end
908
+
909
+ def prepare_timeout(worker)
910
+ handler = TimeoutHandler.new(self, worker, @after_worker_timeout)
911
+ handler.timeout_request = SoftTimeout.request(@soft_timeout, handler)
912
+ handler
913
+ end
835
914
  end
836
915
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pitchfork
4
+ # :stopdoc:
5
+ module SoftTimeout
6
+ extend self
7
+
8
+ CONDVAR = ConditionVariable.new
9
+ QUEUE = Queue.new
10
+ QUEUE_MUTEX = Mutex.new
11
+ TIMEOUT_THREAD_MUTEX = Mutex.new
12
+ @timeout_thread = nil
13
+ private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX
14
+
15
+ class Request
16
+ attr_reader :deadline, :thread
17
+
18
+ def initialize(thread, timeout, block)
19
+ @thread = thread
20
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
21
+ @block = block
22
+
23
+ @mutex = Mutex.new
24
+ @done = false # protected by @mutex
25
+ end
26
+
27
+ def extend_deadline(timeout)
28
+ @deadline += timeout
29
+ QUEUE_MUTEX.synchronize do
30
+ CONDVAR.signal
31
+ end
32
+ self
33
+ end
34
+
35
+ def done?
36
+ @mutex.synchronize do
37
+ @done
38
+ end
39
+ end
40
+
41
+ def expired?(now)
42
+ now >= @deadline
43
+ end
44
+
45
+ def interrupt
46
+ @mutex.synchronize do
47
+ unless @done
48
+ begin
49
+ @block.call(@thread)
50
+ ensure
51
+ @done = true
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def finished
58
+ @mutex.synchronize do
59
+ @done = true
60
+ end
61
+ end
62
+ end
63
+
64
+ def request(sec, callback)
65
+ ensure_timeout_thread_created
66
+ request = Request.new(Thread.current, sec, callback)
67
+ QUEUE_MUTEX.synchronize do
68
+ QUEUE << request
69
+ CONDVAR.signal
70
+ end
71
+ request
72
+ end
73
+
74
+ private
75
+
76
+ def create_timeout_thread
77
+ watcher = Thread.new do
78
+ requests = []
79
+ while true
80
+ until QUEUE.empty? and !requests.empty? # wait to have at least one request
81
+ req = QUEUE.pop
82
+ requests << req unless req.done?
83
+ end
84
+ closest_deadline = requests.min_by(&:deadline).deadline
85
+
86
+ now = 0.0
87
+ QUEUE_MUTEX.synchronize do
88
+ while (now = Process.clock_gettime(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty?
89
+ CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now)
90
+ end
91
+ end
92
+
93
+ requests.each do |req|
94
+ req.interrupt if req.expired?(now)
95
+ end
96
+ requests.reject!(&:done?)
97
+ end
98
+ end
99
+ watcher.name = "Pitchfork::Timeout"
100
+ watcher
101
+ end
102
+
103
+ def ensure_timeout_thread_created
104
+ unless @timeout_thread and @timeout_thread.alive?
105
+ TIMEOUT_THREAD_MUTEX.synchronize do
106
+ unless @timeout_thread and @timeout_thread.alive?
107
+ @timeout_thread = create_timeout_thread
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pitchfork
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  module Const
6
6
  UNICORN_VERSION = '6.1.0'
7
7
  end
@@ -93,7 +93,7 @@ module Pitchfork
93
93
  @mold = true
94
94
  @nr = nil
95
95
  @drop_offset = 0
96
- @tick_drop = MOLD_DROP
96
+ @deadline_drop = MOLD_DROP
97
97
  self
98
98
  end
99
99
 
@@ -167,21 +167,25 @@ module Pitchfork
167
167
  super || (!@nr.nil? && @nr == other)
168
168
  end
169
169
 
170
+ def update_deadline(timeout)
171
+ self.deadline = Pitchfork.time_now(true) + timeout
172
+ end
173
+
170
174
  # called in the worker process
171
- def tick=(value) # :nodoc:
175
+ def deadline=(value) # :nodoc:
172
176
  if mold?
173
177
  MOLD_DROP[0] = value
174
178
  else
175
- @tick_drop[@drop_offset] = value
179
+ @deadline_drop[@drop_offset] = value
176
180
  end
177
181
  end
178
182
 
179
183
  # called in the master process
180
- def tick # :nodoc:
184
+ def deadline # :nodoc:
181
185
  if mold?
182
186
  MOLD_DROP[0]
183
187
  else
184
- @tick_drop[@drop_offset]
188
+ @deadline_drop[@drop_offset]
185
189
  end
186
190
  end
187
191
 
@@ -221,7 +225,7 @@ module Pitchfork
221
225
  else
222
226
  success = true
223
227
  end
224
- rescue Errno::EPIPE, Errno::ECONNRESET
228
+ rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED
225
229
  # worker will be reaped soon
226
230
  end
227
231
  success
@@ -248,8 +252,8 @@ module Pitchfork
248
252
  def build_raindrops(drop_nr)
249
253
  drop_index = drop_nr / PER_DROP
250
254
  @drop_offset = drop_nr % PER_DROP
251
- @tick_drop = TICK_DROPS[drop_index] ||= Raindrops.new(PER_DROP)
252
- @tick_drop[@drop_offset] = 0
255
+ @deadline_drop = TICK_DROPS[drop_index] ||= Raindrops.new(PER_DROP)
256
+ @deadline_drop[@drop_offset] = 0
253
257
  end
254
258
  end
255
259
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pitchfork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-19 00:00:00.000000000 Z
11
+ date: 2023-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: raindrops
@@ -108,6 +108,7 @@ files:
108
108
  - lib/pitchfork/refork_condition.rb
109
109
  - lib/pitchfork/select_waiter.rb
110
110
  - lib/pitchfork/socket_helper.rb
111
+ - lib/pitchfork/soft_timeout.rb
111
112
  - lib/pitchfork/stream_input.rb
112
113
  - lib/pitchfork/tee_input.rb
113
114
  - lib/pitchfork/tmpio.rb