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.
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
@@ -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
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Winloop
4
- VERSION = "0.2.0"
5
- end