polyphony 0.47.5 → 0.49.2
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 +4 -4
- data/CHANGELOG.md +25 -0
- data/Gemfile.lock +1 -1
- data/LICENSE +1 -1
- data/TODO.md +34 -17
- data/examples/io/tcp_proxy.rb +32 -0
- data/examples/performance/line_splitting.rb +34 -0
- data/examples/performance/loop.rb +32 -0
- data/examples/performance/thread-vs-fiber/polyphony_server.rb +6 -2
- data/ext/polyphony/backend_common.h +2 -2
- data/ext/polyphony/backend_io_uring.c +29 -68
- data/ext/polyphony/backend_libev.c +18 -59
- data/ext/polyphony/event.c +1 -1
- data/ext/polyphony/polyphony.c +0 -2
- data/ext/polyphony/polyphony.h +5 -4
- data/ext/polyphony/queue.c +2 -2
- data/ext/polyphony/thread.c +9 -28
- data/lib/polyphony.rb +2 -1
- data/lib/polyphony/adapters/postgres.rb +3 -3
- data/lib/polyphony/adapters/process.rb +2 -0
- data/lib/polyphony/core/global_api.rb +14 -2
- data/lib/polyphony/core/thread_pool.rb +3 -1
- data/lib/polyphony/core/throttler.rb +1 -1
- data/lib/polyphony/core/timer.rb +72 -0
- data/lib/polyphony/extensions/fiber.rb +28 -13
- data/lib/polyphony/extensions/io.rb +8 -14
- data/lib/polyphony/extensions/openssl.rb +4 -4
- data/lib/polyphony/extensions/socket.rb +5 -1
- data/lib/polyphony/extensions/thread.rb +1 -2
- data/lib/polyphony/net.rb +3 -6
- data/lib/polyphony/version.rb +1 -1
- data/polyphony.gemspec +1 -1
- data/test/helper.rb +2 -2
- data/test/test_backend.rb +26 -1
- data/test/test_fiber.rb +79 -1
- data/test/test_global_api.rb +30 -0
- data/test/test_io.rb +26 -0
- data/test/test_signal.rb +1 -2
- data/test/test_socket.rb +5 -5
- data/test/test_supervise.rb +1 -1
- data/test/test_timer.rb +124 -0
- metadata +8 -4
- data/ext/polyphony/backend.h +0 -26
@@ -40,11 +40,14 @@ inline void io_set_nonblock(rb_io_t *fptr, VALUE io) {
|
|
40
40
|
}
|
41
41
|
|
42
42
|
typedef struct Backend_t {
|
43
|
+
// common fields
|
44
|
+
unsigned int currently_polling;
|
45
|
+
unsigned int pending_count;
|
46
|
+
unsigned int poll_no_wait_count;
|
47
|
+
|
48
|
+
// implementation-specific fields
|
43
49
|
struct ev_loop *ev_loop;
|
44
50
|
struct ev_async break_async;
|
45
|
-
int running;
|
46
|
-
int ref_count;
|
47
|
-
int run_no_wait_count;
|
48
51
|
} Backend_t;
|
49
52
|
|
50
53
|
static size_t Backend_size(const void *ptr) {
|
@@ -83,9 +86,9 @@ static VALUE Backend_initialize(VALUE self) {
|
|
83
86
|
ev_async_start(backend->ev_loop, &backend->break_async);
|
84
87
|
ev_unref(backend->ev_loop); // don't count the break_async watcher
|
85
88
|
|
86
|
-
backend->
|
87
|
-
backend->
|
88
|
-
backend->
|
89
|
+
backend->currently_polling = 0;
|
90
|
+
backend->pending_count = 0;
|
91
|
+
backend->poll_no_wait_count = 0;
|
89
92
|
|
90
93
|
return Qnil;
|
91
94
|
}
|
@@ -116,42 +119,11 @@ VALUE Backend_post_fork(VALUE self) {
|
|
116
119
|
return self;
|
117
120
|
}
|
118
121
|
|
119
|
-
|
120
|
-
Backend_t *backend;
|
121
|
-
GetBackend(self, backend);
|
122
|
-
|
123
|
-
backend->ref_count++;
|
124
|
-
return self;
|
125
|
-
}
|
126
|
-
|
127
|
-
VALUE Backend_unref(VALUE self) {
|
128
|
-
Backend_t *backend;
|
129
|
-
GetBackend(self, backend);
|
130
|
-
|
131
|
-
backend->ref_count--;
|
132
|
-
return self;
|
133
|
-
}
|
134
|
-
|
135
|
-
int Backend_ref_count(VALUE self) {
|
136
|
-
Backend_t *backend;
|
137
|
-
GetBackend(self, backend);
|
138
|
-
|
139
|
-
return backend->ref_count;
|
140
|
-
}
|
141
|
-
|
142
|
-
void Backend_reset_ref_count(VALUE self) {
|
122
|
+
unsigned int Backend_pending_count(VALUE self) {
|
143
123
|
Backend_t *backend;
|
144
124
|
GetBackend(self, backend);
|
145
125
|
|
146
|
-
backend->
|
147
|
-
}
|
148
|
-
|
149
|
-
VALUE Backend_pending_count(VALUE self) {
|
150
|
-
int count;
|
151
|
-
Backend_t *backend;
|
152
|
-
GetBackend(self, backend);
|
153
|
-
count = ev_pending_count(backend->ev_loop);
|
154
|
-
return INT2NUM(count);
|
126
|
+
return backend->pending_count;
|
155
127
|
}
|
156
128
|
|
157
129
|
VALUE Backend_poll(VALUE self, VALUE nowait, VALUE current_fiber, VALUE runqueue) {
|
@@ -160,19 +132,19 @@ VALUE Backend_poll(VALUE self, VALUE nowait, VALUE current_fiber, VALUE runqueue
|
|
160
132
|
GetBackend(self, backend);
|
161
133
|
|
162
134
|
if (is_nowait) {
|
163
|
-
backend->
|
164
|
-
if (backend->
|
135
|
+
backend->poll_no_wait_count++;
|
136
|
+
if (backend->poll_no_wait_count < 10) return self;
|
165
137
|
|
166
138
|
long runnable_count = Runqueue_len(runqueue);
|
167
|
-
if (backend->
|
139
|
+
if (backend->poll_no_wait_count < runnable_count) return self;
|
168
140
|
}
|
169
141
|
|
170
|
-
backend->
|
142
|
+
backend->poll_no_wait_count = 0;
|
171
143
|
|
172
144
|
COND_TRACE(2, SYM_fiber_event_poll_enter, current_fiber);
|
173
|
-
backend->
|
145
|
+
backend->currently_polling = 1;
|
174
146
|
ev_run(backend->ev_loop, is_nowait ? EVRUN_NOWAIT : EVRUN_ONCE);
|
175
|
-
backend->
|
147
|
+
backend->currently_polling = 0;
|
176
148
|
COND_TRACE(2, SYM_fiber_event_poll_leave, current_fiber);
|
177
149
|
|
178
150
|
return self;
|
@@ -182,7 +154,7 @@ VALUE Backend_wakeup(VALUE self) {
|
|
182
154
|
Backend_t *backend;
|
183
155
|
GetBackend(self, backend);
|
184
156
|
|
185
|
-
if (backend->
|
157
|
+
if (backend->currently_polling) {
|
186
158
|
// Since the loop will run until at least one event has occurred, we signal
|
187
159
|
// the selector's associated async watcher, which will cause the ev loop to
|
188
160
|
// return. In contrast to using `ev_break` to break out of the loop, which
|
@@ -854,10 +826,6 @@ void Init_Backend() {
|
|
854
826
|
rb_define_method(cBackend, "initialize", Backend_initialize, 0);
|
855
827
|
rb_define_method(cBackend, "finalize", Backend_finalize, 0);
|
856
828
|
rb_define_method(cBackend, "post_fork", Backend_post_fork, 0);
|
857
|
-
rb_define_method(cBackend, "pending_count", Backend_pending_count, 0);
|
858
|
-
|
859
|
-
rb_define_method(cBackend, "ref", Backend_ref, 0);
|
860
|
-
rb_define_method(cBackend, "unref", Backend_unref, 0);
|
861
829
|
|
862
830
|
rb_define_method(cBackend, "poll", Backend_poll, 3);
|
863
831
|
rb_define_method(cBackend, "break", Backend_wakeup, 0);
|
@@ -882,15 +850,6 @@ void Init_Backend() {
|
|
882
850
|
|
883
851
|
ID_ivar_is_nonblocking = rb_intern("@is_nonblocking");
|
884
852
|
SYM_libev = ID2SYM(rb_intern("libev"));
|
885
|
-
|
886
|
-
__BACKEND__.pending_count = Backend_pending_count;
|
887
|
-
__BACKEND__.poll = Backend_poll;
|
888
|
-
__BACKEND__.ref = Backend_ref;
|
889
|
-
__BACKEND__.ref_count = Backend_ref_count;
|
890
|
-
__BACKEND__.reset_ref_count = Backend_reset_ref_count;
|
891
|
-
__BACKEND__.unref = Backend_unref;
|
892
|
-
__BACKEND__.wait_event = Backend_wait_event;
|
893
|
-
__BACKEND__.wakeup = Backend_wakeup;
|
894
853
|
}
|
895
854
|
|
896
855
|
#endif // POLYPHONY_BACKEND_LIBEV
|
data/ext/polyphony/event.c
CHANGED
@@ -66,7 +66,7 @@ VALUE Event_await(VALUE self) {
|
|
66
66
|
|
67
67
|
VALUE backend = rb_ivar_get(rb_thread_current(), ID_ivar_backend);
|
68
68
|
event->waiting_fiber = rb_fiber_current();
|
69
|
-
VALUE switchpoint_result =
|
69
|
+
VALUE switchpoint_result = Backend_wait_event(backend, Qnil);
|
70
70
|
event->waiting_fiber = Qnil;
|
71
71
|
|
72
72
|
RAISE_IF_EXCEPTION(switchpoint_result);
|
data/ext/polyphony/polyphony.c
CHANGED
data/ext/polyphony/polyphony.h
CHANGED
@@ -4,7 +4,6 @@
|
|
4
4
|
#include <execinfo.h>
|
5
5
|
|
6
6
|
#include "ruby.h"
|
7
|
-
#include "backend.h"
|
8
7
|
#include "runqueue_ring_buffer.h"
|
9
8
|
|
10
9
|
// debugging
|
@@ -32,9 +31,6 @@
|
|
32
31
|
// Fiber#transfer
|
33
32
|
#define FIBER_TRANSFER(fiber, value) rb_funcall(fiber, ID_transfer, 1, value)
|
34
33
|
|
35
|
-
extern backend_interface_t backend_interface;
|
36
|
-
#define __BACKEND__ (backend_interface)
|
37
|
-
|
38
34
|
extern VALUE mPolyphony;
|
39
35
|
extern VALUE cQueue;
|
40
36
|
extern VALUE cEvent;
|
@@ -92,6 +88,11 @@ void Runqueue_clear(VALUE self);
|
|
92
88
|
long Runqueue_len(VALUE self);
|
93
89
|
int Runqueue_empty_p(VALUE self);
|
94
90
|
|
91
|
+
unsigned int Backend_pending_count(VALUE self);
|
92
|
+
VALUE Backend_poll(VALUE self, VALUE nowait, VALUE current_fiber, VALUE runqueue);
|
93
|
+
VALUE Backend_wait_event(VALUE self, VALUE raise_on_exception);
|
94
|
+
VALUE Backend_wakeup(VALUE self);
|
95
|
+
|
95
96
|
VALUE Thread_schedule_fiber(VALUE thread, VALUE fiber, VALUE value);
|
96
97
|
VALUE Thread_schedule_fiber_with_priority(VALUE thread, VALUE fiber, VALUE value);
|
97
98
|
VALUE Thread_switch_fiber(VALUE thread);
|
data/ext/polyphony/queue.c
CHANGED
@@ -86,7 +86,7 @@ inline void capped_queue_block_push(Queue_t *queue) {
|
|
86
86
|
if (queue->capacity > queue->values.count) Fiber_make_runnable(fiber, Qnil);
|
87
87
|
|
88
88
|
ring_buffer_push(&queue->push_queue, fiber);
|
89
|
-
switchpoint_result =
|
89
|
+
switchpoint_result = Backend_wait_event(backend, Qnil);
|
90
90
|
ring_buffer_delete(&queue->push_queue, fiber);
|
91
91
|
|
92
92
|
RAISE_IF_EXCEPTION(switchpoint_result);
|
@@ -131,7 +131,7 @@ VALUE Queue_shift(VALUE self) {
|
|
131
131
|
if (queue->values.count) Fiber_make_runnable(fiber, Qnil);
|
132
132
|
|
133
133
|
ring_buffer_push(&queue->shift_queue, fiber);
|
134
|
-
VALUE switchpoint_result =
|
134
|
+
VALUE switchpoint_result = Backend_wait_event(backend, Qnil);
|
135
135
|
ring_buffer_delete(&queue->shift_queue, fiber);
|
136
136
|
|
137
137
|
RAISE_IF_EXCEPTION(switchpoint_result);
|
data/ext/polyphony/thread.c
CHANGED
@@ -17,16 +17,6 @@ static VALUE Thread_setup_fiber_scheduling(VALUE self) {
|
|
17
17
|
return self;
|
18
18
|
}
|
19
19
|
|
20
|
-
int Thread_fiber_ref_count(VALUE self) {
|
21
|
-
VALUE backend = rb_ivar_get(self, ID_ivar_backend);
|
22
|
-
return NUM2INT(__BACKEND__.ref_count(backend));
|
23
|
-
}
|
24
|
-
|
25
|
-
inline void Thread_fiber_reset_ref_count(VALUE self) {
|
26
|
-
VALUE backend = rb_ivar_get(self, ID_ivar_backend);
|
27
|
-
__BACKEND__.reset_ref_count(backend);
|
28
|
-
}
|
29
|
-
|
30
20
|
static VALUE SYM_scheduled_fibers;
|
31
21
|
static VALUE SYM_pending_watchers;
|
32
22
|
|
@@ -39,7 +29,7 @@ static VALUE Thread_fiber_scheduling_stats(VALUE self) {
|
|
39
29
|
long scheduled_count = Runqueue_len(runqueue);
|
40
30
|
rb_hash_aset(stats, SYM_scheduled_fibers, INT2NUM(scheduled_count));
|
41
31
|
|
42
|
-
pending_count =
|
32
|
+
pending_count = Backend_pending_count(backend);
|
43
33
|
rb_hash_aset(stats, SYM_pending_watchers, INT2NUM(pending_count));
|
44
34
|
|
45
35
|
return stats;
|
@@ -64,7 +54,7 @@ void schedule_fiber(VALUE self, VALUE fiber, VALUE value, int prioritize) {
|
|
64
54
|
// happen, not knowing that it there's already a fiber ready to run in its
|
65
55
|
// run queue.
|
66
56
|
VALUE backend = rb_ivar_get(self,ID_ivar_backend);
|
67
|
-
|
57
|
+
Backend_wakeup(backend);
|
68
58
|
}
|
69
59
|
}
|
70
60
|
}
|
@@ -84,25 +74,24 @@ VALUE Thread_switch_fiber(VALUE self) {
|
|
84
74
|
VALUE runqueue = rb_ivar_get(self, ID_ivar_runqueue);
|
85
75
|
runqueue_entry next;
|
86
76
|
VALUE backend = rb_ivar_get(self, ID_ivar_backend);
|
87
|
-
int
|
88
|
-
int backend_was_polled = 0;
|
77
|
+
unsigned int pending_count = Backend_pending_count(backend);
|
78
|
+
unsigned int backend_was_polled = 0;
|
89
79
|
|
90
80
|
if (__tracing_enabled__ && (rb_ivar_get(current_fiber, ID_ivar_running) != Qfalse))
|
91
81
|
TRACE(2, SYM_fiber_switchpoint, current_fiber);
|
92
82
|
|
93
|
-
ref_count = __BACKEND__.ref_count(backend);
|
94
83
|
while (1) {
|
95
84
|
next = Runqueue_shift(runqueue);
|
96
85
|
if (next.fiber != Qnil) {
|
97
|
-
if (backend_was_polled
|
86
|
+
if (!backend_was_polled && pending_count) {
|
98
87
|
// this prevents event starvation in case the run queue never empties
|
99
|
-
|
88
|
+
Backend_poll(backend, Qtrue, current_fiber, runqueue);
|
100
89
|
}
|
101
90
|
break;
|
102
91
|
}
|
103
|
-
if (
|
92
|
+
if (pending_count == 0) break;
|
104
93
|
|
105
|
-
|
94
|
+
Backend_poll(backend, Qnil, current_fiber, runqueue);
|
106
95
|
backend_was_polled = 1;
|
107
96
|
}
|
108
97
|
|
@@ -118,20 +107,13 @@ VALUE Thread_switch_fiber(VALUE self) {
|
|
118
107
|
next.value : FIBER_TRANSFER(next.fiber, next.value);
|
119
108
|
}
|
120
109
|
|
121
|
-
VALUE Thread_reset_fiber_scheduling(VALUE self) {
|
122
|
-
VALUE queue = rb_ivar_get(self, ID_ivar_runqueue);
|
123
|
-
Runqueue_clear(queue);
|
124
|
-
Thread_fiber_reset_ref_count(self);
|
125
|
-
return self;
|
126
|
-
}
|
127
|
-
|
128
110
|
VALUE Thread_fiber_schedule_and_wakeup(VALUE self, VALUE fiber, VALUE resume_obj) {
|
129
111
|
VALUE backend = rb_ivar_get(self, ID_ivar_backend);
|
130
112
|
if (fiber != Qnil) {
|
131
113
|
Thread_schedule_fiber_with_priority(self, fiber, resume_obj);
|
132
114
|
}
|
133
115
|
|
134
|
-
if (
|
116
|
+
if (Backend_wakeup(backend) == Qnil) {
|
135
117
|
// we're not inside the ev_loop, so we just do a switchpoint
|
136
118
|
Thread_switch_fiber(self);
|
137
119
|
}
|
@@ -146,7 +128,6 @@ VALUE Thread_debug(VALUE self) {
|
|
146
128
|
|
147
129
|
void Init_Thread() {
|
148
130
|
rb_define_method(rb_cThread, "setup_fiber_scheduling", Thread_setup_fiber_scheduling, 0);
|
149
|
-
rb_define_method(rb_cThread, "reset_fiber_scheduling", Thread_reset_fiber_scheduling, 0);
|
150
131
|
rb_define_method(rb_cThread, "fiber_scheduling_stats", Thread_fiber_scheduling_stats, 0);
|
151
132
|
rb_define_method(rb_cThread, "schedule_and_wakeup", Thread_fiber_schedule_and_wakeup, 2);
|
152
133
|
|
data/lib/polyphony.rb
CHANGED
@@ -14,6 +14,7 @@ Thread.current.backend = Polyphony::Backend.new
|
|
14
14
|
require_relative './polyphony/core/global_api'
|
15
15
|
require_relative './polyphony/core/resource_pool'
|
16
16
|
require_relative './polyphony/core/sync'
|
17
|
+
require_relative './polyphony/core/timer'
|
17
18
|
require_relative './polyphony/net'
|
18
19
|
require_relative './polyphony/adapters/process'
|
19
20
|
|
@@ -43,7 +44,7 @@ module Polyphony
|
|
43
44
|
rescue SystemExit
|
44
45
|
# fall through to ensure
|
45
46
|
rescue Exception => e
|
46
|
-
|
47
|
+
STDERR << e.full_message
|
47
48
|
exit!
|
48
49
|
ensure
|
49
50
|
exit_forked_process
|
@@ -11,7 +11,7 @@ module ::PG
|
|
11
11
|
|
12
12
|
def self.connect_async(conn)
|
13
13
|
socket_io = conn.socket_io
|
14
|
-
|
14
|
+
while true
|
15
15
|
res = conn.connect_poll
|
16
16
|
case res
|
17
17
|
when PGRES_POLLING_FAILED then raise Error, conn.error_message
|
@@ -23,7 +23,7 @@ module ::PG
|
|
23
23
|
end
|
24
24
|
|
25
25
|
def self.connect_sync(conn)
|
26
|
-
|
26
|
+
while true
|
27
27
|
res = conn.connect_poll
|
28
28
|
case res
|
29
29
|
when PGRES_POLLING_FAILED
|
@@ -96,7 +96,7 @@ class ::PG::Connection
|
|
96
96
|
def wait_for_notify(timeout = nil, &block)
|
97
97
|
return move_on_after(timeout) { wait_for_notify(&block) } if timeout
|
98
98
|
|
99
|
-
|
99
|
+
while true
|
100
100
|
Thread.current.backend.wait_io(socket_io, false)
|
101
101
|
consume_input
|
102
102
|
notice = notifies
|
@@ -59,7 +59,15 @@ module Polyphony
|
|
59
59
|
throttled_loop(rate: rate, interval: interval, &block)
|
60
60
|
end
|
61
61
|
else
|
62
|
-
|
62
|
+
spin_looped_block(tag, caller, block)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def spin_looped_block(tag, caller, block)
|
67
|
+
Fiber.current.spin(tag, caller) do
|
68
|
+
block.call while true
|
69
|
+
rescue LocalJumpError, StopIteration
|
70
|
+
# break called or StopIteration raised
|
63
71
|
end
|
64
72
|
end
|
65
73
|
|
@@ -133,8 +141,12 @@ module Polyphony
|
|
133
141
|
if opts[:count]
|
134
142
|
opts[:count].times { |_i| throttler.(&block) }
|
135
143
|
else
|
136
|
-
|
144
|
+
while true
|
145
|
+
throttler.(&block)
|
146
|
+
end
|
137
147
|
end
|
148
|
+
rescue LocalJumpError, StopIteration
|
149
|
+
# break called or StopIteration raised
|
138
150
|
ensure
|
139
151
|
throttler&.stop
|
140
152
|
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Polyphony
|
4
|
+
# Implements a common timer for running multiple timeouts
|
5
|
+
class Timer
|
6
|
+
def initialize(resolution:)
|
7
|
+
@fiber = spin_loop(interval: resolution) { update }
|
8
|
+
@timeouts = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def stop
|
12
|
+
@fiber.stop
|
13
|
+
end
|
14
|
+
|
15
|
+
def cancel_after(duration, with_exception: Polyphony::Cancel)
|
16
|
+
fiber = Fiber.current
|
17
|
+
@timeouts[fiber] = {
|
18
|
+
duration: duration,
|
19
|
+
target_stamp: Time.now + duration,
|
20
|
+
exception: with_exception
|
21
|
+
}
|
22
|
+
yield
|
23
|
+
ensure
|
24
|
+
@timeouts.delete(fiber)
|
25
|
+
end
|
26
|
+
|
27
|
+
def move_on_after(duration, with_value: nil)
|
28
|
+
fiber = Fiber.current
|
29
|
+
@timeouts[fiber] = {
|
30
|
+
duration: duration,
|
31
|
+
target_stamp: Time.now + duration,
|
32
|
+
value: with_value
|
33
|
+
}
|
34
|
+
yield
|
35
|
+
rescue Polyphony::MoveOn => e
|
36
|
+
e.value
|
37
|
+
ensure
|
38
|
+
@timeouts.delete(fiber)
|
39
|
+
end
|
40
|
+
|
41
|
+
def reset
|
42
|
+
record = @timeouts[Fiber.current]
|
43
|
+
return unless record
|
44
|
+
|
45
|
+
record[:target_stamp] = Time.now + record[:duration]
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def timeout_exception(record)
|
51
|
+
case (exception = record[:exception])
|
52
|
+
when Class then exception.new
|
53
|
+
when Array then exception[0].new(exception[1])
|
54
|
+
when nil then Polyphony::MoveOn.new(record[:value])
|
55
|
+
else RuntimeError.new(exception)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def update
|
60
|
+
now = Time.now
|
61
|
+
# elapsed = nil
|
62
|
+
@timeouts.each do |fiber, record|
|
63
|
+
next if record[:target_stamp] > now
|
64
|
+
|
65
|
+
exception = timeout_exception(record)
|
66
|
+
# (elapsed ||= []) << fiber
|
67
|
+
fiber.schedule exception
|
68
|
+
end
|
69
|
+
# elapsed&.each { |f| @timeouts.delete(f) }
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|