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