winloop 0.2.0 → 0.2.1
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/README.md +11 -278
- data/lib/winloop.rb +4 -81
- metadata +11 -67
- data/CHANGELOG.md +0 -88
- data/ext/winloop/extconf.rb +0 -19
- data/ext/winloop/winloop.c +0 -712
- data/lib/winloop/ops.rb +0 -36
- data/lib/winloop/scheduler.rb +0 -656
- data/lib/winloop/version.rb +0 -5
data/lib/winloop/ops.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Winloop
|
|
4
|
-
# Upper bound for Backend#op_prepare's `capacity:` — 16 MiB. Fits a DWORD with
|
|
5
|
-
# huge margin (and is far past ReadDirectoryChangesW's 64 KiB network ceiling).
|
|
6
|
-
OP_CAPACITY_MAX = 16 * 1024 * 1024
|
|
7
|
-
|
|
8
|
-
# The validated, keyword-argument face of the generic-op preparation primitive.
|
|
9
|
-
# Suite convention: validated kwargs live in Ruby; the C bridge (_op_prepare)
|
|
10
|
-
# is underscore-private so the checks can't be skipped.
|
|
11
|
-
class Backend
|
|
12
|
-
# Allocate one backend-owned op record (state :prepared): a zeroed OVERLAPPED,
|
|
13
|
-
# a `tag` echoed back in the completion tuple, and an optional embedded data
|
|
14
|
-
# buffer. Returns [op_id, ov_addr, buf_addr] (all Integers; buf_addr is 0 when
|
|
15
|
-
# capacity is 0). `handle` must have been passed to #associate on this backend
|
|
16
|
-
# first — an op on a handle that is not on the port never completes (a silent
|
|
17
|
-
# hang, not an error), so unknown handles raise Winloop::Error up front.
|
|
18
|
-
def op_prepare(handle, tag: 0, capacity: 0)
|
|
19
|
-
raise TypeError, "handle must be an Integer (got #{handle.class})" unless handle.is_a?(Integer)
|
|
20
|
-
raise TypeError, "tag must be an Integer (got #{tag.class})" unless tag.is_a?(Integer)
|
|
21
|
-
raise TypeError, "capacity must be an Integer (got #{capacity.class})" unless capacity.is_a?(Integer)
|
|
22
|
-
if handle.zero? || handle == 0xFFFF_FFFF_FFFF_FFFF || !handle.between?(1, 2**64 - 1)
|
|
23
|
-
raise ArgumentError, "#{handle} is not a usable HANDLE value"
|
|
24
|
-
end
|
|
25
|
-
unless tag.between?(0, 2**64 - 1)
|
|
26
|
-
raise ArgumentError, "tag must be in 0..2**64-1 (got #{tag})"
|
|
27
|
-
end
|
|
28
|
-
unless capacity.between?(0, OP_CAPACITY_MAX)
|
|
29
|
-
raise ArgumentError, "capacity must be in 0..#{OP_CAPACITY_MAX} (got #{capacity})"
|
|
30
|
-
end
|
|
31
|
-
_op_prepare(handle, tag, capacity)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private :_op_prepare
|
|
35
|
-
end
|
|
36
|
-
end
|
data/lib/winloop/scheduler.rb
DELETED
|
@@ -1,656 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
|
|
5
|
-
module Winloop
|
|
6
|
-
# A Fiber::Scheduler for MRI on Windows, backed by an I/O Completion Port.
|
|
7
|
-
#
|
|
8
|
-
# Strategy (matches libuv/mio/wepoll on Windows):
|
|
9
|
-
# * io_wait -> IOCTL_AFD_POLL submitted as an overlapped op on the IOCP
|
|
10
|
-
# (the only way to get *readiness* onto a completion port).
|
|
11
|
-
# Polls are COALESCED per socket: at most one AFD poll is
|
|
12
|
-
# outstanding per socket, covering the union of every
|
|
13
|
-
# waiter's interest, and its completion fans out to all of
|
|
14
|
-
# them. (The AFD driver completes only ONE pending poll per
|
|
15
|
-
# readiness edge, so independent polls on the same socket
|
|
16
|
-
# would lose wakeups.)
|
|
17
|
-
# * io_read/io_write -> recv_nonblock/write_nonblock driven by io_wait
|
|
18
|
-
# * kernel_sleep /
|
|
19
|
-
# timeout_after -> a monotonic timer min-heap (the loop's GQCSEx timeout)
|
|
20
|
-
# * block / unblock -> in-process waiter lists; cross-thread unblock wakes the
|
|
21
|
-
# loop with PostQueuedCompletionStatus
|
|
22
|
-
# * close -> drives the run loop until every fiber has finished
|
|
23
|
-
#
|
|
24
|
-
# Single thread-of-use: only the owning thread runs the loop and resumes fibers.
|
|
25
|
-
# #unblock may be called from another thread and only mutates the (locked) ready
|
|
26
|
-
# queue + posts a wakeup; it never resumes a fiber itself.
|
|
27
|
-
class Scheduler
|
|
28
|
-
# One blocking wait. `settled` is the one-shot guard: whoever wakes the fiber
|
|
29
|
-
# first (I/O completion, timeout, or unblock) sets it; everyone else skips.
|
|
30
|
-
Waiter = Struct.new(:fiber, :settled)
|
|
31
|
-
|
|
32
|
-
# One fiber waiting on a socket for `events` (an io_wait waiter).
|
|
33
|
-
IoWaiter = Struct.new(:fiber, :events, :settled)
|
|
34
|
-
|
|
35
|
-
# The single outstanding AFD poll for one socket, shared by its io waiters.
|
|
36
|
-
PollEntry = Struct.new(:key, :io, :id, :armed, :waiters)
|
|
37
|
-
|
|
38
|
-
# A timer heap node. `value` is what the fiber is resumed with when it fires;
|
|
39
|
-
# `raise_exc` (for Timeout.timeout) makes it raise into the fiber instead.
|
|
40
|
-
Timer = Struct.new(:deadline, :waiter, :value, :raise_exc)
|
|
41
|
-
|
|
42
|
-
def initialize
|
|
43
|
-
@backend = Backend.new
|
|
44
|
-
@ready = [] # [[fiber, resume_value], ...] (shared: unblock pushes here)
|
|
45
|
-
@timers = MinHeap.new # of Timer, keyed on :deadline
|
|
46
|
-
@polls = {} # socket key (fileno) => PollEntry
|
|
47
|
-
@ops = {} # backend poll id => PollEntry
|
|
48
|
-
@parked = {} # fiber => Waiter (its CURRENT block/kernel_sleep park)
|
|
49
|
-
@lock = Thread::Mutex.new # guards @ready + @parked (the cross-thread path)
|
|
50
|
-
@loop = Fiber.current # the thread's root fiber == the loop
|
|
51
|
-
@closed = false
|
|
52
|
-
|
|
53
|
-
# ---- generic OVERLAPPED op state (winloop 0.2) ----
|
|
54
|
-
@thread = Thread.current # the op API is loop-thread-only (enforced)
|
|
55
|
-
@op_waiters = {} # op_id => Waiter (a fiber parked in await_op)
|
|
56
|
-
@op_orphans = {} # op_id => true (timed-out/unwound awaits; the late
|
|
57
|
-
# packet is retired without waking anyone)
|
|
58
|
-
@op_done = {} # op_id => [bytes, error] (completed before anyone awaited)
|
|
59
|
-
@op_shut = false # true once the loop has fully finished (backend shut down).
|
|
60
|
-
# NOT @closed: under Winloop.run, #close IS the event loop,
|
|
61
|
-
# so @closed is true while op-protocol fibers still run.
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def monotonic
|
|
65
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# ---- I/O readiness (accept / connect / IO#wait) -----------------------
|
|
69
|
-
|
|
70
|
-
# Wait until `io` is ready for `events`; returns the ready-events bitmask
|
|
71
|
-
# (0 on timeout). events is an OR of Winloop::READABLE/WRITABLE/PRIORITY.
|
|
72
|
-
def io_wait(io, events, timeout)
|
|
73
|
-
key = io.fileno
|
|
74
|
-
waiter = IoWaiter.new(Fiber.current, events, false)
|
|
75
|
-
entry = (@polls[key] ||= PollEntry.new(key, io, nil, 0, []))
|
|
76
|
-
entry.waiters << waiter
|
|
77
|
-
arm_poll(entry)
|
|
78
|
-
@timers.push(Timer.new(monotonic + timeout, waiter, 0, nil)) if timeout
|
|
79
|
-
Fiber.yield # resumed with the ready-events mask (or 0 on timeout)
|
|
80
|
-
ensure
|
|
81
|
-
# On the normal completion path the loop already removed us from the entry
|
|
82
|
-
# and re-armed/dropped it. If we are unwinding by any other path (timeout,
|
|
83
|
-
# or Timeout.timeout raised into us), detach and re-arm/cancel for the rest.
|
|
84
|
-
waiter.settled = true
|
|
85
|
-
if entry.waiters.delete(waiter)
|
|
86
|
-
if entry.waiters.empty?
|
|
87
|
-
cancel_entry(entry)
|
|
88
|
-
else
|
|
89
|
-
arm_poll(entry) # interest shrank; existing poll still covers it (no-op)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# ---- stream read/write (recv_nonblock + io_wait) ----------------------
|
|
95
|
-
#
|
|
96
|
-
# `length` is the MINIMUM bytes to transfer; 0 means "the whole region
|
|
97
|
-
# [offset, buffer.size)". MUST use recv_nonblock — read_nonblock / sysread /
|
|
98
|
-
# readpartial re-enter this hook on mswin and recurse forever.
|
|
99
|
-
|
|
100
|
-
def io_read(io, buffer, length, offset = 0)
|
|
101
|
-
return blocking_read(io, buffer, length, offset) unless io.respond_to?(:recv_nonblock)
|
|
102
|
-
total = 0
|
|
103
|
-
minimum = length.zero? ? 1 : length
|
|
104
|
-
while total < minimum
|
|
105
|
-
maximum = buffer.size - offset - total
|
|
106
|
-
break if maximum <= 0
|
|
107
|
-
result = io.recv_nonblock(maximum, exception: false)
|
|
108
|
-
case result
|
|
109
|
-
when :wait_readable then io_wait(io, READABLE, nil)
|
|
110
|
-
when nil then break # EOF
|
|
111
|
-
else
|
|
112
|
-
buffer.set_string(result, offset + total)
|
|
113
|
-
total += result.bytesize
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
total
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def io_write(io, buffer, length, offset = 0)
|
|
120
|
-
total = 0
|
|
121
|
-
maximum = buffer.size - offset
|
|
122
|
-
return 0 if maximum <= 0
|
|
123
|
-
minimum = length.zero? ? maximum : length
|
|
124
|
-
minimum = maximum if minimum > maximum # never spin writing empty chunks
|
|
125
|
-
while total < minimum
|
|
126
|
-
chunk = buffer.get_string(offset + total, maximum - total)
|
|
127
|
-
break if chunk.empty?
|
|
128
|
-
result = io.write_nonblock(chunk, exception: false)
|
|
129
|
-
if result == :wait_writable
|
|
130
|
-
io_wait(io, WRITABLE, nil)
|
|
131
|
-
else
|
|
132
|
-
total += result
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
total
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# ---- timers -----------------------------------------------------------
|
|
139
|
-
|
|
140
|
-
def kernel_sleep(duration = nil)
|
|
141
|
-
fiber = Fiber.current
|
|
142
|
-
waiter = Waiter.new(fiber, false)
|
|
143
|
-
# Park under @parked so a cross-thread #unblock (e.g. ConditionVariable#wait
|
|
144
|
-
# with a timeout parks HERE, and a racing signal must wake us through the
|
|
145
|
-
# SAME one-shot guard as the timer — never a second, competing wakeup).
|
|
146
|
-
@lock.synchronize { @parked[fiber] = waiter }
|
|
147
|
-
@timers.push(Timer.new(monotonic + duration, waiter, nil, nil)) if duration
|
|
148
|
-
Fiber.yield
|
|
149
|
-
duration
|
|
150
|
-
ensure
|
|
151
|
-
@lock.synchronize do
|
|
152
|
-
waiter.settled = true # disarm a not-yet-fired timer on abnormal exit
|
|
153
|
-
@parked.delete(fiber)
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def timeout_after(duration, exception_class, *exception_arguments)
|
|
158
|
-
waiter = Waiter.new(Fiber.current, false)
|
|
159
|
-
@timers.push(Timer.new(monotonic + duration, waiter, nil,
|
|
160
|
-
[exception_class, exception_arguments]))
|
|
161
|
-
begin
|
|
162
|
-
yield duration
|
|
163
|
-
ensure
|
|
164
|
-
# disarm if the block finished before the deadline
|
|
165
|
-
@lock.synchronize { waiter.settled = true }
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# ---- block / unblock (Mutex, ConditionVariable, Queue, Thread#join) ----
|
|
170
|
-
|
|
171
|
-
def block(blocker, timeout = nil)
|
|
172
|
-
fiber = Fiber.current
|
|
173
|
-
waiter = Waiter.new(fiber, false)
|
|
174
|
-
@lock.synchronize { @parked[fiber] = waiter }
|
|
175
|
-
@timers.push(Timer.new(monotonic + timeout, waiter, false, nil)) if timeout
|
|
176
|
-
Fiber.yield # true (unblocked) or false (timed out)
|
|
177
|
-
ensure
|
|
178
|
-
@lock.synchronize do
|
|
179
|
-
waiter.settled = true
|
|
180
|
-
@parked.delete(fiber)
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# Called from ANY thread: only mutate shared state under the lock and wake
|
|
185
|
-
# the loop — never resume the fiber here.
|
|
186
|
-
#
|
|
187
|
-
# We wake by FIBER identity through its single parked Waiter, ignoring
|
|
188
|
-
# `blocker`. That is deliberate and important:
|
|
189
|
-
# * Thread#join calls block(thread) but MRI calls unblock(<a DIFFERENT
|
|
190
|
-
# object>, fiber); keying on the fiber wakes it correctly anyway.
|
|
191
|
-
# * A fiber can have more than one live wakeup source (a timeout timer AND
|
|
192
|
-
# this unblock — e.g. ConditionVariable#wait(mutex, timeout) racing a
|
|
193
|
-
# signal). Routing through the one Waiter's `settled` guard means exactly
|
|
194
|
-
# one of them resumes the fiber; the loser is dropped. A raw, unguarded
|
|
195
|
-
# "@ready << fiber" would double-resume and cut the fiber's NEXT wait short.
|
|
196
|
-
def unblock(_blocker, fiber)
|
|
197
|
-
@lock.synchronize do
|
|
198
|
-
waiter = @parked[fiber]
|
|
199
|
-
if waiter
|
|
200
|
-
wake_locked(waiter, true)
|
|
201
|
-
elsif @ready.none? { |f, _| f.equal?(fiber) }
|
|
202
|
-
# Not parked in any wait we track (spurious/late unblock) — last resort,
|
|
203
|
-
# de-duplicated so it can never double-enqueue.
|
|
204
|
-
@ready << [fiber, true]
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
@backend.wakeup
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# ---- fiber creation ----------------------------------------------------
|
|
211
|
-
|
|
212
|
-
def fiber(&block)
|
|
213
|
-
fiber = Fiber.new(blocking: false, &block)
|
|
214
|
-
fiber.resume
|
|
215
|
-
fiber
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# ---- optional hooks (offloaded to a thread; the originals re-enter) -----
|
|
219
|
-
|
|
220
|
-
# Run a blocking operation on a fresh, scheduler-FREE thread and park the
|
|
221
|
-
# calling fiber on it cooperatively (Thread#value -> our block hook). The
|
|
222
|
-
# worker is killed if this fiber unwinds first (Timeout.timeout / Fiber kill)
|
|
223
|
-
# so it can never outlive us, leak, or race a later operation. We do NOT
|
|
224
|
-
# join after kill — that would re-enter the scheduler mid-unwind; Thread#kill
|
|
225
|
-
# interrupts the blocking syscall and the worker terminates on its own.
|
|
226
|
-
def await_on_thread(&block)
|
|
227
|
-
worker = Thread.new(&block)
|
|
228
|
-
worker.report_on_exception = false # expected errors are surfaced via #value
|
|
229
|
-
begin
|
|
230
|
-
worker.value
|
|
231
|
-
ensure
|
|
232
|
-
if worker.alive?
|
|
233
|
-
# Unwound first (Timeout/kill): interrupt the worker out of its blocking
|
|
234
|
-
# syscall and WAIT for it to die before returning, so it can't read (and
|
|
235
|
-
# discard) bytes that a later operation on the same IO expects. kill
|
|
236
|
-
# interrupts readpartial immediately, so this join does not block.
|
|
237
|
-
worker.kill
|
|
238
|
-
worker.join
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def address_resolve(hostname)
|
|
244
|
-
await_on_thread do
|
|
245
|
-
Addrinfo.getaddrinfo(hostname, nil, nil, :STREAM).map(&:ip_address).uniq
|
|
246
|
-
end
|
|
247
|
-
rescue SocketError
|
|
248
|
-
[]
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def process_wait(pid, flags)
|
|
252
|
-
await_on_thread { Process::Status.wait(pid, flags) }
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# ---- generic OVERLAPPED completions (the cross-gem op protocol) ---------
|
|
256
|
-
#
|
|
257
|
-
# Client gems feature-detect with `Fiber.scheduler.respond_to?(:await_op)`
|
|
258
|
-
# and talk ONLY to these wrappers (never the Backend — they cannot reach
|
|
259
|
-
# #wait/#shutdown and corrupt the loop). The contract loop:
|
|
260
|
-
#
|
|
261
|
-
# op_associate(h) # once, at handle-open time
|
|
262
|
-
# op_id, ov, buf = op_prepare(h, capacity: n)
|
|
263
|
-
# ok = <the client's own native submit using ov/buf>
|
|
264
|
-
# if ok # native call returned TRUE or GetLastError == ERROR_IO_PENDING
|
|
265
|
-
# op_submitted(op_id)
|
|
266
|
-
# bytes, error, data = await_op(op_id, timeout: t) # fiber parks here
|
|
267
|
-
# else # synchronous failure: no packet will ever arrive
|
|
268
|
-
# op_abandon(op_id)
|
|
269
|
-
# end
|
|
270
|
-
#
|
|
271
|
-
# All of these are loop-thread-only and raise once the scheduler has shut down.
|
|
272
|
-
|
|
273
|
-
def op_associate(handle)
|
|
274
|
-
op_guard!
|
|
275
|
-
@backend.associate(handle)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
def op_prepare(handle, tag: 0, capacity: 0)
|
|
279
|
-
op_guard!
|
|
280
|
-
@backend.op_prepare(handle, tag: tag, capacity: capacity)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def op_submitted(op_id)
|
|
284
|
-
op_guard!
|
|
285
|
-
@backend.op_submitted(op_id)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def op_abandon(op_id)
|
|
289
|
-
op_guard!
|
|
290
|
-
@backend.op_abandon(op_id)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def op_cancel(op_id)
|
|
294
|
-
op_guard!
|
|
295
|
-
@backend.op_cancel(op_id)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def op_state(op_id)
|
|
299
|
-
op_guard!
|
|
300
|
-
@backend.op_state(op_id)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Park the calling fiber until op_id's completion packet is reaped by the
|
|
304
|
-
# loop, then return [bytes, error, data] (op_result is pulled HERE, in the
|
|
305
|
-
# woken fiber, so orphaned completions never allocate Strings). On timeout:
|
|
306
|
-
# returns nil, auto-cancels and orphans the op. Either way the op is retired:
|
|
307
|
-
# after await_op returns — value or nil — the op_id must not be used again.
|
|
308
|
-
#
|
|
309
|
-
# timeout: nil (wait forever — safe provided the op was submitted on a handle
|
|
310
|
-
# that is still the associated handle) or a positive Numeric in seconds.
|
|
311
|
-
# Recommendation: pass a real timeout while first integrating a new native
|
|
312
|
-
# API; switch to nil once the submit path is proven.
|
|
313
|
-
def await_op(op_id, timeout: nil)
|
|
314
|
-
op_guard!
|
|
315
|
-
validate_op_timeout!(timeout)
|
|
316
|
-
if @op_done.delete(op_id) # early completion: no yield
|
|
317
|
-
return @backend.op_result(op_id)
|
|
318
|
-
end
|
|
319
|
-
raise Error, "winloop: op #{op_id} already has a waiter" if @op_waiters.key?(op_id)
|
|
320
|
-
raise Error, "winloop: op #{op_id} has not been submitted" if @backend.op_state(op_id) == :prepared
|
|
321
|
-
waiter = Waiter.new(Fiber.current, false)
|
|
322
|
-
@op_waiters[op_id] = waiter
|
|
323
|
-
@timers.push(Timer.new(monotonic + timeout, waiter, nil, nil)) if timeout
|
|
324
|
-
completed = Fiber.yield # [bytes, error] or nil (timer fired)
|
|
325
|
-
completed ? @backend.op_result(op_id) : nil
|
|
326
|
-
ensure
|
|
327
|
-
# NOTHING in this ensure may raise — it runs during Timeout::Error /
|
|
328
|
-
# Fiber#raise / Fiber#kill unwinds and must never mask the in-flight
|
|
329
|
-
# exception (op_cancel's no-raise-on-CancelIoEx contract exists for this).
|
|
330
|
-
if waiter
|
|
331
|
-
waiter.settled = true
|
|
332
|
-
if @op_waiters.delete(op_id) # loop did NOT deliver: timeout or unwind
|
|
333
|
-
@op_orphans[op_id] = true
|
|
334
|
-
begin
|
|
335
|
-
@backend.op_cancel(op_id) # late packet -> orphan path retires it
|
|
336
|
-
rescue Error
|
|
337
|
-
# backend already shut down mid-unwind: nothing left to cancel
|
|
338
|
-
end
|
|
339
|
-
elsif !completed # loop DID deliver, but we were unwound
|
|
340
|
-
# (Fiber#kill, or a sibling raise) before resuming with the value: the
|
|
341
|
-
# record sits OP_COMPLETED with nobody left to op_result it — free it
|
|
342
|
-
# now. Guarded: the loop's settled/timer branch may have retired the id.
|
|
343
|
-
@backend.op_free(op_id) if op_completed_unconsumed?(op_id)
|
|
344
|
-
end
|
|
345
|
-
end
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
# ---- lifecycle ---------------------------------------------------------
|
|
349
|
-
|
|
350
|
-
# MRI calls this automatically at thread/program exit. It IS the event loop.
|
|
351
|
-
def close
|
|
352
|
-
return if @closed
|
|
353
|
-
@closed = true
|
|
354
|
-
run
|
|
355
|
-
ensure
|
|
356
|
-
@op_shut = true # the op protocol is over; op_* / await_op raise from here on
|
|
357
|
-
@backend.shutdown
|
|
358
|
-
# NEVER call Fiber.set_scheduler(nil) here — it raises SystemStackError
|
|
359
|
-
# during MRI's implicit close at thread exit.
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def closed? = @closed
|
|
363
|
-
|
|
364
|
-
# ---- the run loop ------------------------------------------------------
|
|
365
|
-
|
|
366
|
-
def run
|
|
367
|
-
run_once until done?
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
private
|
|
371
|
-
|
|
372
|
-
def done?
|
|
373
|
-
# Locked so the "are we idle?" decision is atomic against a cross-thread
|
|
374
|
-
# #unblock that is moving a waiter into @ready right now.
|
|
375
|
-
@lock.synchronize do
|
|
376
|
-
drop_settled_timers
|
|
377
|
-
@ready.empty? && @timers.empty? && @ops.empty? && @parked.empty? &&
|
|
378
|
-
@op_waiters.empty?
|
|
379
|
-
# @op_orphans/@op_done deliberately do NOT keep the loop alive —
|
|
380
|
-
# shutdown's cancel+drain+sweep retires whatever is left.
|
|
381
|
-
end
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
def run_once
|
|
385
|
-
# (a) resume everything currently ready (outside the lock — resume runs
|
|
386
|
-
# arbitrary Ruby which may re-enter the scheduler). An unhandled error
|
|
387
|
-
# in a scheduled child fiber is reported, not fatal to the loop
|
|
388
|
-
# (Thread-like); the main block's own errors are handled by Winloop.run.
|
|
389
|
-
ready = @lock.synchronize { r = @ready; @ready = []; r }
|
|
390
|
-
ready.each do |fiber, value|
|
|
391
|
-
next unless fiber.alive?
|
|
392
|
-
begin
|
|
393
|
-
fiber.resume(value)
|
|
394
|
-
rescue StandardError => e
|
|
395
|
-
report_fiber_error(e)
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# (b) fire expired timers (skipping any already settled by another path)
|
|
400
|
-
drop_settled_timers
|
|
401
|
-
now = monotonic
|
|
402
|
-
while (t = @timers.peek) && t.deadline <= now
|
|
403
|
-
@timers.pop
|
|
404
|
-
next if t.waiter.settled
|
|
405
|
-
if t.raise_exc
|
|
406
|
-
t.waiter.settled = true
|
|
407
|
-
klass, args = t.raise_exc
|
|
408
|
-
f = t.waiter.fiber
|
|
409
|
-
begin
|
|
410
|
-
f.raise(klass, *args) if f.alive?
|
|
411
|
-
rescue StandardError => e
|
|
412
|
-
report_fiber_error(e)
|
|
413
|
-
end
|
|
414
|
-
else
|
|
415
|
-
@lock.synchronize { wake_locked(t.waiter, t.value) }
|
|
416
|
-
end
|
|
417
|
-
drop_settled_timers
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
# (c) compute the single-wait timeout. The idle decision is taken under
|
|
421
|
-
# the lock so a concurrent #unblock can't slip a ready fiber past us.
|
|
422
|
-
timeout_ms = nil
|
|
423
|
-
idle = false
|
|
424
|
-
@lock.synchronize do
|
|
425
|
-
drop_settled_timers
|
|
426
|
-
if !@ready.empty?
|
|
427
|
-
timeout_ms = 0
|
|
428
|
-
elsif (t = @timers.peek)
|
|
429
|
-
ms = ((t.deadline - monotonic) * 1000).ceil
|
|
430
|
-
timeout_ms = ms < 0 ? 0 : ms
|
|
431
|
-
elsif @ops.empty? && @parked.empty? && @op_waiters.empty?
|
|
432
|
-
idle = true # nothing to wait for
|
|
433
|
-
else
|
|
434
|
-
timeout_ms = nil # INFINITE — pending I/O, parked fibers or awaited ops
|
|
435
|
-
# wake us. An awaited op pending => INFINITE is correct for contract-
|
|
436
|
-
# abiding ops: its completion (or its cancel's completion) wakes the
|
|
437
|
-
# port — guaranteed because op_prepare verified the handle is on it.
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
return if idle
|
|
441
|
-
|
|
442
|
-
# (d) one GetQueuedCompletionStatusEx; dispatch per entry on tuple size:
|
|
443
|
-
# AFD readiness completions are 2-tuples [id, ready_events]; generic
|
|
444
|
-
# op completions (winloop 0.2) are 4-tuples [op_id, bytes, error, tag].
|
|
445
|
-
@backend.wait(timeout_ms).each do |entry|
|
|
446
|
-
if entry.size == 4
|
|
447
|
-
op_id, bytes, error, _tag = entry
|
|
448
|
-
if (w = @op_waiters.delete(op_id))
|
|
449
|
-
if w.settled # its timer fired this same turn: orphan
|
|
450
|
-
@backend.op_free(op_id)
|
|
451
|
-
else
|
|
452
|
-
w.settled = true
|
|
453
|
-
@lock.synchronize { @ready << [w.fiber, [bytes, error]] }
|
|
454
|
-
end
|
|
455
|
-
elsif @op_orphans.delete(op_id) # await timed out / unwound earlier
|
|
456
|
-
@backend.op_free(op_id)
|
|
457
|
-
else # completed before anyone awaited
|
|
458
|
-
@op_done[op_id] = [bytes, error]
|
|
459
|
-
end
|
|
460
|
-
else
|
|
461
|
-
fan_out_afd(entry[0], entry[1])
|
|
462
|
-
end
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
# Fan one AFD readiness completion out to the io waiters whose interest the
|
|
467
|
-
# ready mask satisfies (the pre-0.2 body of run_once step (d), unchanged).
|
|
468
|
-
def fan_out_afd(id, ready_events)
|
|
469
|
-
entry = @ops.delete(id)
|
|
470
|
-
return unless entry # stale: a superseded/cancelled poll
|
|
471
|
-
entry.id = nil
|
|
472
|
-
entry.armed = 0
|
|
473
|
-
to_wake = []
|
|
474
|
-
entry.waiters.reject! do |w|
|
|
475
|
-
if w.settled
|
|
476
|
-
true
|
|
477
|
-
elsif (got = w.events & ready_events) != 0
|
|
478
|
-
w.settled = true
|
|
479
|
-
to_wake << [w.fiber, got]
|
|
480
|
-
true
|
|
481
|
-
else
|
|
482
|
-
false # this waiter's direction didn't fire; keep it
|
|
483
|
-
end
|
|
484
|
-
end
|
|
485
|
-
@lock.synchronize { @ready.concat(to_wake) } unless to_wake.empty?
|
|
486
|
-
if entry.waiters.empty?
|
|
487
|
-
@polls.delete(entry.key)
|
|
488
|
-
else
|
|
489
|
-
arm_poll(entry) # re-arm for the still-waiting fibers
|
|
490
|
-
end
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
# Ensure at most one AFD poll is outstanding for `entry`'s socket, covering
|
|
494
|
-
# the union of all its waiters' interests. Re-arms only when the union grows.
|
|
495
|
-
def arm_poll(entry)
|
|
496
|
-
union = entry.waiters.inject(0) { |m, w| m | w.events }
|
|
497
|
-
return if union.zero?
|
|
498
|
-
return if entry.id && (union & ~entry.armed).zero? # current poll covers it
|
|
499
|
-
|
|
500
|
-
if entry.id
|
|
501
|
-
@ops.delete(entry.id)
|
|
502
|
-
@backend.cancel(entry.id) # its cancelled completion is reaped + ignored later
|
|
503
|
-
entry.id = nil
|
|
504
|
-
end
|
|
505
|
-
begin
|
|
506
|
-
entry.id = @backend.poll(entry.io, union)
|
|
507
|
-
entry.armed = union
|
|
508
|
-
@ops[entry.id] = entry
|
|
509
|
-
rescue Winloop::Error
|
|
510
|
-
# Socket vanished/closed mid-wait: wake every waiter so the follow-up
|
|
511
|
-
# recv/send surfaces the real error instead of hanging here.
|
|
512
|
-
to_wake = []
|
|
513
|
-
entry.waiters.each do |w|
|
|
514
|
-
next if w.settled
|
|
515
|
-
w.settled = true
|
|
516
|
-
to_wake << [w.fiber, w.events]
|
|
517
|
-
end
|
|
518
|
-
entry.waiters.clear
|
|
519
|
-
@lock.synchronize { @ready.concat(to_wake) } unless to_wake.empty?
|
|
520
|
-
@polls.delete(entry.key)
|
|
521
|
-
end
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
def cancel_entry(entry)
|
|
525
|
-
if entry.id
|
|
526
|
-
@ops.delete(entry.id)
|
|
527
|
-
@backend.cancel(entry.id)
|
|
528
|
-
entry.id = nil
|
|
529
|
-
end
|
|
530
|
-
@polls.delete(entry.key)
|
|
531
|
-
end
|
|
532
|
-
|
|
533
|
-
# Discard settled timers from the heap top so done? and the wait-timeout
|
|
534
|
-
# computation never block on a timer whose fiber already woke another way.
|
|
535
|
-
def drop_settled_timers
|
|
536
|
-
@timers.pop while (t = @timers.peek) && t.waiter.settled
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
# ---- generic-op helpers (winloop 0.2) ----------------------------------
|
|
540
|
-
|
|
541
|
-
# The op protocol is loop-thread-only (ops are submitted on the loop thread —
|
|
542
|
-
# fibers run there anyway; Ruby worker threads must not submit) and over once
|
|
543
|
-
# the loop has shut down.
|
|
544
|
-
def op_guard!
|
|
545
|
-
unless Thread.current == @thread
|
|
546
|
-
raise Error, "winloop: op API must be called on the scheduler's thread"
|
|
547
|
-
end
|
|
548
|
-
raise Error, "winloop: scheduler is closed" if @op_shut
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
def validate_op_timeout!(timeout)
|
|
552
|
-
return if timeout.nil?
|
|
553
|
-
return if timeout.is_a?(Numeric) && timeout.positive?
|
|
554
|
-
raise ArgumentError, "timeout must be nil or a positive Numeric (got #{timeout.inspect})"
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# true iff the id is still live and in :completed state; false for retired ids
|
|
558
|
-
# (op_state raises "unknown op id" — swallowed here, never re-raised: this
|
|
559
|
-
# probe runs inside await_op's must-not-raise ensure).
|
|
560
|
-
def op_completed_unconsumed?(op_id)
|
|
561
|
-
@backend.op_state(op_id) == :completed
|
|
562
|
-
rescue Error
|
|
563
|
-
false
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
# Enqueue a fiber to resume, honouring the one-shot guard. Caller holds @lock.
|
|
567
|
-
def wake_locked(waiter, value)
|
|
568
|
-
return if waiter.settled
|
|
569
|
-
waiter.settled = true
|
|
570
|
-
@ready << [waiter.fiber, value]
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
def report_fiber_error(error)
|
|
574
|
-
return unless Thread.report_on_exception
|
|
575
|
-
warn "winloop: unhandled exception in scheduled fiber: #{error.class}: #{error.message}"
|
|
576
|
-
warn(error.backtrace.first(10).map { |l| "\tfrom #{l}" }.join("\n")) if error.backtrace
|
|
577
|
-
rescue StandardError
|
|
578
|
-
# never let error reporting itself break the loop
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
# Degraded path for non-socket IO (pipes, the console — no AFD readiness).
|
|
582
|
-
#
|
|
583
|
-
# The read runs on a scheduler-free worker thread (see await_on_thread) so
|
|
584
|
-
# IO#readpartial there is a genuine blocking syscall instead of re-dispatching
|
|
585
|
-
# back into this io_read hook (which would recurse to a SystemStackError),
|
|
586
|
-
# while the calling fiber parks cooperatively and the loop keeps serving
|
|
587
|
-
# others. `want` is clamped to the buffer's free region so set_string can't
|
|
588
|
-
# overflow. Note: non-socket IO is best-effort — if THIS read is unwound by a
|
|
589
|
-
# Timeout, bytes the worker had already pulled off the stream are lost (you
|
|
590
|
-
# can't un-read). Sockets are the first-class path.
|
|
591
|
-
def blocking_read(io, buffer, length, offset)
|
|
592
|
-
maximum = buffer.size - offset
|
|
593
|
-
return 0 if maximum <= 0
|
|
594
|
-
want = length.zero? ? maximum : [length, maximum].min
|
|
595
|
-
data =
|
|
596
|
-
begin
|
|
597
|
-
await_on_thread { io.readpartial(want) }
|
|
598
|
-
rescue EOFError, IOError
|
|
599
|
-
nil
|
|
600
|
-
end
|
|
601
|
-
return 0 if data.nil? || data.empty?
|
|
602
|
-
buffer.set_string(data, offset)
|
|
603
|
-
data.bytesize
|
|
604
|
-
end
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
# A tiny binary min-heap keyed on the node's #deadline. Enough for timers.
|
|
608
|
-
class MinHeap
|
|
609
|
-
def initialize
|
|
610
|
-
@a = []
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
def empty? = @a.empty?
|
|
614
|
-
def size = @a.size
|
|
615
|
-
def peek = @a[0]
|
|
616
|
-
|
|
617
|
-
def push(node)
|
|
618
|
-
@a << node
|
|
619
|
-
i = @a.size - 1
|
|
620
|
-
while i > 0
|
|
621
|
-
parent = (i - 1) / 2
|
|
622
|
-
break if @a[parent].deadline <= @a[i].deadline
|
|
623
|
-
@a[parent], @a[i] = @a[i], @a[parent]
|
|
624
|
-
i = parent
|
|
625
|
-
end
|
|
626
|
-
node
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
def pop
|
|
630
|
-
return nil if @a.empty?
|
|
631
|
-
top = @a[0]
|
|
632
|
-
last = @a.pop
|
|
633
|
-
unless @a.empty?
|
|
634
|
-
@a[0] = last
|
|
635
|
-
sift_down(0)
|
|
636
|
-
end
|
|
637
|
-
top
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
private
|
|
641
|
-
|
|
642
|
-
def sift_down(i)
|
|
643
|
-
n = @a.size
|
|
644
|
-
loop do
|
|
645
|
-
l = 2 * i + 1
|
|
646
|
-
r = 2 * i + 2
|
|
647
|
-
smallest = i
|
|
648
|
-
smallest = l if l < n && @a[l].deadline < @a[smallest].deadline
|
|
649
|
-
smallest = r if r < n && @a[r].deadline < @a[smallest].deadline
|
|
650
|
-
break if smallest == i
|
|
651
|
-
@a[i], @a[smallest] = @a[smallest], @a[i]
|
|
652
|
-
i = smallest
|
|
653
|
-
end
|
|
654
|
-
end
|
|
655
|
-
end
|
|
656
|
-
end
|