winloop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/ext/winloop/extconf.rb +19 -0
- data/ext/winloop/winloop.c +358 -0
- data/lib/winloop/scheduler.rb +496 -0
- data/lib/winloop/version.rb +5 -0
- data/lib/winloop.rb +80 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 52ea4b37e9fbf3f00710317ae3ce66560b014a39103fcd731c825c77ec8367b5
|
|
4
|
+
data.tar.gz: c137e9507075259aaec8a604b5937bb305c8ed6134f33b54cecc8c19b756c21f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5406ad357675a336f7fece41a9a4f986449a71d29324b40eb1b852851517d25310417c0e88026bd353de16dd9cf4c9bcc735c7c799a298da8e08937176ad86f2
|
|
7
|
+
data.tar.gz: fc833ec7d8d95317b010f9c25b5acae10db5366ce30ffb881c922ca05fb38fa480b928b14830758f1e4b4b02432b40d45dfb0cb7b06a31aa32db4693fcbd6059
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
* A `Fiber::Scheduler` for MRI on Windows backed by I/O Completion Ports.
|
|
8
|
+
* C backend (`Winloop::Backend`): one IOCP plus a `\Device\Afd` handle, with
|
|
9
|
+
one-shot readiness polls (`IOCTL_AFD_POLL`) submitted as overlapped operations
|
|
10
|
+
on the port, reaped in batches via `GetQueuedCompletionStatusEx`. The wait runs
|
|
11
|
+
without the GVL so background threads can wake the loop with
|
|
12
|
+
`PostQueuedCompletionStatus`.
|
|
13
|
+
* Scheduler hooks: `io_wait`, `io_read`, `io_write`, `kernel_sleep`,
|
|
14
|
+
`timeout_after`, `block`/`unblock`, `fiber`, `address_resolve`, `process_wait`,
|
|
15
|
+
and `close` (which drives the event loop).
|
|
16
|
+
* Monotonic timer min-heap for `sleep` and `Timeout.timeout`.
|
|
17
|
+
* `Winloop.run { ... }` convenience entry point.
|
|
18
|
+
* Verified with TCP echo over many concurrent connections, large multi-chunk
|
|
19
|
+
transfers, EOF handling, `Timeout` (both firing and not), cross-thread `Queue`,
|
|
20
|
+
inter-fiber `Mutex` contention, `Thread#join`, DNS resolution, and `IO.popen`.
|
|
21
|
+
|
|
22
|
+
Hardened over three rounds of adversarial review (the GVL is released around the
|
|
23
|
+
completion wait so background threads can wake the loop):
|
|
24
|
+
* AFD readiness polls are **coalesced per socket** — multiple fibers waiting the
|
|
25
|
+
same socket for the same event all wake (the AFD driver only completes one poll
|
|
26
|
+
per readiness edge, so independent polls would lose wakeups).
|
|
27
|
+
* Cross-thread wakeups are unified through a single per-fiber guard, so a timeout
|
|
28
|
+
timer racing an `unblock` (e.g. `ConditionVariable#wait(mutex, timeout)` racing a
|
|
29
|
+
signal, or `Thread#join(timeout)` near its deadline) can never double-resume a
|
|
30
|
+
fiber and cut its next wait short.
|
|
31
|
+
* Settled timers are dropped from the heap so an early-resolved wait can't pin the
|
|
32
|
+
loop asleep until its original (possibly long) timeout.
|
|
33
|
+
* An unhandled exception in a scheduled fiber is reported, not fatal to the loop.
|
|
34
|
+
* The non-socket read fallback runs on a worker thread that is killed if its fiber
|
|
35
|
+
unwinds, so it can't leak or steal data; pipe EOF is silent.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leiz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# winloop
|
|
2
|
+
|
|
3
|
+
A native **Windows** Fiber Scheduler for MRI Ruby, built on **I/O Completion Ports**.
|
|
4
|
+
|
|
5
|
+
`winloop` makes ordinary blocking code — socket reads and writes, `sleep`,
|
|
6
|
+
`Timeout.timeout`, `Mutex`, `Queue`, `Thread#join`, DNS lookups — run
|
|
7
|
+
cooperatively on a single thread. Wrap your code in `Winloop.run { ... }`,
|
|
8
|
+
spawn fibers with `Fiber.schedule`, and thousands of connections share one
|
|
9
|
+
thread without you touching a single nonblocking API.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "winloop"
|
|
13
|
+
require "socket"
|
|
14
|
+
|
|
15
|
+
Winloop.run do
|
|
16
|
+
server = TCPServer.new("127.0.0.1", 9292)
|
|
17
|
+
loop do
|
|
18
|
+
client = server.accept # readiness via AFD poll on the IOCP
|
|
19
|
+
Fiber.schedule do # one lightweight fiber per connection
|
|
20
|
+
while (line = client.gets) # recv driven by the completion port
|
|
21
|
+
client.write(line) # send, never blocking the loop
|
|
22
|
+
end
|
|
23
|
+
client.close
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why this exists
|
|
30
|
+
|
|
31
|
+
Ruby's `Fiber::Scheduler` (the engine behind `socketry/async` and friends) is the
|
|
32
|
+
modern way to write concurrent I/O. But on Windows the selectors that back it —
|
|
33
|
+
`socketry/io-event` ships `select`, `epoll`, `kqueue`, and `io_uring` — have **no
|
|
34
|
+
IOCP backend**. The result has long been the weakest async story of any major
|
|
35
|
+
platform: `select()`-bound, capped at 64 handles, and slow.
|
|
36
|
+
|
|
37
|
+
`winloop` fills that hole the same way libuv, Rust's mio, and wepoll do it:
|
|
38
|
+
|
|
39
|
+
* The event loop is a real **I/O Completion Port**. One
|
|
40
|
+
`GetQueuedCompletionStatusEx` call per turn reaps every ready event at once.
|
|
41
|
+
* **Readiness** (accept, connect, `IO#wait_readable`) — which IOCP doesn't natively
|
|
42
|
+
provide — is obtained by submitting `IOCTL_AFD_POLL` against `\Device\Afd` as an
|
|
43
|
+
overlapped operation on the port. This is the undocumented mechanism libuv/mio
|
|
44
|
+
rely on, and it is the only way to get readiness semantics onto a completion port.
|
|
45
|
+
* **Reads and writes** are driven by `recv`/`send` once the port reports readiness.
|
|
46
|
+
* **Timers** (`sleep`, `Timeout.timeout`) use a monotonic min-heap that sets the
|
|
47
|
+
port's wait timeout — no kernel timer objects, no extra threads.
|
|
48
|
+
* **`Mutex`/`Queue`/`Thread#join`** park the fiber on an in-process waiter list; a
|
|
49
|
+
wakeup from another OS thread breaks the loop's wait via
|
|
50
|
+
`PostQueuedCompletionStatus`.
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
* A **native Windows MSVC build of Ruby** (`x64-mswin64`). winloop compiles a C
|
|
55
|
+
extension with `cl.exe` and links `ws2_32`. It will refuse to install on any
|
|
56
|
+
non-`mswin` platform.
|
|
57
|
+
* Ruby >= 3.1 (for a stable `Fiber::Scheduler` and `IO::Buffer`).
|
|
58
|
+
* `\Device\Afd` readiness polling is a Windows NT facility; winloop is **not**
|
|
59
|
+
expected to work under Wine.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
gem install winloop
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or build from a checkout (it dogfoods the [`vcvars`](https://rubygems.org/gems/vcvars)
|
|
68
|
+
gem so it can find the MSVC toolchain without a Developer Command Prompt):
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
rake compile
|
|
72
|
+
rake test
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What runs cooperatively
|
|
76
|
+
|
|
77
|
+
Inside `Winloop.run { ... }` (and any `Fiber.schedule` started within it):
|
|
78
|
+
|
|
79
|
+
| Operation | Hook | Mechanism |
|
|
80
|
+
| ------------------------------------------ | --------------- | -------------------------------------- |
|
|
81
|
+
| `TCPServer#accept`, `IO#wait_readable` | `io_wait` | `IOCTL_AFD_POLL` overlapped on the IOCP |
|
|
82
|
+
| `IO#read` / `gets` / `recv` on a socket | `io_read` | `recv_nonblock` + `io_wait` |
|
|
83
|
+
| `IO#write` / `puts` on a socket | `io_write` | `write_nonblock` + `io_wait` |
|
|
84
|
+
| `sleep` | `kernel_sleep` | monotonic timer heap |
|
|
85
|
+
| `Timeout.timeout` | `timeout_after` | timer heap + `Fiber#raise` |
|
|
86
|
+
| `Mutex`, `ConditionVariable`, `Queue`, `Thread#join` | `block`/`unblock` | waiter lists + `PostQueuedCompletionStatus` |
|
|
87
|
+
| `Addrinfo.getaddrinfo`, DNS in `TCPSocket.new` | `address_resolve` | resolved on a worker thread |
|
|
88
|
+
| `Process.wait` | `process_wait` | reaped on a worker thread |
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Winloop.run { ... } # install a scheduler, run the block in a fiber, drive the
|
|
94
|
+
# loop until every scheduled fiber finishes; returns the
|
|
95
|
+
# block's value (and re-raises anything it raised).
|
|
96
|
+
|
|
97
|
+
Winloop.supported? # true when the native IOCP/AFD backend loaded.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
For full control you can drive it yourself, exactly like any other
|
|
101
|
+
`Fiber::Scheduler`:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
scheduler = Winloop::Scheduler.new
|
|
105
|
+
Fiber.set_scheduler(scheduler)
|
|
106
|
+
Fiber.schedule { ... }
|
|
107
|
+
scheduler.close # runs the event loop to completion
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Scope and limitations (v0.1)
|
|
111
|
+
|
|
112
|
+
* **Sockets are the focus.** TCP/UDP sockets get the full IOCP/AFD path. Pipes and
|
|
113
|
+
regular files fall back to a blocking read inside `io_read` (async file I/O on
|
|
114
|
+
Windows is a separate mechanism, planned for later).
|
|
115
|
+
* **Single thread-of-use.** One thread runs the loop and resumes fibers; other OS
|
|
116
|
+
threads may safely wake it (e.g. a background `Queue` producer), but the loop
|
|
117
|
+
itself is not a multi-threaded worker pool.
|
|
118
|
+
* `IOCTL_AFD_POLL` is undocumented; winloop resolves its `ntdll` entry points at
|
|
119
|
+
runtime and degrades by raising a clear error if `\Device\Afd` is unavailable.
|
|
120
|
+
|
|
121
|
+
## How it compares
|
|
122
|
+
|
|
123
|
+
`winloop` is a drop-in `Fiber::Scheduler`, so it works with anything that targets
|
|
124
|
+
the scheduler interface. It is *not* a reimplementation of `async` — it's the
|
|
125
|
+
missing Windows engine such libraries can run on. The reference scheduler shipped
|
|
126
|
+
with Ruby is intended for testing and is `IO.select`-bound; winloop replaces that
|
|
127
|
+
selector with a true completion port.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# extconf.rb for the winloop C backend (IOCP + \Device\Afd readiness).
|
|
4
|
+
|
|
5
|
+
require "mkmf"
|
|
6
|
+
|
|
7
|
+
unless RbConfig::CONFIG["target_os"] =~ /mswin/
|
|
8
|
+
abort <<~MSG
|
|
9
|
+
winloop requires a native Windows MSVC (mswin) Ruby — its event loop is built
|
|
10
|
+
on Win32 I/O Completion Ports and \\Device\\Afd readiness polling (cl.exe).
|
|
11
|
+
Your Ruby is "#{RbConfig::CONFIG['arch']}".
|
|
12
|
+
MSG
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Winsock. (ntdll's Nt*/Rtl* entry points are resolved at runtime via
|
|
16
|
+
# GetProcAddress, so no ntdll import lib is required.) Pure C — no -EHsc.
|
|
17
|
+
$libs = [$libs, "ws2_32.lib"].join(" ")
|
|
18
|
+
|
|
19
|
+
create_makefile("winloop/winloop")
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* winloop — a Windows IOCP event-loop backend for a Ruby Fiber Scheduler.
|
|
3
|
+
*
|
|
4
|
+
* This C extension owns one I/O Completion Port plus one \Device\Afd handle and
|
|
5
|
+
* exposes the minimum the Ruby scheduler needs:
|
|
6
|
+
*
|
|
7
|
+
* Winloop::Backend.new -> create the IOCP + AFD handle
|
|
8
|
+
* #poll(io, events) -> id arm a one-shot AFD readiness poll for `io`
|
|
9
|
+
* #cancel(id) cancel a pending poll (CancelIoEx)
|
|
10
|
+
* #wait(timeout_ms) -> [[id, ready_events], ...] reap completions (one GQCSEx)
|
|
11
|
+
* #wakeup PostQueuedCompletionStatus (break the wait)
|
|
12
|
+
* #shutdown cancel + drain + close (idempotent)
|
|
13
|
+
*
|
|
14
|
+
* Readiness over a *completion* port is done the libuv/mio/wepoll way: submit
|
|
15
|
+
* IOCTL_AFD_POLL on \Device\Afd as an overlapped op whose ApcContext is the
|
|
16
|
+
* OVERLAPPED pointer (THE make-or-break detail — without it no completion packet
|
|
17
|
+
* is queued). Each in-flight poll is a winloop_req whose OVERLAPPED is the first
|
|
18
|
+
* member, so a completion's lpOverlapped casts straight back to the request.
|
|
19
|
+
*
|
|
20
|
+
* Pure C (no C++/-EHsc), so rb_raise (longjmp) is the normal, safe mechanism.
|
|
21
|
+
* include <ruby.h> before the Windows headers; never name a variable IN/OUT.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
#include <ruby.h>
|
|
25
|
+
#include <ruby/io.h>
|
|
26
|
+
#include <ruby/win32.h>
|
|
27
|
+
#include <ruby/st.h>
|
|
28
|
+
#include <ruby/thread.h>
|
|
29
|
+
|
|
30
|
+
#include <winsock2.h>
|
|
31
|
+
#include <ws2tcpip.h>
|
|
32
|
+
#include <windows.h>
|
|
33
|
+
#include <winternl.h>
|
|
34
|
+
#include <stdint.h>
|
|
35
|
+
|
|
36
|
+
/* ---- Ruby IO event bits (verified: READABLE=1, WRITABLE=4, PRIORITY=2) ---- */
|
|
37
|
+
#define RB_READABLE 1
|
|
38
|
+
#define RB_PRIORITY 2
|
|
39
|
+
#define RB_WRITABLE 4
|
|
40
|
+
|
|
41
|
+
/* ---- AFD poll flags / IOCTL (verified on the target machine) ---- */
|
|
42
|
+
#define AFD_POLL_RECEIVE 0x0001
|
|
43
|
+
#define AFD_POLL_RECEIVE_EXPEDITED 0x0002
|
|
44
|
+
#define AFD_POLL_SEND 0x0004
|
|
45
|
+
#define AFD_POLL_DISCONNECT 0x0008
|
|
46
|
+
#define AFD_POLL_ABORT 0x0010
|
|
47
|
+
#define AFD_POLL_LOCAL_CLOSE 0x0020
|
|
48
|
+
#define AFD_POLL_ACCEPT 0x0080
|
|
49
|
+
#define AFD_POLL_CONNECT_FAIL 0x0100
|
|
50
|
+
#define IOCTL_AFD_POLL 0x00012024
|
|
51
|
+
#ifndef SIO_BASE_HANDLE
|
|
52
|
+
#define SIO_BASE_HANDLE 0x48000022
|
|
53
|
+
#endif
|
|
54
|
+
#ifndef STATUS_PENDING
|
|
55
|
+
#define STATUS_PENDING ((NTSTATUS)0x00000103L)
|
|
56
|
+
#endif
|
|
57
|
+
#define AFD_FILE_OPEN 0x00000001
|
|
58
|
+
|
|
59
|
+
#define WAKE_KEY ((ULONG_PTR)0x57414B45ull) /* "WAKE" — PostQueuedCompletionStatus marker */
|
|
60
|
+
#define AFD_KEY ((ULONG_PTR)0x41464430ull) /* "AFD0" — the AFD handle's completion key */
|
|
61
|
+
#define REAP_CAP 64
|
|
62
|
+
|
|
63
|
+
typedef struct _AFD_POLL_HANDLE_INFO {
|
|
64
|
+
HANDLE Handle;
|
|
65
|
+
ULONG Events;
|
|
66
|
+
NTSTATUS Status;
|
|
67
|
+
} AFD_POLL_HANDLE_INFO;
|
|
68
|
+
typedef struct _AFD_POLL_INFO {
|
|
69
|
+
LARGE_INTEGER Timeout;
|
|
70
|
+
ULONG NumberOfHandles;
|
|
71
|
+
ULONG Exclusive;
|
|
72
|
+
AFD_POLL_HANDLE_INFO Handles[1];
|
|
73
|
+
} AFD_POLL_INFO;
|
|
74
|
+
|
|
75
|
+
typedef NTSTATUS (NTAPI *PFN_NtCreateFile)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES,
|
|
76
|
+
PIO_STATUS_BLOCK, PLARGE_INTEGER, ULONG, ULONG, ULONG, ULONG, PVOID, ULONG);
|
|
77
|
+
typedef NTSTATUS (NTAPI *PFN_NtDeviceIoControlFile)(HANDLE, HANDLE, PIO_APC_ROUTINE,
|
|
78
|
+
PVOID, PIO_STATUS_BLOCK, ULONG, PVOID, ULONG, PVOID, ULONG);
|
|
79
|
+
typedef VOID (NTAPI *PFN_RtlInitUnicodeString)(PUNICODE_STRING, PCWSTR);
|
|
80
|
+
|
|
81
|
+
static PFN_NtCreateFile pNtCreateFile;
|
|
82
|
+
static PFN_NtDeviceIoControlFile pNtDeviceIoControlFile;
|
|
83
|
+
static PFN_RtlInitUnicodeString pRtlInitUnicodeString;
|
|
84
|
+
|
|
85
|
+
#ifndef InitializeObjectAttributes
|
|
86
|
+
#define InitializeObjectAttributes(p, n, a, r, s) do { \
|
|
87
|
+
(p)->Length = sizeof(OBJECT_ATTRIBUTES); (p)->RootDirectory = (r); \
|
|
88
|
+
(p)->Attributes = (a); (p)->ObjectName = (n); (p)->SecurityDescriptor = (s); \
|
|
89
|
+
(p)->SecurityQualityOfService = NULL; } while (0)
|
|
90
|
+
#endif
|
|
91
|
+
|
|
92
|
+
/* One in-flight AFD poll. OVERLAPPED MUST be first so lpOverlapped casts back. */
|
|
93
|
+
typedef struct {
|
|
94
|
+
OVERLAPPED ov;
|
|
95
|
+
uint64_t id;
|
|
96
|
+
AFD_POLL_INFO in;
|
|
97
|
+
AFD_POLL_INFO out;
|
|
98
|
+
} winloop_req;
|
|
99
|
+
|
|
100
|
+
typedef struct {
|
|
101
|
+
HANDLE iocp;
|
|
102
|
+
HANDLE afd;
|
|
103
|
+
uint64_t next_id;
|
|
104
|
+
st_table *reqs; /* id -> winloop_req* */
|
|
105
|
+
int closed;
|
|
106
|
+
} winloop_backend;
|
|
107
|
+
|
|
108
|
+
static VALUE mWinloop, cBackend, eError;
|
|
109
|
+
|
|
110
|
+
/* ---- ntdll resolution ---- */
|
|
111
|
+
static int resolve_ntdll(void) {
|
|
112
|
+
HMODULE nt = GetModuleHandleW(L"ntdll.dll");
|
|
113
|
+
if (!nt) return 0;
|
|
114
|
+
pNtCreateFile = (PFN_NtCreateFile) GetProcAddress(nt, "NtCreateFile");
|
|
115
|
+
pNtDeviceIoControlFile = (PFN_NtDeviceIoControlFile) GetProcAddress(nt, "NtDeviceIoControlFile");
|
|
116
|
+
pRtlInitUnicodeString = (PFN_RtlInitUnicodeString) GetProcAddress(nt, "RtlInitUnicodeString");
|
|
117
|
+
return pNtCreateFile && pNtDeviceIoControlFile && pRtlInitUnicodeString;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static HANDLE afd_open(void) {
|
|
121
|
+
HANDLE h = INVALID_HANDLE_VALUE;
|
|
122
|
+
UNICODE_STRING name; OBJECT_ATTRIBUTES oa; IO_STATUS_BLOCK iosb; NTSTATUS st;
|
|
123
|
+
pRtlInitUnicodeString(&name, L"\\Device\\Afd\\Winloop");
|
|
124
|
+
InitializeObjectAttributes(&oa, &name, 0, NULL, NULL);
|
|
125
|
+
st = pNtCreateFile(&h, SYNCHRONIZE | FILE_READ_DATA | FILE_WRITE_DATA, &oa, &iosb,
|
|
126
|
+
NULL, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, AFD_FILE_OPEN, 0, NULL, 0);
|
|
127
|
+
return (st == 0) ? h : INVALID_HANDLE_VALUE;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static SOCKET base_socket(SOCKET s) {
|
|
131
|
+
SOCKET base = INVALID_SOCKET; DWORD bytes = 0;
|
|
132
|
+
if (WSAIoctl(s, SIO_BASE_HANDLE, NULL, 0, &base, sizeof(base), &bytes, NULL, NULL) == 0)
|
|
133
|
+
return base;
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static ULONG ruby_to_afd(int events) {
|
|
138
|
+
ULONG a = AFD_POLL_ABORT | AFD_POLL_CONNECT_FAIL | AFD_POLL_LOCAL_CLOSE; /* always report errors */
|
|
139
|
+
if (events & RB_READABLE) a |= AFD_POLL_RECEIVE | AFD_POLL_ACCEPT | AFD_POLL_DISCONNECT;
|
|
140
|
+
if (events & RB_PRIORITY) a |= AFD_POLL_RECEIVE_EXPEDITED;
|
|
141
|
+
if (events & RB_WRITABLE) a |= AFD_POLL_SEND;
|
|
142
|
+
return a;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static int afd_to_ruby(ULONG a) {
|
|
146
|
+
int e = 0;
|
|
147
|
+
if (a & (AFD_POLL_RECEIVE | AFD_POLL_RECEIVE_EXPEDITED | AFD_POLL_ACCEPT |
|
|
148
|
+
AFD_POLL_DISCONNECT | AFD_POLL_LOCAL_CLOSE)) e |= RB_READABLE;
|
|
149
|
+
if (a & AFD_POLL_RECEIVE_EXPEDITED) e |= RB_PRIORITY;
|
|
150
|
+
if (a & (AFD_POLL_SEND | AFD_POLL_CONNECT_FAIL)) e |= RB_WRITABLE;
|
|
151
|
+
/* On abort/error, wake for both directions so the follow-up recv/send surfaces it. */
|
|
152
|
+
if (a & (AFD_POLL_ABORT | AFD_POLL_CONNECT_FAIL)) e |= RB_READABLE | RB_WRITABLE;
|
|
153
|
+
return e;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---- TypedData ---- */
|
|
157
|
+
static int free_req_i(st_data_t key, st_data_t val, st_data_t arg) {
|
|
158
|
+
(void)key; (void)arg;
|
|
159
|
+
xfree((void *)val);
|
|
160
|
+
return ST_CONTINUE;
|
|
161
|
+
}
|
|
162
|
+
static void backend_drain_and_close(winloop_backend *b) {
|
|
163
|
+
if (b->closed) return;
|
|
164
|
+
b->closed = 1;
|
|
165
|
+
if (b->reqs) {
|
|
166
|
+
/* Cancel everything still pending so the kernel stops owning our OVERLAPPEDs. */
|
|
167
|
+
if (b->afd != INVALID_HANDLE_VALUE) CancelIoEx(b->afd, NULL);
|
|
168
|
+
}
|
|
169
|
+
/* Drain queued completions (best-effort) so no packet references freed memory. */
|
|
170
|
+
if (b->iocp) {
|
|
171
|
+
OVERLAPPED_ENTRY ents[REAP_CAP]; ULONG n = 0; int spins = 0;
|
|
172
|
+
while (GetQueuedCompletionStatusEx(b->iocp, ents, REAP_CAP, &n, 0, FALSE) && n && spins++ < 4096) {
|
|
173
|
+
/* drop them; the requests are freed below from the table */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (b->afd != INVALID_HANDLE_VALUE) { CloseHandle(b->afd); b->afd = INVALID_HANDLE_VALUE; }
|
|
177
|
+
if (b->iocp) { CloseHandle(b->iocp); b->iocp = NULL; }
|
|
178
|
+
if (b->reqs) { st_foreach(b->reqs, free_req_i, 0); st_free_table(b->reqs); b->reqs = NULL; }
|
|
179
|
+
}
|
|
180
|
+
static void backend_free(void *p) {
|
|
181
|
+
winloop_backend *b = (winloop_backend *)p;
|
|
182
|
+
if (!b) return;
|
|
183
|
+
backend_drain_and_close(b);
|
|
184
|
+
xfree(b);
|
|
185
|
+
}
|
|
186
|
+
static size_t backend_memsize(const void *p) { (void)p; return sizeof(winloop_backend); }
|
|
187
|
+
static const rb_data_type_t backend_type = {
|
|
188
|
+
"Winloop::Backend", { 0, backend_free, backend_memsize }, 0, 0, RUBY_TYPED_FREE_IMMEDIATELY
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
static winloop_backend *get_backend(VALUE self) {
|
|
192
|
+
winloop_backend *b;
|
|
193
|
+
TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
|
|
194
|
+
if (!b || b->closed) rb_raise(eError, "winloop: backend is closed");
|
|
195
|
+
return b;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
static VALUE backend_alloc(VALUE klass) {
|
|
199
|
+
winloop_backend *b;
|
|
200
|
+
VALUE obj = TypedData_Make_Struct(klass, winloop_backend, &backend_type, b);
|
|
201
|
+
b->afd = INVALID_HANDLE_VALUE;
|
|
202
|
+
return obj;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static VALUE backend_initialize(VALUE self) {
|
|
206
|
+
winloop_backend *b;
|
|
207
|
+
TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
|
|
208
|
+
if (!resolve_ntdll()) rb_raise(eError, "winloop: could not resolve ntdll entry points");
|
|
209
|
+
b->iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
|
|
210
|
+
if (!b->iocp) rb_raise(eError, "winloop: CreateIoCompletionPort failed (%lu)", GetLastError());
|
|
211
|
+
b->afd = afd_open();
|
|
212
|
+
if (b->afd == INVALID_HANDLE_VALUE) {
|
|
213
|
+
CloseHandle(b->iocp); b->iocp = NULL;
|
|
214
|
+
rb_raise(eError, "winloop: could not open \\Device\\Afd (unsupported platform?)");
|
|
215
|
+
}
|
|
216
|
+
if (!CreateIoCompletionPort(b->afd, b->iocp, AFD_KEY, 0)) {
|
|
217
|
+
CloseHandle(b->afd); CloseHandle(b->iocp); b->afd = INVALID_HANDLE_VALUE; b->iocp = NULL;
|
|
218
|
+
rb_raise(eError, "winloop: could not associate AFD handle (%lu)", GetLastError());
|
|
219
|
+
}
|
|
220
|
+
b->next_id = 0;
|
|
221
|
+
b->reqs = st_init_numtable();
|
|
222
|
+
b->closed = 0;
|
|
223
|
+
return self;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Arm a one-shot readiness poll on `io` for `events`; returns the request id. */
|
|
227
|
+
static VALUE backend_poll(VALUE self, VALUE io, VALUE vevents) {
|
|
228
|
+
winloop_backend *b = get_backend(self);
|
|
229
|
+
int events = NUM2INT(vevents);
|
|
230
|
+
int fd = rb_io_descriptor(io);
|
|
231
|
+
SOCKET sock = rb_w32_get_osfhandle(fd);
|
|
232
|
+
if (sock == INVALID_SOCKET || !rb_w32_is_socket(fd))
|
|
233
|
+
rb_raise(eError, "winloop: fd %d is not a socket (winloop polls sockets)", fd);
|
|
234
|
+
SOCKET base = base_socket(sock);
|
|
235
|
+
|
|
236
|
+
winloop_req *req = ALLOC(winloop_req);
|
|
237
|
+
memset(req, 0, sizeof(*req));
|
|
238
|
+
req->id = ++b->next_id;
|
|
239
|
+
req->in.Timeout.QuadPart = INT64_MAX; /* stays pending until an event fires */
|
|
240
|
+
req->in.NumberOfHandles = 1;
|
|
241
|
+
req->in.Exclusive = FALSE;
|
|
242
|
+
req->in.Handles[0].Handle = (HANDLE)base;
|
|
243
|
+
req->in.Handles[0].Events = ruby_to_afd(events);
|
|
244
|
+
|
|
245
|
+
NTSTATUS st = pNtDeviceIoControlFile(b->afd, NULL, NULL, &req->ov,
|
|
246
|
+
(PIO_STATUS_BLOCK)&req->ov.Internal, IOCTL_AFD_POLL,
|
|
247
|
+
&req->in, sizeof(req->in), &req->out, sizeof(req->out));
|
|
248
|
+
if (st != STATUS_PENDING && st != 0 /*STATUS_SUCCESS*/) {
|
|
249
|
+
xfree(req);
|
|
250
|
+
rb_raise(eError, "winloop: AFD poll failed (status 0x%08lX)", (unsigned long)st);
|
|
251
|
+
}
|
|
252
|
+
/* STATUS_SUCCESS == satisfied synchronously, but a completion packet is still
|
|
253
|
+
queued (we never set FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) — handle uniformly. */
|
|
254
|
+
st_insert(b->reqs, (st_data_t)req->id, (st_data_t)req);
|
|
255
|
+
return ULL2NUM(req->id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Cancel a pending poll. The cancelled op still posts a (STATUS_CANCELLED)
|
|
259
|
+
completion, which #wait reaps and frees — so we do NOT free here. */
|
|
260
|
+
static VALUE backend_cancel(VALUE self, VALUE vid) {
|
|
261
|
+
winloop_backend *b = get_backend(self);
|
|
262
|
+
uint64_t id = NUM2ULL(vid);
|
|
263
|
+
st_data_t val;
|
|
264
|
+
if (st_lookup(b->reqs, (st_data_t)id, &val)) {
|
|
265
|
+
winloop_req *req = (winloop_req *)val;
|
|
266
|
+
CancelIoEx(b->afd, &req->ov);
|
|
267
|
+
}
|
|
268
|
+
return Qnil;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* Reap completions in one GetQueuedCompletionStatusEx. Returns an Array of
|
|
272
|
+
[id, ready_events]; wakeup packets are skipped. Empty array on timeout. */
|
|
273
|
+
/* The blocking GQCSEx call, run WITHOUT the GVL so other threads (and the very
|
|
274
|
+
producer that will call #unblock) can run while the loop sleeps on the port. */
|
|
275
|
+
typedef struct {
|
|
276
|
+
HANDLE iocp;
|
|
277
|
+
OVERLAPPED_ENTRY *ents;
|
|
278
|
+
ULONG cap, n;
|
|
279
|
+
DWORD ms, err;
|
|
280
|
+
BOOL ok;
|
|
281
|
+
} gqcs_args;
|
|
282
|
+
|
|
283
|
+
static void *gqcs_call(void *p) {
|
|
284
|
+
gqcs_args *a = (gqcs_args *)p;
|
|
285
|
+
a->ok = GetQueuedCompletionStatusEx(a->iocp, a->ents, a->cap, &a->n, a->ms, FALSE);
|
|
286
|
+
a->err = a->ok ? 0 : GetLastError();
|
|
287
|
+
return NULL;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* Ruby's interrupt path (signals, Thread#kill): wake the GQCSEx so the loop
|
|
291
|
+
thread can return to Ruby and check interrupts. */
|
|
292
|
+
static void gqcs_ubf(void *p) {
|
|
293
|
+
PostQueuedCompletionStatus((HANDLE)p, 0, WAKE_KEY, NULL);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
static VALUE backend_wait(VALUE self, VALUE vtimeout_ms) {
|
|
297
|
+
winloop_backend *b = get_backend(self);
|
|
298
|
+
DWORD ms = NIL_P(vtimeout_ms) ? INFINITE : (DWORD)NUM2ULONG(vtimeout_ms);
|
|
299
|
+
OVERLAPPED_ENTRY ents[REAP_CAP];
|
|
300
|
+
|
|
301
|
+
gqcs_args a = { b->iocp, ents, REAP_CAP, 0, ms, 0, FALSE };
|
|
302
|
+
rb_thread_call_without_gvl(gqcs_call, &a, gqcs_ubf, b->iocp);
|
|
303
|
+
BOOL ok = a.ok;
|
|
304
|
+
DWORD err = a.err;
|
|
305
|
+
ULONG n = a.n;
|
|
306
|
+
|
|
307
|
+
VALUE result = rb_ary_new();
|
|
308
|
+
if (!ok) {
|
|
309
|
+
if (err == WAIT_TIMEOUT) return result;
|
|
310
|
+
rb_raise(eError, "winloop: GetQueuedCompletionStatusEx failed (%lu)", err);
|
|
311
|
+
}
|
|
312
|
+
for (ULONG i = 0; i < n; i++) {
|
|
313
|
+
if (ents[i].lpCompletionKey == WAKE_KEY || ents[i].lpOverlapped == NULL) continue;
|
|
314
|
+
winloop_req *req = (winloop_req *)ents[i].lpOverlapped;
|
|
315
|
+
st_data_t key = (st_data_t)req->id;
|
|
316
|
+
st_delete(b->reqs, &key, NULL);
|
|
317
|
+
int events = afd_to_ruby(req->out.Handles[0].Events);
|
|
318
|
+
VALUE pair = rb_ary_new_from_args(2, ULL2NUM(req->id), INT2NUM(events));
|
|
319
|
+
rb_ary_push(result, pair);
|
|
320
|
+
xfree(req);
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
static VALUE backend_wakeup(VALUE self) {
|
|
326
|
+
winloop_backend *b = get_backend(self);
|
|
327
|
+
PostQueuedCompletionStatus(b->iocp, 0, WAKE_KEY, NULL);
|
|
328
|
+
return Qnil;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
static VALUE backend_shutdown(VALUE self) {
|
|
332
|
+
winloop_backend *b;
|
|
333
|
+
TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
|
|
334
|
+
if (b) backend_drain_and_close(b);
|
|
335
|
+
return Qnil;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
void Init_winloop(void) {
|
|
339
|
+
WSADATA wsa;
|
|
340
|
+
WSAStartup(MAKEWORD(2, 2), &wsa);
|
|
341
|
+
|
|
342
|
+
mWinloop = rb_define_module("Winloop");
|
|
343
|
+
cBackend = rb_define_class_under(mWinloop, "Backend", rb_cObject);
|
|
344
|
+
eError = rb_define_class_under(mWinloop, "Error", rb_eStandardError);
|
|
345
|
+
|
|
346
|
+
/* Export the Ruby IO event bits the scheduler/backend agree on. */
|
|
347
|
+
rb_define_const(mWinloop, "READABLE", INT2NUM(RB_READABLE));
|
|
348
|
+
rb_define_const(mWinloop, "WRITABLE", INT2NUM(RB_WRITABLE));
|
|
349
|
+
rb_define_const(mWinloop, "PRIORITY", INT2NUM(RB_PRIORITY));
|
|
350
|
+
|
|
351
|
+
rb_define_alloc_func(cBackend, backend_alloc);
|
|
352
|
+
rb_define_method(cBackend, "initialize", RUBY_METHOD_FUNC(backend_initialize), 0);
|
|
353
|
+
rb_define_method(cBackend, "poll", RUBY_METHOD_FUNC(backend_poll), 2);
|
|
354
|
+
rb_define_method(cBackend, "cancel", RUBY_METHOD_FUNC(backend_cancel), 1);
|
|
355
|
+
rb_define_method(cBackend, "wait", RUBY_METHOD_FUNC(backend_wait), 1);
|
|
356
|
+
rb_define_method(cBackend, "wakeup", RUBY_METHOD_FUNC(backend_wakeup), 0);
|
|
357
|
+
rb_define_method(cBackend, "shutdown", RUBY_METHOD_FUNC(backend_shutdown), 0);
|
|
358
|
+
}
|
|
@@ -0,0 +1,496 @@
|
|
|
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
|
+
end
|
|
53
|
+
|
|
54
|
+
def monotonic
|
|
55
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ---- I/O readiness (accept / connect / IO#wait) -----------------------
|
|
59
|
+
|
|
60
|
+
# Wait until `io` is ready for `events`; returns the ready-events bitmask
|
|
61
|
+
# (0 on timeout). events is an OR of Winloop::READABLE/WRITABLE/PRIORITY.
|
|
62
|
+
def io_wait(io, events, timeout)
|
|
63
|
+
key = io.fileno
|
|
64
|
+
waiter = IoWaiter.new(Fiber.current, events, false)
|
|
65
|
+
entry = (@polls[key] ||= PollEntry.new(key, io, nil, 0, []))
|
|
66
|
+
entry.waiters << waiter
|
|
67
|
+
arm_poll(entry)
|
|
68
|
+
@timers.push(Timer.new(monotonic + timeout, waiter, 0, nil)) if timeout
|
|
69
|
+
Fiber.yield # resumed with the ready-events mask (or 0 on timeout)
|
|
70
|
+
ensure
|
|
71
|
+
# On the normal completion path the loop already removed us from the entry
|
|
72
|
+
# and re-armed/dropped it. If we are unwinding by any other path (timeout,
|
|
73
|
+
# or Timeout.timeout raised into us), detach and re-arm/cancel for the rest.
|
|
74
|
+
waiter.settled = true
|
|
75
|
+
if entry.waiters.delete(waiter)
|
|
76
|
+
if entry.waiters.empty?
|
|
77
|
+
cancel_entry(entry)
|
|
78
|
+
else
|
|
79
|
+
arm_poll(entry) # interest shrank; existing poll still covers it (no-op)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- stream read/write (recv_nonblock + io_wait) ----------------------
|
|
85
|
+
#
|
|
86
|
+
# `length` is the MINIMUM bytes to transfer; 0 means "the whole region
|
|
87
|
+
# [offset, buffer.size)". MUST use recv_nonblock — read_nonblock / sysread /
|
|
88
|
+
# readpartial re-enter this hook on mswin and recurse forever.
|
|
89
|
+
|
|
90
|
+
def io_read(io, buffer, length, offset = 0)
|
|
91
|
+
return blocking_read(io, buffer, length, offset) unless io.respond_to?(:recv_nonblock)
|
|
92
|
+
total = 0
|
|
93
|
+
minimum = length.zero? ? 1 : length
|
|
94
|
+
while total < minimum
|
|
95
|
+
maximum = buffer.size - offset - total
|
|
96
|
+
break if maximum <= 0
|
|
97
|
+
result = io.recv_nonblock(maximum, exception: false)
|
|
98
|
+
case result
|
|
99
|
+
when :wait_readable then io_wait(io, READABLE, nil)
|
|
100
|
+
when nil then break # EOF
|
|
101
|
+
else
|
|
102
|
+
buffer.set_string(result, offset + total)
|
|
103
|
+
total += result.bytesize
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
total
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def io_write(io, buffer, length, offset = 0)
|
|
110
|
+
total = 0
|
|
111
|
+
maximum = buffer.size - offset
|
|
112
|
+
return 0 if maximum <= 0
|
|
113
|
+
minimum = length.zero? ? maximum : length
|
|
114
|
+
minimum = maximum if minimum > maximum # never spin writing empty chunks
|
|
115
|
+
while total < minimum
|
|
116
|
+
chunk = buffer.get_string(offset + total, maximum - total)
|
|
117
|
+
break if chunk.empty?
|
|
118
|
+
result = io.write_nonblock(chunk, exception: false)
|
|
119
|
+
if result == :wait_writable
|
|
120
|
+
io_wait(io, WRITABLE, nil)
|
|
121
|
+
else
|
|
122
|
+
total += result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
total
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ---- timers -----------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def kernel_sleep(duration = nil)
|
|
131
|
+
fiber = Fiber.current
|
|
132
|
+
waiter = Waiter.new(fiber, false)
|
|
133
|
+
# Park under @parked so a cross-thread #unblock (e.g. ConditionVariable#wait
|
|
134
|
+
# with a timeout parks HERE, and a racing signal must wake us through the
|
|
135
|
+
# SAME one-shot guard as the timer — never a second, competing wakeup).
|
|
136
|
+
@lock.synchronize { @parked[fiber] = waiter }
|
|
137
|
+
@timers.push(Timer.new(monotonic + duration, waiter, nil, nil)) if duration
|
|
138
|
+
Fiber.yield
|
|
139
|
+
duration
|
|
140
|
+
ensure
|
|
141
|
+
@lock.synchronize do
|
|
142
|
+
waiter.settled = true # disarm a not-yet-fired timer on abnormal exit
|
|
143
|
+
@parked.delete(fiber)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def timeout_after(duration, exception_class, *exception_arguments)
|
|
148
|
+
waiter = Waiter.new(Fiber.current, false)
|
|
149
|
+
@timers.push(Timer.new(monotonic + duration, waiter, nil,
|
|
150
|
+
[exception_class, exception_arguments]))
|
|
151
|
+
begin
|
|
152
|
+
yield duration
|
|
153
|
+
ensure
|
|
154
|
+
# disarm if the block finished before the deadline
|
|
155
|
+
@lock.synchronize { waiter.settled = true }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ---- block / unblock (Mutex, ConditionVariable, Queue, Thread#join) ----
|
|
160
|
+
|
|
161
|
+
def block(blocker, timeout = nil)
|
|
162
|
+
fiber = Fiber.current
|
|
163
|
+
waiter = Waiter.new(fiber, false)
|
|
164
|
+
@lock.synchronize { @parked[fiber] = waiter }
|
|
165
|
+
@timers.push(Timer.new(monotonic + timeout, waiter, false, nil)) if timeout
|
|
166
|
+
Fiber.yield # true (unblocked) or false (timed out)
|
|
167
|
+
ensure
|
|
168
|
+
@lock.synchronize do
|
|
169
|
+
waiter.settled = true
|
|
170
|
+
@parked.delete(fiber)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Called from ANY thread: only mutate shared state under the lock and wake
|
|
175
|
+
# the loop — never resume the fiber here.
|
|
176
|
+
#
|
|
177
|
+
# We wake by FIBER identity through its single parked Waiter, ignoring
|
|
178
|
+
# `blocker`. That is deliberate and important:
|
|
179
|
+
# * Thread#join calls block(thread) but MRI calls unblock(<a DIFFERENT
|
|
180
|
+
# object>, fiber); keying on the fiber wakes it correctly anyway.
|
|
181
|
+
# * A fiber can have more than one live wakeup source (a timeout timer AND
|
|
182
|
+
# this unblock — e.g. ConditionVariable#wait(mutex, timeout) racing a
|
|
183
|
+
# signal). Routing through the one Waiter's `settled` guard means exactly
|
|
184
|
+
# one of them resumes the fiber; the loser is dropped. A raw, unguarded
|
|
185
|
+
# "@ready << fiber" would double-resume and cut the fiber's NEXT wait short.
|
|
186
|
+
def unblock(_blocker, fiber)
|
|
187
|
+
@lock.synchronize do
|
|
188
|
+
waiter = @parked[fiber]
|
|
189
|
+
if waiter
|
|
190
|
+
wake_locked(waiter, true)
|
|
191
|
+
elsif @ready.none? { |f, _| f.equal?(fiber) }
|
|
192
|
+
# Not parked in any wait we track (spurious/late unblock) — last resort,
|
|
193
|
+
# de-duplicated so it can never double-enqueue.
|
|
194
|
+
@ready << [fiber, true]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
@backend.wakeup
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ---- fiber creation ----------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def fiber(&block)
|
|
203
|
+
fiber = Fiber.new(blocking: false, &block)
|
|
204
|
+
fiber.resume
|
|
205
|
+
fiber
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- optional hooks (offloaded to a thread; the originals re-enter) -----
|
|
209
|
+
|
|
210
|
+
# Run a blocking operation on a fresh, scheduler-FREE thread and park the
|
|
211
|
+
# calling fiber on it cooperatively (Thread#value -> our block hook). The
|
|
212
|
+
# worker is killed if this fiber unwinds first (Timeout.timeout / Fiber kill)
|
|
213
|
+
# so it can never outlive us, leak, or race a later operation. We do NOT
|
|
214
|
+
# join after kill — that would re-enter the scheduler mid-unwind; Thread#kill
|
|
215
|
+
# interrupts the blocking syscall and the worker terminates on its own.
|
|
216
|
+
def await_on_thread(&block)
|
|
217
|
+
worker = Thread.new(&block)
|
|
218
|
+
worker.report_on_exception = false # expected errors are surfaced via #value
|
|
219
|
+
begin
|
|
220
|
+
worker.value
|
|
221
|
+
ensure
|
|
222
|
+
if worker.alive?
|
|
223
|
+
# Unwound first (Timeout/kill): interrupt the worker out of its blocking
|
|
224
|
+
# syscall and WAIT for it to die before returning, so it can't read (and
|
|
225
|
+
# discard) bytes that a later operation on the same IO expects. kill
|
|
226
|
+
# interrupts readpartial immediately, so this join does not block.
|
|
227
|
+
worker.kill
|
|
228
|
+
worker.join
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def address_resolve(hostname)
|
|
234
|
+
await_on_thread do
|
|
235
|
+
Addrinfo.getaddrinfo(hostname, nil, nil, :STREAM).map(&:ip_address).uniq
|
|
236
|
+
end
|
|
237
|
+
rescue SocketError
|
|
238
|
+
[]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def process_wait(pid, flags)
|
|
242
|
+
await_on_thread { Process::Status.wait(pid, flags) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ---- lifecycle ---------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
# MRI calls this automatically at thread/program exit. It IS the event loop.
|
|
248
|
+
def close
|
|
249
|
+
return if @closed
|
|
250
|
+
@closed = true
|
|
251
|
+
run
|
|
252
|
+
ensure
|
|
253
|
+
@backend.shutdown
|
|
254
|
+
# NEVER call Fiber.set_scheduler(nil) here — it raises SystemStackError
|
|
255
|
+
# during MRI's implicit close at thread exit.
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def closed? = @closed
|
|
259
|
+
|
|
260
|
+
# ---- the run loop ------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def run
|
|
263
|
+
run_once until done?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def done?
|
|
269
|
+
# Locked so the "are we idle?" decision is atomic against a cross-thread
|
|
270
|
+
# #unblock that is moving a waiter into @ready right now.
|
|
271
|
+
@lock.synchronize do
|
|
272
|
+
drop_settled_timers
|
|
273
|
+
@ready.empty? && @timers.empty? && @ops.empty? && @parked.empty?
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def run_once
|
|
278
|
+
# (a) resume everything currently ready (outside the lock — resume runs
|
|
279
|
+
# arbitrary Ruby which may re-enter the scheduler). An unhandled error
|
|
280
|
+
# in a scheduled child fiber is reported, not fatal to the loop
|
|
281
|
+
# (Thread-like); the main block's own errors are handled by Winloop.run.
|
|
282
|
+
ready = @lock.synchronize { r = @ready; @ready = []; r }
|
|
283
|
+
ready.each do |fiber, value|
|
|
284
|
+
next unless fiber.alive?
|
|
285
|
+
begin
|
|
286
|
+
fiber.resume(value)
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
report_fiber_error(e)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# (b) fire expired timers (skipping any already settled by another path)
|
|
293
|
+
drop_settled_timers
|
|
294
|
+
now = monotonic
|
|
295
|
+
while (t = @timers.peek) && t.deadline <= now
|
|
296
|
+
@timers.pop
|
|
297
|
+
next if t.waiter.settled
|
|
298
|
+
if t.raise_exc
|
|
299
|
+
t.waiter.settled = true
|
|
300
|
+
klass, args = t.raise_exc
|
|
301
|
+
f = t.waiter.fiber
|
|
302
|
+
begin
|
|
303
|
+
f.raise(klass, *args) if f.alive?
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
report_fiber_error(e)
|
|
306
|
+
end
|
|
307
|
+
else
|
|
308
|
+
@lock.synchronize { wake_locked(t.waiter, t.value) }
|
|
309
|
+
end
|
|
310
|
+
drop_settled_timers
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# (c) compute the single-wait timeout. The idle decision is taken under
|
|
314
|
+
# the lock so a concurrent #unblock can't slip a ready fiber past us.
|
|
315
|
+
timeout_ms = nil
|
|
316
|
+
idle = false
|
|
317
|
+
@lock.synchronize do
|
|
318
|
+
drop_settled_timers
|
|
319
|
+
if !@ready.empty?
|
|
320
|
+
timeout_ms = 0
|
|
321
|
+
elsif (t = @timers.peek)
|
|
322
|
+
ms = ((t.deadline - monotonic) * 1000).ceil
|
|
323
|
+
timeout_ms = ms < 0 ? 0 : ms
|
|
324
|
+
elsif @ops.empty? && @parked.empty?
|
|
325
|
+
idle = true # nothing to wait for
|
|
326
|
+
else
|
|
327
|
+
timeout_ms = nil # INFINITE — pending I/O or parked fibers wake us
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
return if idle
|
|
331
|
+
|
|
332
|
+
# (d) one GetQueuedCompletionStatusEx; fan each completion out to the
|
|
333
|
+
# io waiters whose interest the ready mask satisfies.
|
|
334
|
+
@backend.wait(timeout_ms).each do |id, ready_events|
|
|
335
|
+
entry = @ops.delete(id)
|
|
336
|
+
next unless entry # stale: a superseded/cancelled poll
|
|
337
|
+
entry.id = nil
|
|
338
|
+
entry.armed = 0
|
|
339
|
+
to_wake = []
|
|
340
|
+
entry.waiters.reject! do |w|
|
|
341
|
+
if w.settled
|
|
342
|
+
true
|
|
343
|
+
elsif (got = w.events & ready_events) != 0
|
|
344
|
+
w.settled = true
|
|
345
|
+
to_wake << [w.fiber, got]
|
|
346
|
+
true
|
|
347
|
+
else
|
|
348
|
+
false # this waiter's direction didn't fire; keep it
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
@lock.synchronize { @ready.concat(to_wake) } unless to_wake.empty?
|
|
352
|
+
if entry.waiters.empty?
|
|
353
|
+
@polls.delete(entry.key)
|
|
354
|
+
else
|
|
355
|
+
arm_poll(entry) # re-arm for the still-waiting fibers
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Ensure at most one AFD poll is outstanding for `entry`'s socket, covering
|
|
361
|
+
# the union of all its waiters' interests. Re-arms only when the union grows.
|
|
362
|
+
def arm_poll(entry)
|
|
363
|
+
union = entry.waiters.inject(0) { |m, w| m | w.events }
|
|
364
|
+
return if union.zero?
|
|
365
|
+
return if entry.id && (union & ~entry.armed).zero? # current poll covers it
|
|
366
|
+
|
|
367
|
+
if entry.id
|
|
368
|
+
@ops.delete(entry.id)
|
|
369
|
+
@backend.cancel(entry.id) # its cancelled completion is reaped + ignored later
|
|
370
|
+
entry.id = nil
|
|
371
|
+
end
|
|
372
|
+
begin
|
|
373
|
+
entry.id = @backend.poll(entry.io, union)
|
|
374
|
+
entry.armed = union
|
|
375
|
+
@ops[entry.id] = entry
|
|
376
|
+
rescue Winloop::Error
|
|
377
|
+
# Socket vanished/closed mid-wait: wake every waiter so the follow-up
|
|
378
|
+
# recv/send surfaces the real error instead of hanging here.
|
|
379
|
+
to_wake = []
|
|
380
|
+
entry.waiters.each do |w|
|
|
381
|
+
next if w.settled
|
|
382
|
+
w.settled = true
|
|
383
|
+
to_wake << [w.fiber, w.events]
|
|
384
|
+
end
|
|
385
|
+
entry.waiters.clear
|
|
386
|
+
@lock.synchronize { @ready.concat(to_wake) } unless to_wake.empty?
|
|
387
|
+
@polls.delete(entry.key)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def cancel_entry(entry)
|
|
392
|
+
if entry.id
|
|
393
|
+
@ops.delete(entry.id)
|
|
394
|
+
@backend.cancel(entry.id)
|
|
395
|
+
entry.id = nil
|
|
396
|
+
end
|
|
397
|
+
@polls.delete(entry.key)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Discard settled timers from the heap top so done? and the wait-timeout
|
|
401
|
+
# computation never block on a timer whose fiber already woke another way.
|
|
402
|
+
def drop_settled_timers
|
|
403
|
+
@timers.pop while (t = @timers.peek) && t.waiter.settled
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Enqueue a fiber to resume, honouring the one-shot guard. Caller holds @lock.
|
|
407
|
+
def wake_locked(waiter, value)
|
|
408
|
+
return if waiter.settled
|
|
409
|
+
waiter.settled = true
|
|
410
|
+
@ready << [waiter.fiber, value]
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def report_fiber_error(error)
|
|
414
|
+
return unless Thread.report_on_exception
|
|
415
|
+
warn "winloop: unhandled exception in scheduled fiber: #{error.class}: #{error.message}"
|
|
416
|
+
warn(error.backtrace.first(10).map { |l| "\tfrom #{l}" }.join("\n")) if error.backtrace
|
|
417
|
+
rescue StandardError
|
|
418
|
+
# never let error reporting itself break the loop
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Degraded path for non-socket IO (pipes, the console — no AFD readiness).
|
|
422
|
+
#
|
|
423
|
+
# The read runs on a scheduler-free worker thread (see await_on_thread) so
|
|
424
|
+
# IO#readpartial there is a genuine blocking syscall instead of re-dispatching
|
|
425
|
+
# back into this io_read hook (which would recurse to a SystemStackError),
|
|
426
|
+
# while the calling fiber parks cooperatively and the loop keeps serving
|
|
427
|
+
# others. `want` is clamped to the buffer's free region so set_string can't
|
|
428
|
+
# overflow. Note: non-socket IO is best-effort — if THIS read is unwound by a
|
|
429
|
+
# Timeout, bytes the worker had already pulled off the stream are lost (you
|
|
430
|
+
# can't un-read). Sockets are the first-class path.
|
|
431
|
+
def blocking_read(io, buffer, length, offset)
|
|
432
|
+
maximum = buffer.size - offset
|
|
433
|
+
return 0 if maximum <= 0
|
|
434
|
+
want = length.zero? ? maximum : [length, maximum].min
|
|
435
|
+
data =
|
|
436
|
+
begin
|
|
437
|
+
await_on_thread { io.readpartial(want) }
|
|
438
|
+
rescue EOFError, IOError
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
return 0 if data.nil? || data.empty?
|
|
442
|
+
buffer.set_string(data, offset)
|
|
443
|
+
data.bytesize
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A tiny binary min-heap keyed on the node's #deadline. Enough for timers.
|
|
448
|
+
class MinHeap
|
|
449
|
+
def initialize
|
|
450
|
+
@a = []
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def empty? = @a.empty?
|
|
454
|
+
def size = @a.size
|
|
455
|
+
def peek = @a[0]
|
|
456
|
+
|
|
457
|
+
def push(node)
|
|
458
|
+
@a << node
|
|
459
|
+
i = @a.size - 1
|
|
460
|
+
while i > 0
|
|
461
|
+
parent = (i - 1) / 2
|
|
462
|
+
break if @a[parent].deadline <= @a[i].deadline
|
|
463
|
+
@a[parent], @a[i] = @a[i], @a[parent]
|
|
464
|
+
i = parent
|
|
465
|
+
end
|
|
466
|
+
node
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def pop
|
|
470
|
+
return nil if @a.empty?
|
|
471
|
+
top = @a[0]
|
|
472
|
+
last = @a.pop
|
|
473
|
+
unless @a.empty?
|
|
474
|
+
@a[0] = last
|
|
475
|
+
sift_down(0)
|
|
476
|
+
end
|
|
477
|
+
top
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
private
|
|
481
|
+
|
|
482
|
+
def sift_down(i)
|
|
483
|
+
n = @a.size
|
|
484
|
+
loop do
|
|
485
|
+
l = 2 * i + 1
|
|
486
|
+
r = 2 * i + 2
|
|
487
|
+
smallest = i
|
|
488
|
+
smallest = l if l < n && @a[l].deadline < @a[smallest].deadline
|
|
489
|
+
smallest = r if r < n && @a[r].deadline < @a[smallest].deadline
|
|
490
|
+
break if smallest == i
|
|
491
|
+
@a[i], @a[smallest] = @a[smallest], @a[i]
|
|
492
|
+
i = smallest
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
end
|
data/lib/winloop.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "winloop/version"
|
|
4
|
+
|
|
5
|
+
# winloop — a native Windows (IOCP) Fiber Scheduler for MRI Ruby.
|
|
6
|
+
#
|
|
7
|
+
# require "winloop"
|
|
8
|
+
#
|
|
9
|
+
# Winloop.run do
|
|
10
|
+
# server = TCPServer.new("127.0.0.1", 9292)
|
|
11
|
+
# loop do
|
|
12
|
+
# client = server.accept # io_wait via AFD poll
|
|
13
|
+
# Fiber.schedule do # one fiber per connection
|
|
14
|
+
# while (line = client.gets) # io_read via recv_nonblock + io_wait
|
|
15
|
+
# client.write(line)
|
|
16
|
+
# end
|
|
17
|
+
# client.close
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# All blocking socket I/O, sleeps, timeouts and Mutex/Queue/Thread#join inside
|
|
23
|
+
# the block run cooperatively on a single thread, driven by one I/O Completion
|
|
24
|
+
# Port. See Winloop::Scheduler for the hook implementations.
|
|
25
|
+
module Winloop
|
|
26
|
+
class Error < StandardError; end unless const_defined?(:Error)
|
|
27
|
+
|
|
28
|
+
# Loaded for its side effect of defining Winloop::Backend + the IO event
|
|
29
|
+
# constants (READABLE/WRITABLE/PRIORITY). mswin-only.
|
|
30
|
+
begin
|
|
31
|
+
require "winloop/winloop"
|
|
32
|
+
rescue LoadError => e
|
|
33
|
+
raise LoadError, "winloop's native backend failed to load — winloop needs a " \
|
|
34
|
+
"Windows MSVC (mswin) Ruby. (#{e.message})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
require_relative "winloop/scheduler"
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# True if the IOCP/AFD backend is available on this platform.
|
|
42
|
+
def supported?
|
|
43
|
+
const_defined?(:Backend)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Install a fresh Winloop::Scheduler on the current thread, run `block` inside
|
|
47
|
+
# a non-blocking fiber, drive the event loop until every scheduled fiber has
|
|
48
|
+
# finished, and return the block's value.
|
|
49
|
+
#
|
|
50
|
+
# Nesting is not supported; if a scheduler is already installed, the block is
|
|
51
|
+
# simply scheduled on it.
|
|
52
|
+
def run
|
|
53
|
+
raise ArgumentError, "Winloop.run requires a block" unless block_given?
|
|
54
|
+
|
|
55
|
+
# Already inside a scheduler (e.g. a nested Winloop.run within a fiber): just
|
|
56
|
+
# run the block here — blocking operations already cooperate with that loop.
|
|
57
|
+
return yield if Fiber.scheduler
|
|
58
|
+
|
|
59
|
+
scheduler = Scheduler.new
|
|
60
|
+
Fiber.set_scheduler(scheduler)
|
|
61
|
+
result = nil
|
|
62
|
+
error = nil
|
|
63
|
+
Fiber.schedule do
|
|
64
|
+
begin
|
|
65
|
+
result = yield
|
|
66
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
67
|
+
error = e
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
scheduler.close # drives the loop to completion
|
|
71
|
+
raise error if error
|
|
72
|
+
result
|
|
73
|
+
ensure
|
|
74
|
+
# Detach our (now-closed) scheduler so subsequent ordinary I/O on this
|
|
75
|
+
# thread doesn't hit a closed backend. Safe here: we are on the main fiber
|
|
76
|
+
# and close has already returned (the reentrant-close trap only bites when
|
|
77
|
+
# set_scheduler(nil) is called from *inside* #close during finalization).
|
|
78
|
+
Fiber.set_scheduler(nil) if Fiber.scheduler.equal?(scheduler)
|
|
79
|
+
end
|
|
80
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: winloop
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Leiz
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake-compiler
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.2'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
description: |
|
|
55
|
+
winloop is a Ruby Fiber::Scheduler built on Win32 I/O Completion Ports. It
|
|
56
|
+
makes ordinary socket I/O, sleeps, timeouts and Mutex/Queue/Thread#join run
|
|
57
|
+
cooperatively on a single thread — the async-runtime story that has always
|
|
58
|
+
been weak on Windows, done the way libuv/mio/wepoll do it: readiness over an
|
|
59
|
+
IOCP via \Device\Afd polling, with recv/send driven by the completion port.
|
|
60
|
+
Requires a native Windows MSVC (mswin) build of Ruby.
|
|
61
|
+
executables: []
|
|
62
|
+
extensions:
|
|
63
|
+
- ext/winloop/extconf.rb
|
|
64
|
+
extra_rdoc_files: []
|
|
65
|
+
files:
|
|
66
|
+
- CHANGELOG.md
|
|
67
|
+
- LICENSE.txt
|
|
68
|
+
- README.md
|
|
69
|
+
- ext/winloop/extconf.rb
|
|
70
|
+
- ext/winloop/winloop.c
|
|
71
|
+
- lib/winloop.rb
|
|
72
|
+
- lib/winloop/scheduler.rb
|
|
73
|
+
- lib/winloop/version.rb
|
|
74
|
+
homepage: https://github.com/leiz/winloop
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/leiz/winloop
|
|
79
|
+
source_code_uri: https://github.com/leiz/winloop
|
|
80
|
+
changelog_uri: https://github.com/leiz/winloop/blob/main/CHANGELOG.md
|
|
81
|
+
allowed_push_host: https://rubygems.org
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.1'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: A native Windows (IOCP) Fiber Scheduler for MRI Ruby.
|
|
99
|
+
test_files: []
|