winloop 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,712 +0,0 @@
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 EXT_KEY ((ULONG_PTR)0x45585431ull) /* "EXT1" — ALL generic client ops (winloop 0.2) */
62
- #define REAP_CAP 64
63
-
64
- /* Generic op state machine: prepare -> submit -> complete (reaped) -> retire (freed).
65
- * Only a reaped completion ends kernel ownership of the OVERLAPPED + buffer; the
66
- * sole exception is op_abandon on a PREPARED op the kernel never saw. */
67
- enum { OP_PREPARED = 0, OP_SUBMITTED = 1, OP_COMPLETED = 2 };
68
-
69
- typedef struct _AFD_POLL_HANDLE_INFO {
70
- HANDLE Handle;
71
- ULONG Events;
72
- NTSTATUS Status;
73
- } AFD_POLL_HANDLE_INFO;
74
- typedef struct _AFD_POLL_INFO {
75
- LARGE_INTEGER Timeout;
76
- ULONG NumberOfHandles;
77
- ULONG Exclusive;
78
- AFD_POLL_HANDLE_INFO Handles[1];
79
- } AFD_POLL_INFO;
80
-
81
- typedef NTSTATUS (NTAPI *PFN_NtCreateFile)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES,
82
- PIO_STATUS_BLOCK, PLARGE_INTEGER, ULONG, ULONG, ULONG, ULONG, PVOID, ULONG);
83
- typedef NTSTATUS (NTAPI *PFN_NtDeviceIoControlFile)(HANDLE, HANDLE, PIO_APC_ROUTINE,
84
- PVOID, PIO_STATUS_BLOCK, ULONG, PVOID, ULONG, PVOID, ULONG);
85
- typedef VOID (NTAPI *PFN_RtlInitUnicodeString)(PUNICODE_STRING, PCWSTR);
86
- typedef ULONG (NTAPI *PFN_RtlNtStatusToDosError)(NTSTATUS);
87
-
88
- static PFN_NtCreateFile pNtCreateFile;
89
- static PFN_NtDeviceIoControlFile pNtDeviceIoControlFile;
90
- static PFN_RtlInitUnicodeString pRtlInitUnicodeString;
91
- static PFN_RtlNtStatusToDosError pRtlNtStatusToDosError;
92
-
93
- #ifndef InitializeObjectAttributes
94
- #define InitializeObjectAttributes(p, n, a, r, s) do { \
95
- (p)->Length = sizeof(OBJECT_ATTRIBUTES); (p)->RootDirectory = (r); \
96
- (p)->Attributes = (a); (p)->ObjectName = (n); (p)->SecurityDescriptor = (s); \
97
- (p)->SecurityQualityOfService = NULL; } while (0)
98
- #endif
99
-
100
- /* One in-flight AFD poll. OVERLAPPED MUST be first so lpOverlapped casts back. */
101
- typedef struct {
102
- OVERLAPPED ov;
103
- uint64_t id;
104
- AFD_POLL_INFO in;
105
- AFD_POLL_INFO out;
106
- } winloop_req;
107
-
108
- /* One generic op (winloop 0.2). OVERLAPPED MUST be first: a completion's
109
- * lpOverlapped casts back to the record — the same proven pattern as winloop_req. */
110
- typedef struct {
111
- OVERLAPPED ov; /* zeroed at prepare; kernel writes Internal/InternalHigh */
112
- uint64_t id; /* Ruby-visible op id (shares next_id with AFD reqs) */
113
- uint64_t tag; /* user value, echoed in the completion tuple */
114
- HANDLE handle; /* associated client handle for CancelIoEx; never NULL */
115
- int state; /* OP_PREPARED -> OP_SUBMITTED -> OP_COMPLETED */
116
- DWORD bytes; /* filled at reap */
117
- DWORD error; /* Win32 code at reap (RtlNtStatusToDosError(ov.Internal)) */
118
- size_t cap; /* embedded buffer capacity (0 = none) */
119
- /* unsigned char buf[cap] co-allocated at OP_BUF_OFFSET (8-byte aligned) */
120
- } winloop_op;
121
-
122
- #define OP_BUF_OFFSET ((sizeof(winloop_op) + 7) & ~(size_t)7)
123
-
124
- typedef struct {
125
- HANDLE iocp;
126
- HANDLE afd;
127
- uint64_t next_id;
128
- st_table *reqs; /* id -> winloop_req* */
129
- st_table *ops_by_id; /* id -> winloop_op* (Ruby-facing op methods) */
130
- st_table *ops_by_addr; /* OVERLAPPED addr -> winloop_op* (defensive reap) */
131
- st_table *assoc_handles; /* set of HANDLE values passed to #associate (op_prepare
132
- validation ONLY — never used for blanket cancels, so
133
- a recycled handle value can't trigger a wrong cancel) */
134
- size_t op_bytes; /* live op allocations, reported by backend_memsize */
135
- int closed;
136
- } winloop_backend;
137
-
138
- static VALUE mWinloop, cBackend, eError;
139
-
140
- /* ---- ntdll resolution ---- */
141
- static int resolve_ntdll(void) {
142
- HMODULE nt = GetModuleHandleW(L"ntdll.dll");
143
- if (!nt) return 0;
144
- pNtCreateFile = (PFN_NtCreateFile) GetProcAddress(nt, "NtCreateFile");
145
- pNtDeviceIoControlFile = (PFN_NtDeviceIoControlFile) GetProcAddress(nt, "NtDeviceIoControlFile");
146
- pRtlInitUnicodeString = (PFN_RtlInitUnicodeString) GetProcAddress(nt, "RtlInitUnicodeString");
147
- pRtlNtStatusToDosError = (PFN_RtlNtStatusToDosError) GetProcAddress(nt, "RtlNtStatusToDosError");
148
- return pNtCreateFile && pNtDeviceIoControlFile && pRtlInitUnicodeString &&
149
- pRtlNtStatusToDosError;
150
- }
151
-
152
- static HANDLE afd_open(void) {
153
- HANDLE h = INVALID_HANDLE_VALUE;
154
- UNICODE_STRING name; OBJECT_ATTRIBUTES oa; IO_STATUS_BLOCK iosb; NTSTATUS st;
155
- pRtlInitUnicodeString(&name, L"\\Device\\Afd\\Winloop");
156
- InitializeObjectAttributes(&oa, &name, 0, NULL, NULL);
157
- st = pNtCreateFile(&h, SYNCHRONIZE | FILE_READ_DATA | FILE_WRITE_DATA, &oa, &iosb,
158
- NULL, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, AFD_FILE_OPEN, 0, NULL, 0);
159
- return (st == 0) ? h : INVALID_HANDLE_VALUE;
160
- }
161
-
162
- static SOCKET base_socket(SOCKET s) {
163
- SOCKET base = INVALID_SOCKET; DWORD bytes = 0;
164
- if (WSAIoctl(s, SIO_BASE_HANDLE, NULL, 0, &base, sizeof(base), &bytes, NULL, NULL) == 0)
165
- return base;
166
- return s;
167
- }
168
-
169
- static ULONG ruby_to_afd(int events) {
170
- ULONG a = AFD_POLL_ABORT | AFD_POLL_CONNECT_FAIL | AFD_POLL_LOCAL_CLOSE; /* always report errors */
171
- if (events & RB_READABLE) a |= AFD_POLL_RECEIVE | AFD_POLL_ACCEPT | AFD_POLL_DISCONNECT;
172
- if (events & RB_PRIORITY) a |= AFD_POLL_RECEIVE_EXPEDITED;
173
- if (events & RB_WRITABLE) a |= AFD_POLL_SEND;
174
- return a;
175
- }
176
-
177
- static int afd_to_ruby(ULONG a) {
178
- int e = 0;
179
- if (a & (AFD_POLL_RECEIVE | AFD_POLL_RECEIVE_EXPEDITED | AFD_POLL_ACCEPT |
180
- AFD_POLL_DISCONNECT | AFD_POLL_LOCAL_CLOSE)) e |= RB_READABLE;
181
- if (a & AFD_POLL_RECEIVE_EXPEDITED) e |= RB_PRIORITY;
182
- if (a & (AFD_POLL_SEND | AFD_POLL_CONNECT_FAIL)) e |= RB_WRITABLE;
183
- /* On abort/error, wake for both directions so the follow-up recv/send surfaces it. */
184
- if (a & (AFD_POLL_ABORT | AFD_POLL_CONNECT_FAIL)) e |= RB_READABLE | RB_WRITABLE;
185
- return e;
186
- }
187
-
188
- /* ---- TypedData ---- */
189
- static int free_req_i(st_data_t key, st_data_t val, st_data_t arg) {
190
- (void)key; (void)arg;
191
- xfree((void *)val);
192
- return ST_CONTINUE;
193
- }
194
- /* Shutdown sweep step 1: cancel every still-SUBMITTED generic op, per op
195
- * (CancelIoEx(handle, ov) — precise; a blanket per-handle cancel would be exposed
196
- * to the recycled-handle-value hazard if a client closed a handle early). */
197
- static int cancel_submitted_op_i(st_data_t key, st_data_t val, st_data_t arg) {
198
- winloop_op *op = (winloop_op *)val;
199
- (void)key; (void)arg;
200
- if (op->state == OP_SUBMITTED) CancelIoEx(op->handle, &op->ov);
201
- /* failures ignored: ERROR_NOT_FOUND = already completed; a (contract-
202
- * violating) closed handle simply fails the lookup */
203
- return ST_CONTINUE;
204
- }
205
- static int count_submitted_op_i(st_data_t key, st_data_t val, st_data_t arg) {
206
- winloop_op *op = (winloop_op *)val;
207
- (void)key;
208
- if (op->state == OP_SUBMITTED) (*(size_t *)arg)++;
209
- return ST_CONTINUE;
210
- }
211
- /* Shutdown sweep step 4: free every op the kernel can no longer touch. Ops still
212
- * OP_SUBMITTED (their packet never surfaced inside the bounded drain) are
213
- * DELIBERATELY LEAKED — the kernel may still write their OVERLAPPED/IOSB, and a
214
- * use-after-free beats a bounded leak (the libuv tradeoff). In practice the
215
- * per-op CancelIoEx + the 100 ms drain retires them. */
216
- static int free_op_unless_submitted_i(st_data_t key, st_data_t val, st_data_t arg) {
217
- winloop_op *op = (winloop_op *)val;
218
- (void)key; (void)arg;
219
- if (op->state != OP_SUBMITTED) xfree(op);
220
- return ST_CONTINUE;
221
- }
222
- /* During the shutdown drain, mark reaped EXT packets' ops OP_COMPLETED so the
223
- * sweep may free them. Pure C — also runs from the GC free hook (no Ruby calls). */
224
- static void drain_mark_ext(winloop_backend *b, OVERLAPPED_ENTRY *ents, ULONG n) {
225
- ULONG i;
226
- for (i = 0; i < n; i++) {
227
- st_data_t val;
228
- if (ents[i].lpCompletionKey != EXT_KEY || ents[i].lpOverlapped == NULL) continue;
229
- if (!st_lookup(b->ops_by_addr, (st_data_t)(uintptr_t)ents[i].lpOverlapped, &val)) continue;
230
- ((winloop_op *)val)->state = OP_COMPLETED;
231
- }
232
- }
233
- static void backend_drain_and_close(winloop_backend *b) {
234
- if (b->closed) return;
235
- b->closed = 1;
236
- if (b->reqs) {
237
- /* Cancel everything still pending so the kernel stops owning our OVERLAPPEDs. */
238
- if (b->afd != INVALID_HANDLE_VALUE) CancelIoEx(b->afd, NULL);
239
- }
240
- if (b->ops_by_id) st_foreach(b->ops_by_id, cancel_submitted_op_i, 0);
241
- /* Drain queued completions (best-effort) so no packet references freed memory. */
242
- if (b->iocp) {
243
- OVERLAPPED_ENTRY ents[REAP_CAP]; ULONG n = 0; int spins = 0;
244
- while (GetQueuedCompletionStatusEx(b->iocp, ents, REAP_CAP, &n, 0, FALSE) && n && spins++ < 4096) {
245
- if (b->ops_by_addr) drain_mark_ext(b, ents, n);
246
- }
247
- /* One bounded 100 ms drain — ONLY when generic ops are still in flight
248
- * (their cancel completions are usually instants away). Existing AFD-only
249
- * workloads never take this branch; the GVL stays held (this also runs
250
- * from the GC free hook, where releasing the GVL is not an option). */
251
- if (b->ops_by_id) {
252
- size_t still = 0;
253
- st_foreach(b->ops_by_id, count_submitted_op_i, (st_data_t)&still);
254
- if (still &&
255
- GetQueuedCompletionStatusEx(b->iocp, ents, REAP_CAP, &n, 100, FALSE) && n) {
256
- drain_mark_ext(b, ents, n);
257
- spins = 0;
258
- while (GetQueuedCompletionStatusEx(b->iocp, ents, REAP_CAP, &n, 0, FALSE) && n && spins++ < 4096)
259
- drain_mark_ext(b, ents, n);
260
- }
261
- }
262
- }
263
- if (b->afd != INVALID_HANDLE_VALUE) { CloseHandle(b->afd); b->afd = INVALID_HANDLE_VALUE; }
264
- if (b->iocp) { CloseHandle(b->iocp); b->iocp = NULL; }
265
- if (b->reqs) { st_foreach(b->reqs, free_req_i, 0); st_free_table(b->reqs); b->reqs = NULL; }
266
- if (b->ops_by_id) { st_foreach(b->ops_by_id, free_op_unless_submitted_i, 0); st_free_table(b->ops_by_id); b->ops_by_id = NULL; }
267
- if (b->ops_by_addr) { st_free_table(b->ops_by_addr); b->ops_by_addr = NULL; }
268
- if (b->assoc_handles) { st_free_table(b->assoc_handles); b->assoc_handles = NULL; }
269
- b->op_bytes = 0;
270
- }
271
- static void backend_free(void *p) {
272
- winloop_backend *b = (winloop_backend *)p;
273
- if (!b) return;
274
- backend_drain_and_close(b);
275
- xfree(b);
276
- }
277
- static size_t backend_memsize(const void *p) {
278
- const winloop_backend *b = (const winloop_backend *)p;
279
- return sizeof(winloop_backend) + (b ? b->op_bytes : 0);
280
- }
281
- static const rb_data_type_t backend_type = {
282
- "Winloop::Backend", { 0, backend_free, backend_memsize }, 0, 0, RUBY_TYPED_FREE_IMMEDIATELY
283
- };
284
-
285
- static winloop_backend *get_backend(VALUE self) {
286
- winloop_backend *b;
287
- TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
288
- if (!b || b->closed) rb_raise(eError, "winloop: backend is closed");
289
- return b;
290
- }
291
-
292
- /* The op methods additionally need the op tables, which only #initialize creates
293
- * (a Backend.allocate'd-but-never-initialized object must raise, not crash). */
294
- static winloop_backend *get_backend_ops(VALUE self) {
295
- winloop_backend *b = get_backend(self);
296
- if (!b->ops_by_id) rb_raise(eError, "winloop: backend is closed");
297
- return b;
298
- }
299
-
300
- static VALUE backend_alloc(VALUE klass) {
301
- winloop_backend *b;
302
- VALUE obj = TypedData_Make_Struct(klass, winloop_backend, &backend_type, b);
303
- b->afd = INVALID_HANDLE_VALUE;
304
- return obj;
305
- }
306
-
307
- static VALUE backend_initialize(VALUE self) {
308
- winloop_backend *b;
309
- TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
310
- if (!resolve_ntdll()) rb_raise(eError, "winloop: could not resolve ntdll entry points");
311
- b->iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
312
- if (!b->iocp) rb_raise(eError, "winloop: CreateIoCompletionPort failed (%lu)", GetLastError());
313
- b->afd = afd_open();
314
- if (b->afd == INVALID_HANDLE_VALUE) {
315
- CloseHandle(b->iocp); b->iocp = NULL;
316
- rb_raise(eError, "winloop: could not open \\Device\\Afd (unsupported platform?)");
317
- }
318
- if (!CreateIoCompletionPort(b->afd, b->iocp, AFD_KEY, 0)) {
319
- CloseHandle(b->afd); CloseHandle(b->iocp); b->afd = INVALID_HANDLE_VALUE; b->iocp = NULL;
320
- rb_raise(eError, "winloop: could not associate AFD handle (%lu)", GetLastError());
321
- }
322
- b->next_id = 0;
323
- b->reqs = st_init_numtable();
324
- /* st_data_t is pointer-sized; numtables hold ids/addresses/handles arm64-clean. */
325
- b->ops_by_id = st_init_numtable();
326
- b->ops_by_addr = st_init_numtable();
327
- b->assoc_handles = st_init_numtable();
328
- b->op_bytes = 0;
329
- b->closed = 0;
330
- return self;
331
- }
332
-
333
- /* Arm a one-shot readiness poll on `io` for `events`; returns the request id. */
334
- static VALUE backend_poll(VALUE self, VALUE io, VALUE vevents) {
335
- winloop_backend *b = get_backend(self);
336
- int events = NUM2INT(vevents);
337
- int fd = rb_io_descriptor(io);
338
- SOCKET sock = rb_w32_get_osfhandle(fd);
339
- if (sock == INVALID_SOCKET || !rb_w32_is_socket(fd))
340
- rb_raise(eError, "winloop: fd %d is not a socket (winloop polls sockets)", fd);
341
- SOCKET base = base_socket(sock);
342
-
343
- winloop_req *req = ALLOC(winloop_req);
344
- memset(req, 0, sizeof(*req));
345
- req->id = ++b->next_id;
346
- req->in.Timeout.QuadPart = INT64_MAX; /* stays pending until an event fires */
347
- req->in.NumberOfHandles = 1;
348
- req->in.Exclusive = FALSE;
349
- req->in.Handles[0].Handle = (HANDLE)base;
350
- req->in.Handles[0].Events = ruby_to_afd(events);
351
-
352
- NTSTATUS st = pNtDeviceIoControlFile(b->afd, NULL, NULL, &req->ov,
353
- (PIO_STATUS_BLOCK)&req->ov.Internal, IOCTL_AFD_POLL,
354
- &req->in, sizeof(req->in), &req->out, sizeof(req->out));
355
- if (st != STATUS_PENDING && st != 0 /*STATUS_SUCCESS*/) {
356
- xfree(req);
357
- rb_raise(eError, "winloop: AFD poll failed (status 0x%08lX)", (unsigned long)st);
358
- }
359
- /* STATUS_SUCCESS == satisfied synchronously, but a completion packet is still
360
- queued (we never set FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) — handle uniformly. */
361
- st_insert(b->reqs, (st_data_t)req->id, (st_data_t)req);
362
- return ULL2NUM(req->id);
363
- }
364
-
365
- /* Cancel a pending poll. The cancelled op still posts a (STATUS_CANCELLED)
366
- completion, which #wait reaps and frees — so we do NOT free here. */
367
- static VALUE backend_cancel(VALUE self, VALUE vid) {
368
- winloop_backend *b = get_backend(self);
369
- uint64_t id = NUM2ULL(vid);
370
- st_data_t val;
371
- if (st_lookup(b->reqs, (st_data_t)id, &val)) {
372
- winloop_req *req = (winloop_req *)val;
373
- CancelIoEx(b->afd, &req->ov);
374
- }
375
- return Qnil;
376
- }
377
-
378
- /* ===== Generic OVERLAPPED ops — "bring your own OVERLAPPED" (winloop 0.2) =====
379
- *
380
- * The whole feature is Integer-in/Integer-out: handles, addresses and ids cross
381
- * the API as Ruby Integers (the cross-gem ABI — other gems never link winloop).
382
- * winloop owns op memory (OVERLAPPED + embedded buffer, one heap allocation);
383
- * clients own their handles — winloop NEVER calls CloseHandle on them. */
384
-
385
- static const char *op_state_name(int state) {
386
- switch (state) {
387
- case OP_PREPARED: return "prepared";
388
- case OP_SUBMITTED: return "submitted";
389
- default: return "completed";
390
- }
391
- }
392
-
393
- static winloop_op *op_lookup(winloop_backend *b, uint64_t id) {
394
- st_data_t val;
395
- if (!st_lookup(b->ops_by_id, (st_data_t)id, &val))
396
- rb_raise(eError, "winloop: unknown op id %llu", (unsigned long long)id);
397
- return (winloop_op *)val;
398
- }
399
-
400
- /* Remove an op from both tables and free it (the ONLY free path besides the
401
- * shutdown sweep). Callers must have established the kernel no longer owns it. */
402
- static void op_retire(winloop_backend *b, winloop_op *op) {
403
- st_data_t key = (st_data_t)op->id;
404
- st_delete(b->ops_by_id, &key, NULL);
405
- key = (st_data_t)(uintptr_t)&op->ov;
406
- st_delete(b->ops_by_addr, &key, NULL);
407
- b->op_bytes -= OP_BUF_OFFSET + op->cap;
408
- xfree(op);
409
- }
410
-
411
- /* Associate `handle` (opened FILE_FLAG_OVERLAPPED) with the backend's IOCP under
412
- * EXT_KEY. Permanent for the handle's lifetime (Win32: one port per handle, no
413
- * disassociation); the caller keeps ownership. */
414
- static VALUE backend_associate(VALUE self, VALUE vhandle) {
415
- winloop_backend *b = get_backend_ops(self);
416
- uint64_t hv = NUM2ULL(vhandle); /* coercion first: a raise here owns nothing */
417
- DWORD gle;
418
- if (hv == 0 || hv == UINT64_MAX)
419
- rb_raise(rb_eArgError, "winloop: %llu is not a usable HANDLE value",
420
- (unsigned long long)hv);
421
- if (!CreateIoCompletionPort((HANDLE)(uintptr_t)hv, b->iocp, EXT_KEY, 0)) {
422
- gle = GetLastError(); /* captured before any Ruby allocation */
423
- rb_raise(eError, "winloop: could not associate handle %llu with the completion "
424
- "port (error %lu: already associated with a completion port, or not "
425
- "opened with FILE_FLAG_OVERLAPPED)", (unsigned long long)hv, gle);
426
- }
427
- st_insert(b->assoc_handles, (st_data_t)hv, (st_data_t)1);
428
- return Qtrue;
429
- }
430
-
431
- /* Private primitive behind Winloop::Backend#op_prepare (lib/winloop/ops.rb owns
432
- * the kwargs + range validation). Allocates one op record: zeroed OVERLAPPED +
433
- * tag + optional embedded buffer; returns [op_id, ov_addr, buf_addr]. */
434
- static VALUE backend__op_prepare(VALUE self, VALUE vhandle, VALUE vtag, VALUE vcap) {
435
- winloop_backend *b = get_backend_ops(self);
436
- /* All coercion before any allocation (raise hygiene). */
437
- uint64_t hv = NUM2ULL(vhandle);
438
- uint64_t tag = NUM2ULL(vtag);
439
- uint64_t cap = NUM2ULL(vcap);
440
- size_t total;
441
- winloop_op *op;
442
- if (cap > 16ull * 1024 * 1024) /* defensive twin of OP_CAPACITY_MAX (send bypass) */
443
- rb_raise(rb_eArgError, "winloop: capacity %llu exceeds OP_CAPACITY_MAX",
444
- (unsigned long long)cap);
445
- /* The anti-hang check (§3.4 rule 8): an op on a handle that is not on the
446
- * port never completes — not an error, a silent loop freeze. Honestly: a
447
- * handle that was associated, closed and recycled to the same value passes. */
448
- if (!st_lookup(b->assoc_handles, (st_data_t)hv, NULL))
449
- rb_raise(eError, "winloop: handle %llu was never associated with this backend "
450
- "— call #associate first", (unsigned long long)hv);
451
-
452
- total = OP_BUF_OFFSET + (size_t)cap;
453
- op = (winloop_op *)xmalloc(total); /* a raise above this line leaks nothing */
454
- memset(op, 0, total); /* zeroed OVERLAPPED (offset 0 reads); buffer never leaks heap */
455
- op->id = ++b->next_id;
456
- op->tag = tag;
457
- op->handle = (HANDLE)(uintptr_t)hv;
458
- op->state = OP_PREPARED;
459
- op->cap = (size_t)cap;
460
- st_insert(b->ops_by_id, (st_data_t)op->id, (st_data_t)op);
461
- /* An OOM longjmp from this second insert leaks one record until the shutdown
462
- * sweep — the identical, accepted exposure as backend_poll's st_insert. */
463
- st_insert(b->ops_by_addr, (st_data_t)(uintptr_t)&op->ov, (st_data_t)op);
464
- b->op_bytes += total;
465
- return rb_ary_new_from_args(3, ULL2NUM(op->id), ULL2NUM((uintptr_t)&op->ov),
466
- cap ? ULL2NUM((uintptr_t)((char *)op + OP_BUF_OFFSET))
467
- : ULL2NUM(0));
468
- }
469
-
470
- /* Call after the client's native call returned success OR ERROR_IO_PENDING —
471
- * both queue exactly one completion packet (no skip-on-success, ever). */
472
- static VALUE backend_op_submitted(VALUE self, VALUE vid) {
473
- winloop_backend *b = get_backend_ops(self);
474
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
475
- if (op->state != OP_PREPARED)
476
- rb_raise(eError, "winloop: op %llu is %s; op_submitted is only legal on a "
477
- "prepared op", (unsigned long long)op->id, op_state_name(op->state));
478
- op->state = OP_SUBMITTED;
479
- return Qtrue;
480
- }
481
-
482
- /* For a native call that failed SYNCHRONOUSLY (GetLastError != ERROR_IO_PENDING):
483
- * the kernel never saw the op, no packet will ever arrive — free immediately. */
484
- static VALUE backend_op_abandon(VALUE self, VALUE vid) {
485
- winloop_backend *b = get_backend_ops(self);
486
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
487
- if (op->state == OP_SUBMITTED)
488
- rb_raise(eError, "winloop: op %llu is submitted; a submitted op is retired by "
489
- "its completion, not op_abandon", (unsigned long long)op->id);
490
- if (op->state == OP_COMPLETED)
491
- rb_raise(eError, "winloop: op %llu is completed; retire it with op_result or "
492
- "op_free, not op_abandon", (unsigned long long)op->id);
493
- op_retire(b, op);
494
- return Qtrue;
495
- }
496
-
497
- /* CancelIoEx(handle, ov) — targeted, cross-thread-safe. NEVER frees: a cancelled
498
- * op still posts a packet (usually error 995) which #wait reaps. Returns true if
499
- * a cancel was issued; false on ERROR_NOT_FOUND (already completed — benign) or
500
- * ANY other CancelIoEx failure (rb_warn'ed, never raised: await_op's ensure path
501
- * must never mask an in-flight Timeout/Fiber#raise unwind with a Winloop::Error). */
502
- static VALUE backend_op_cancel(VALUE self, VALUE vid) {
503
- winloop_backend *b = get_backend_ops(self);
504
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
505
- DWORD gle;
506
- if (op->state == OP_PREPARED)
507
- rb_raise(eError, "winloop: op %llu is prepared; only a submitted op can be "
508
- "cancelled (a prepared op is retired with op_abandon)",
509
- (unsigned long long)op->id);
510
- if (CancelIoEx(op->handle, &op->ov)) return Qtrue;
511
- gle = GetLastError(); /* captured immediately, before any Ruby call */
512
- if (gle != ERROR_NOT_FOUND)
513
- rb_warn("winloop: CancelIoEx for op %llu failed (error %lu) — treated as "
514
- "no-cancel, never an exception", (unsigned long long)op->id, gle);
515
- return Qfalse;
516
- }
517
-
518
- /* [bytes, error, data] for a COMPLETED op; frees the record. The String is built
519
- * BEFORE the free so an OOM raise leaves the op intact and retryable. `data` is
520
- * clamped to min(bytes, capacity): a stray packet's bogus byte count cannot make
521
- * winloop read past its own buffer. */
522
- static VALUE backend_op_result(VALUE self, VALUE vid) {
523
- winloop_backend *b = get_backend_ops(self);
524
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
525
- VALUE data = Qnil, result;
526
- if (op->state != OP_COMPLETED)
527
- rb_raise(eError, "winloop: op %llu is %s; op_result is only legal once its "
528
- "completion has been reaped by #wait",
529
- (unsigned long long)op->id, op_state_name(op->state));
530
- if (op->cap) {
531
- size_t n = (size_t)op->bytes < op->cap ? (size_t)op->bytes : op->cap;
532
- data = rb_str_new((const char *)op + OP_BUF_OFFSET, (long)n); /* ASCII-8BIT */
533
- }
534
- result = rb_ary_new_from_args(3, ULONG2NUM(op->bytes), ULONG2NUM(op->error), data);
535
- op_retire(b, op);
536
- return result;
537
- }
538
-
539
- /* Retire a COMPLETED op without materializing its data (orphaned completions). */
540
- static VALUE backend_op_free(VALUE self, VALUE vid) {
541
- winloop_backend *b = get_backend_ops(self);
542
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
543
- if (op->state != OP_COMPLETED)
544
- rb_raise(eError, "winloop: op %llu is %s; op_free is only legal once its "
545
- "completion has been reaped by #wait",
546
- (unsigned long long)op->id, op_state_name(op->state));
547
- op_retire(b, op);
548
- return Qtrue;
549
- }
550
-
551
- static VALUE backend_op_state(VALUE self, VALUE vid) {
552
- winloop_backend *b = get_backend_ops(self);
553
- winloop_op *op = op_lookup(b, NUM2ULL(vid));
554
- switch (op->state) {
555
- case OP_PREPARED: return ID2SYM(rb_intern("prepared"));
556
- case OP_SUBMITTED: return ID2SYM(rb_intern("submitted"));
557
- default: return ID2SYM(rb_intern("completed"));
558
- }
559
- }
560
-
561
- /* The raw IOCP HANDLE value — read-only, for tests and diagnostics. Hard rules:
562
- * never wait on it, never close it, never associate handles behind winloop's
563
- * back, never post after shutdown, at most ONE post per op. */
564
- static VALUE backend_port_handle(VALUE self) {
565
- winloop_backend *b = get_backend_ops(self);
566
- return ULL2NUM((uintptr_t)b->iocp);
567
- }
568
-
569
- /* Reap completions in one GetQueuedCompletionStatusEx. Returns an Array of
570
- [id, ready_events]; wakeup packets are skipped. Empty array on timeout. */
571
- /* The blocking GQCSEx call, run WITHOUT the GVL so other threads (and the very
572
- producer that will call #unblock) can run while the loop sleeps on the port. */
573
- typedef struct {
574
- HANDLE iocp;
575
- OVERLAPPED_ENTRY *ents;
576
- ULONG cap, n;
577
- DWORD ms, err;
578
- BOOL ok;
579
- } gqcs_args;
580
-
581
- static void *gqcs_call(void *p) {
582
- gqcs_args *a = (gqcs_args *)p;
583
- a->ok = GetQueuedCompletionStatusEx(a->iocp, a->ents, a->cap, &a->n, a->ms, FALSE);
584
- a->err = a->ok ? 0 : GetLastError();
585
- return NULL;
586
- }
587
-
588
- /* Ruby's interrupt path (signals, Thread#kill): wake the GQCSEx so the loop
589
- thread can return to Ruby and check interrupts. */
590
- static void gqcs_ubf(void *p) {
591
- PostQueuedCompletionStatus((HANDLE)p, 0, WAKE_KEY, NULL);
592
- }
593
-
594
- static VALUE backend_wait(VALUE self, VALUE vtimeout_ms) {
595
- winloop_backend *b = get_backend(self);
596
- DWORD ms = NIL_P(vtimeout_ms) ? INFINITE : (DWORD)NUM2ULONG(vtimeout_ms);
597
- OVERLAPPED_ENTRY ents[REAP_CAP];
598
-
599
- gqcs_args a = { b->iocp, ents, REAP_CAP, 0, ms, 0, FALSE };
600
- rb_thread_call_without_gvl(gqcs_call, &a, gqcs_ubf, b->iocp);
601
- BOOL ok = a.ok;
602
- DWORD err = a.err;
603
- ULONG n = a.n;
604
-
605
- VALUE result = rb_ary_new();
606
- if (!ok) {
607
- if (err == WAIT_TIMEOUT) return result;
608
- rb_raise(eError, "winloop: GetQueuedCompletionStatusEx failed (%lu)", err);
609
- }
610
- for (ULONG i = 0; i < n; i++) {
611
- if (ents[i].lpCompletionKey == WAKE_KEY || ents[i].lpOverlapped == NULL) continue;
612
- if (ents[i].lpCompletionKey == EXT_KEY) {
613
- /* Generic-op path (winloop 0.2). Defensive dispatch: verify membership
614
- * in ops_by_addr AND op state before acting — an unknown lpOverlapped
615
- * is never cast, an already-COMPLETED op (double post) is never
616
- * re-completed and never yields a second tuple. The only accepted
617
- * anomaly is a packet for a still-PREPARED op (a standalone embedder
618
- * called #wait between the native submit and op_submitted): the packet
619
- * is real and the memory is ours, so accept it with a warning. */
620
- st_data_t val;
621
- winloop_op *op;
622
- if (!st_lookup(b->ops_by_addr, (st_data_t)(uintptr_t)ents[i].lpOverlapped, &val)) {
623
- rb_warn("winloop: dropped a completion packet for an unknown OVERLAPPED "
624
- "0x%llx (stray or duplicate post)",
625
- (unsigned long long)(uintptr_t)ents[i].lpOverlapped);
626
- continue;
627
- }
628
- op = (winloop_op *)val;
629
- if (op->state == OP_COMPLETED) {
630
- rb_warn("winloop: dropped a duplicate completion packet for op %llu "
631
- "(already completed; bytes/error preserved)",
632
- (unsigned long long)op->id);
633
- continue;
634
- }
635
- if (op->state == OP_PREPARED)
636
- rb_warn("winloop: completion packet reaped for op %llu before "
637
- "op_submitted (protocol violation; accepting the packet)",
638
- (unsigned long long)op->id);
639
- op->state = OP_COMPLETED;
640
- op->bytes = ents[i].dwNumberOfBytesTransferred;
641
- /* Status lives in the OVERLAPPED's Internal (an NTSTATUS), readable
642
- * only after the packet left the port; map it to a familiar Win32
643
- * code (0 ok, 995 cancelled, 1022 rescan, 38 EOF, ...). */
644
- op->error = pRtlNtStatusToDosError((NTSTATUS)op->ov.Internal);
645
- rb_ary_push(result, rb_ary_new_from_args(4, ULL2NUM(op->id),
646
- ULONG2NUM(op->bytes), ULONG2NUM(op->error), ULL2NUM(op->tag)));
647
- continue;
648
- }
649
- /* AFD path — byte-for-byte the pre-0.2 code (only the AFD handle carries
650
- * AFD_KEY on this port). */
651
- winloop_req *req = (winloop_req *)ents[i].lpOverlapped;
652
- st_data_t key = (st_data_t)req->id;
653
- st_delete(b->reqs, &key, NULL);
654
- int events = afd_to_ruby(req->out.Handles[0].Events);
655
- VALUE pair = rb_ary_new_from_args(2, ULL2NUM(req->id), INT2NUM(events));
656
- rb_ary_push(result, pair);
657
- xfree(req);
658
- }
659
- return result;
660
- }
661
-
662
- static VALUE backend_wakeup(VALUE self) {
663
- winloop_backend *b = get_backend(self);
664
- PostQueuedCompletionStatus(b->iocp, 0, WAKE_KEY, NULL);
665
- return Qnil;
666
- }
667
-
668
- static VALUE backend_shutdown(VALUE self) {
669
- winloop_backend *b;
670
- TypedData_Get_Struct(self, winloop_backend, &backend_type, b);
671
- if (b) backend_drain_and_close(b);
672
- return Qnil;
673
- }
674
-
675
- void Init_winloop(void) {
676
- WSADATA wsa;
677
- WSAStartup(MAKEWORD(2, 2), &wsa);
678
-
679
- mWinloop = rb_define_module("Winloop");
680
- cBackend = rb_define_class_under(mWinloop, "Backend", rb_cObject);
681
- eError = rb_define_class_under(mWinloop, "Error", rb_eStandardError);
682
-
683
- /* Export the Ruby IO event bits the scheduler/backend agree on. */
684
- rb_define_const(mWinloop, "READABLE", INT2NUM(RB_READABLE));
685
- rb_define_const(mWinloop, "WRITABLE", INT2NUM(RB_WRITABLE));
686
- rb_define_const(mWinloop, "PRIORITY", INT2NUM(RB_PRIORITY));
687
-
688
- /* The completion key winloop assigns to ALL generic ops on the port
689
- * (internal dispatch; exposed for tests and a possible future injection
690
- * protocol — PQCS injection is NOT a v1 protocol). */
691
- rb_define_const(mWinloop, "EXTERNAL_KEY", ULL2NUM(0x45585431ull));
692
-
693
- rb_define_alloc_func(cBackend, backend_alloc);
694
- rb_define_method(cBackend, "initialize", RUBY_METHOD_FUNC(backend_initialize), 0);
695
- rb_define_method(cBackend, "poll", RUBY_METHOD_FUNC(backend_poll), 2);
696
- rb_define_method(cBackend, "cancel", RUBY_METHOD_FUNC(backend_cancel), 1);
697
- rb_define_method(cBackend, "wait", RUBY_METHOD_FUNC(backend_wait), 1);
698
- rb_define_method(cBackend, "wakeup", RUBY_METHOD_FUNC(backend_wakeup), 0);
699
- rb_define_method(cBackend, "shutdown", RUBY_METHOD_FUNC(backend_shutdown), 0);
700
-
701
- /* Generic OVERLAPPED ops (winloop 0.2). `_op_prepare` is the private bridge
702
- * behind the validated Winloop::Backend#op_prepare in lib/winloop/ops.rb. */
703
- rb_define_method(cBackend, "associate", RUBY_METHOD_FUNC(backend_associate), 1);
704
- rb_define_method(cBackend, "_op_prepare", RUBY_METHOD_FUNC(backend__op_prepare), 3);
705
- rb_define_method(cBackend, "op_submitted", RUBY_METHOD_FUNC(backend_op_submitted), 1);
706
- rb_define_method(cBackend, "op_abandon", RUBY_METHOD_FUNC(backend_op_abandon), 1);
707
- rb_define_method(cBackend, "op_cancel", RUBY_METHOD_FUNC(backend_op_cancel), 1);
708
- rb_define_method(cBackend, "op_result", RUBY_METHOD_FUNC(backend_op_result), 1);
709
- rb_define_method(cBackend, "op_free", RUBY_METHOD_FUNC(backend_op_free), 1);
710
- rb_define_method(cBackend, "op_state", RUBY_METHOD_FUNC(backend_op_state), 1);
711
- rb_define_method(cBackend, "port_handle", RUBY_METHOD_FUNC(backend_port_handle), 0);
712
- }