winwatch 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.
@@ -0,0 +1,883 @@
1
+ /*
2
+ * winwatch — a ReadDirectoryChangesW directory watcher for Ruby.
3
+ *
4
+ * PURE C (no C++), so rb_raise/longjmp is the normal, safe error mechanism —
5
+ * the same discipline as winipc/winloop, and the opposite of the lithos /EHsc
6
+ * hazard. The directory handle, its OVERLAPPED, manual-reset event and the
7
+ * completion buffer live in a TypedData wrapper whose free hook tears them down,
8
+ * so a forgotten #close never leaks a HANDLE or a buffer. The one blocking call
9
+ * (WaitForSingleObject on the overlapped event) releases the GVL with a real
10
+ * per-op cancel unblock function (CancelIoEx on THIS op), so other Ruby threads
11
+ * run and Thread#kill / Ctrl-C / Timeout break an INFINITE wait.
12
+ *
13
+ * The watcher thread (the no-GVL wait) touches ONLY plain C state — never the
14
+ * Ruby C-API. Everything that allocates Ruby objects (the batch String, the
15
+ * parsed Event array, path joins) happens with the GVL held, after the wait has
16
+ * returned. The OVERLAPPED/buffer are only ever freed when no op is in flight
17
+ * (a reaped completion, or a cancel followed by a settling GetOverlappedResult).
18
+ *
19
+ * The kernel mirror buffer is sized at the FIRST ReadDirectoryChangesW and kept
20
+ * for the handle's lifetime; it accumulates changes between our calls, so the
21
+ * pull design is lossless across take() gaps (overflow becomes :rescan, never a
22
+ * silent drop). See plans/research/rdcw.md and plans/winwatch-spec.md.
23
+ *
24
+ * Links kernel32 only (linked by default): CreateFileW / ReadDirectoryChangesW /
25
+ * CancelIoEx / WaitForSingleObject / GetOverlappedResult / GetLongPathNameW.
26
+ */
27
+
28
+ #include <ruby.h>
29
+ #include <ruby/thread.h>
30
+ #include <ruby/encoding.h>
31
+
32
+ #define WIN32_LEAN_AND_MEAN
33
+ #include <windows.h>
34
+
35
+ #include <stdlib.h>
36
+ #include <string.h>
37
+
38
+ /* ------------------------------------------------------------------ globals */
39
+
40
+ static VALUE mWinwatch;
41
+ static VALUE cWatcher;
42
+ static VALUE eError, eOSError, eNotFound, eAccessDenied, eUnsupported,
43
+ eNotADirectory, eClosed;
44
+
45
+ /* FILE_ACTION_* values (winnt.h) — re-stated for clarity (rdcw §3). */
46
+ #ifndef FILE_ACTION_ADDED
47
+ # define FILE_ACTION_ADDED 0x00000001
48
+ # define FILE_ACTION_REMOVED 0x00000002
49
+ # define FILE_ACTION_MODIFIED 0x00000003
50
+ # define FILE_ACTION_RENAMED_OLD_NAME 0x00000004
51
+ # define FILE_ACTION_RENAMED_NEW_NAME 0x00000005
52
+ #endif
53
+
54
+ /* ------------------------------------------------------------ small helpers */
55
+
56
+ /* Build + raise a winwatch error carrying @code (the Win32 error). The code
57
+ * must be captured by the caller immediately after the failing syscall, and any
58
+ * OS allocation released BEFORE this is called (so an OOM longjmp from
59
+ * rb_sprintf / rb_exc_new_str cannot leak it). Verbatim winipc raise_code. */
60
+ static void
61
+ raise_code(VALUE klass, const char *api, DWORD code)
62
+ {
63
+ VALUE exc, msg;
64
+ WCHAR *buf = NULL;
65
+ char detail[512];
66
+ DWORD n;
67
+
68
+ detail[0] = 0;
69
+ n = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
70
+ FORMAT_MESSAGE_IGNORE_INSERTS, NULL, code,
71
+ MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPWSTR)&buf, 0, NULL);
72
+ if (n && buf) {
73
+ while (n && (buf[n - 1] == L'\r' || buf[n - 1] == L'\n' || buf[n - 1] == L'.'))
74
+ buf[--n] = 0;
75
+ WideCharToMultiByte(CP_UTF8, 0, buf, -1, detail, (int)sizeof(detail), NULL, NULL);
76
+ detail[sizeof(detail) - 1] = 0;
77
+ }
78
+ if (buf) LocalFree(buf);
79
+
80
+ if (detail[0])
81
+ msg = rb_sprintf("%s: %s (error %lu)", api, detail, (unsigned long)code);
82
+ else
83
+ msg = rb_sprintf("%s failed (error %lu)", api, (unsigned long)code);
84
+ exc = rb_exc_new_str(klass, msg);
85
+ rb_iv_set(exc, "@code", ULONG2NUM(code));
86
+ rb_exc_raise(exc);
87
+ }
88
+
89
+ /* Map an open-time / first-arm Win32 error to the right winwatch subclass and
90
+ * raise (§3.7 / §5.0 open-time mapping). */
91
+ static void
92
+ raise_gle(const char *api, DWORD code)
93
+ {
94
+ VALUE klass = eOSError;
95
+ switch (code) {
96
+ case ERROR_FILE_NOT_FOUND: /* 2 */
97
+ case ERROR_PATH_NOT_FOUND: /* 3 */ klass = eNotFound; break;
98
+ case ERROR_ACCESS_DENIED: /* 5 */ klass = eAccessDenied; break;
99
+ case ERROR_INVALID_FUNCTION:/* 1 */ klass = eUnsupported; break;
100
+ default: break;
101
+ }
102
+ raise_code(klass, api, code);
103
+ }
104
+
105
+ /* =====================================================================
106
+ * Watcher — Winwatch::Watcher
107
+ * ===================================================================== */
108
+
109
+ typedef struct {
110
+ HANDLE dir; /* CreateFileW directory handle; INVALID_HANDLE_VALUE sentinel */
111
+ HANDLE ev; /* manual-reset event = ov.hEvent (standalone; NULL in :winloop) */
112
+ OVERLAPPED ov; /* the single in-flight RDCW (standalone mode) */
113
+ unsigned char *buf; /* malloc'd completion buffer, buf_len bytes (standalone mode) */
114
+ DWORD buf_len;
115
+ DWORD filter; /* dwNotifyFilter, precomputed in Ruby */
116
+ BOOL recursive;
117
+ int external; /* 1 = :winloop mode (no ev/buf/ov of our own) */
118
+ int armed; /* an RDCW we issued on &ov is in flight (standalone) */
119
+ int in_take; /* threads inside _take's wait path; touched only with the GVL */
120
+ /* held (inc before release, dec after reacquire), so close */
121
+ /* reads it race-free — deferred teardown (§3.5 rule 3) */
122
+ int closed;
123
+ /* interrupt-race stash (§5.12): a completed batch drained during an interrupt */
124
+ unsigned char *stash; DWORD stash_len; DWORD stash_code;
125
+ } watch_t;
126
+
127
+ /* Free buf/stash and zero the pointers (idempotent). */
128
+ static void
129
+ watch_free_bufs(watch_t *w)
130
+ {
131
+ if (w->buf) { free(w->buf); w->buf = NULL; }
132
+ if (w->stash) { free(w->stash); w->stash = NULL; }
133
+ w->stash_len = 0;
134
+ w->stash_code = 0;
135
+ }
136
+
137
+ /* The teardown tail: settle a cancelled op, close handles, free buffers.
138
+ * Runs from close (when no taker is mid-wait), from the last leaver of _take,
139
+ * and from the GC free hook. Idempotent and tolerant of never-initialized
140
+ * sentinels. NO Ruby C-API here (it also runs in the free hook). */
141
+ static void
142
+ watch_teardown(watch_t *w)
143
+ {
144
+ /* Settle a cancelled/in-flight op before freeing its OVERLAPPED + buffer:
145
+ * completions for cancelled ops still arrive and freeing early is
146
+ * use-after-free (rdcw §13). Prompt by construction: a cancel was already
147
+ * issued (close/free path) or the op completed normally. Standalone only —
148
+ * in :winloop mode winloop owns the OVERLAPPED/buffer (wlx §3.4 rule 1). */
149
+ if (!w->external && w->armed && w->dir != INVALID_HANDLE_VALUE) {
150
+ DWORD n = 0;
151
+ GetOverlappedResult(w->dir, &w->ov, &n, TRUE);
152
+ w->armed = 0;
153
+ }
154
+ if (w->ev) { CloseHandle(w->ev); w->ev = NULL; }
155
+ if (w->dir != INVALID_HANDLE_VALUE) { CloseHandle(w->dir); w->dir = INVALID_HANDLE_VALUE; }
156
+ watch_free_bufs(w);
157
+ }
158
+
159
+ static void
160
+ watch_dfree(void *p)
161
+ {
162
+ watch_t *w = (watch_t *)p;
163
+ /* GC implies no thread is inside _take. Run the identical teardown the API
164
+ * uses (cancel already issued by close, or cancel now if still armed). */
165
+ if (!w->external && w->armed && w->dir != INVALID_HANDLE_VALUE)
166
+ CancelIoEx(w->dir, &w->ov);
167
+ watch_teardown(w);
168
+ xfree(w);
169
+ }
170
+
171
+ static size_t
172
+ watch_dsize(const void *p)
173
+ {
174
+ const watch_t *w = (const watch_t *)p;
175
+ return sizeof(watch_t) + (size_t)w->buf_len + (size_t)w->stash_len;
176
+ }
177
+
178
+ static const rb_data_type_t watch_type = {
179
+ "Winwatch::Watcher",
180
+ { 0, watch_dfree, watch_dsize, },
181
+ 0, 0, RUBY_TYPED_FREE_IMMEDIATELY,
182
+ };
183
+
184
+ static VALUE
185
+ watch_alloc(VALUE klass)
186
+ {
187
+ watch_t *w;
188
+ VALUE obj = TypedData_Make_Struct(klass, watch_t, &watch_type, w);
189
+ w->dir = INVALID_HANDLE_VALUE;
190
+ w->ev = NULL;
191
+ memset(&w->ov, 0, sizeof(w->ov));
192
+ w->buf = NULL;
193
+ w->buf_len = 0;
194
+ w->filter = 0;
195
+ w->recursive = FALSE;
196
+ w->external = 0;
197
+ w->armed = 0;
198
+ w->in_take = 0;
199
+ w->closed = 0;
200
+ w->stash = NULL;
201
+ w->stash_len = 0;
202
+ w->stash_code = 0;
203
+ return obj;
204
+ }
205
+
206
+ /* Raw getter (used by closed?/close — must work on closed objects). */
207
+ static watch_t *
208
+ watch_get(VALUE self)
209
+ {
210
+ watch_t *w;
211
+ TypedData_Get_Struct(self, watch_t, &watch_type, w);
212
+ return w;
213
+ }
214
+
215
+ /* Live getter — raises Winwatch::Closed when closed. */
216
+ static watch_t *
217
+ watch_live(VALUE self)
218
+ {
219
+ watch_t *w = watch_get(self);
220
+ if (w->closed || w->dir == INVALID_HANDLE_VALUE)
221
+ rb_raise(eClosed, "winwatch: watcher is closed");
222
+ return w;
223
+ }
224
+
225
+ /* --- UTF-8 path -> extended-length wide path ready for CreateFileW ---------
226
+ *
227
+ * Flips '/'->'\\' and prepends \\?\ (or \\?\UNC\ for \\server\share), so long
228
+ * roots survive (rdcw §2). Returns a freshly malloc'd NUL-terminated WCHAR*;
229
+ * caller frees with free(). Raises on invalid UTF-8 (no OS state held yet). */
230
+ static WCHAR *
231
+ to_ext_wide(VALUE str)
232
+ {
233
+ int len, n, i;
234
+ WCHAR *raw, *out;
235
+ size_t prefix, total;
236
+ const WCHAR *pfx;
237
+ int is_unc;
238
+
239
+ StringValue(str);
240
+ len = (int)RSTRING_LEN(str);
241
+ if (len == 0) rb_raise(rb_eArgError, "winwatch: path must not be empty");
242
+
243
+ n = MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(str), len, NULL, 0);
244
+ if (n <= 0) rb_raise(eError, "winwatch: invalid UTF-8 in path");
245
+ raw = (WCHAR *)malloc(sizeof(WCHAR) * ((size_t)n + 1));
246
+ if (!raw) rb_raise(rb_eNoMemError, "winwatch: out of memory");
247
+ MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(str), len, raw, n);
248
+ raw[n] = 0;
249
+
250
+ /* normalize separators to backslash */
251
+ for (i = 0; i < n; i++) if (raw[i] == L'/') raw[i] = L'\\';
252
+
253
+ /* UNC if it starts with two backslashes (\\server\share). */
254
+ is_unc = (n >= 2 && raw[0] == L'\\' && raw[1] == L'\\');
255
+ if (is_unc) { pfx = L"\\\\?\\UNC\\"; prefix = 8; }
256
+ else { pfx = L"\\\\?\\"; prefix = 4; }
257
+
258
+ /* For UNC we skip the leading "\\" of the raw path (the prefix supplies it). */
259
+ {
260
+ int skip = is_unc ? 2 : 0;
261
+ size_t body = (size_t)(n - skip);
262
+ total = prefix + body + 1;
263
+ out = (WCHAR *)malloc(sizeof(WCHAR) * total);
264
+ if (!out) { free(raw); rb_raise(rb_eNoMemError, "winwatch: out of memory"); }
265
+ memcpy(out, pfx, prefix * sizeof(WCHAR));
266
+ memcpy(out + prefix, raw + skip, body * sizeof(WCHAR));
267
+ out[prefix + body] = 0;
268
+ }
269
+ free(raw);
270
+ return out;
271
+ }
272
+
273
+ /* Issue one ReadDirectoryChangesW on &w->ov. GVL held; the call only queues.
274
+ * Returns 0 on success (op queued), else GetLastError, captured immediately. */
275
+ static DWORD
276
+ watch_arm(watch_t *w)
277
+ {
278
+ BOOL ok;
279
+ DWORD gle;
280
+
281
+ ResetEvent(w->ev);
282
+ memset(&w->ov, 0, sizeof(w->ov));
283
+ w->ov.hEvent = w->ev;
284
+ ok = ReadDirectoryChangesW(w->dir, w->buf, w->buf_len, w->recursive,
285
+ w->filter, NULL, &w->ov, NULL);
286
+ if (ok) { w->armed = 1; return 0; }
287
+ gle = GetLastError();
288
+ /* FALSE + ERROR_IO_PENDING still queues the op (defensive; not expected for
289
+ * RDCW, which returns TRUE on a successful queue). */
290
+ if (gle == ERROR_IO_PENDING) { w->armed = 1; return 0; }
291
+ w->armed = 0;
292
+ return gle;
293
+ }
294
+
295
+ /*
296
+ * Watcher._open(abs_path_utf8, filter_dword, recursive_bool, buffer_size, external_bool)
297
+ * -> Watcher.
298
+ * external_bool=true (:winloop mode) skips event/buffer creation and the first
299
+ * arm (the pump issues against winloop-owned memory). Captures GetLastError
300
+ * immediately, frees the wide path, then raises (the handle is stored into the
301
+ * struct before anything that can raise, so the free hook owns it).
302
+ */
303
+ static VALUE
304
+ watch_open(VALUE klass, VALUE vpath, VALUE vfilter, VALUE vrec, VALUE vbuf, VALUE vext)
305
+ {
306
+ VALUE obj = watch_alloc(cWatcher);
307
+ watch_t *w = watch_get(obj);
308
+ WCHAR *wpath;
309
+ DWORD bufsz = NUM2ULONG(vbuf);
310
+ DWORD gle;
311
+ BY_HANDLE_FILE_INFORMATION fi;
312
+
313
+ /* Range-assert again in C (§5.18). Round up to a DWORD multiple of 4. */
314
+ if (bufsz < 4096 || bufsz > 65536)
315
+ rb_raise(rb_eArgError, "winwatch: buffer_size out of range");
316
+ bufsz = (bufsz + 3u) & ~3u;
317
+
318
+ w->filter = NUM2ULONG(vfilter);
319
+ w->recursive = RTEST(vrec) ? TRUE : FALSE;
320
+ w->external = RTEST(vext) ? 1 : 0;
321
+ w->buf_len = bufsz;
322
+
323
+ wpath = to_ext_wide(vpath); /* malloc'd; freed below before any raise */
324
+
325
+ /* Share-all (READ|WRITE|DELETE) is load-bearing (§5.6): omitting
326
+ * FILE_SHARE_DELETE blocks delete/rename of the root for other processes. */
327
+ w->dir = CreateFileW(wpath, FILE_LIST_DIRECTORY,
328
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
329
+ NULL, OPEN_EXISTING,
330
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, NULL);
331
+ gle = GetLastError();
332
+ free(wpath);
333
+ if (w->dir == INVALID_HANDLE_VALUE)
334
+ raise_gle("CreateFile", gle); /* 2/3 NotFound, 5 AccessDenied, else OSError */
335
+
336
+ /* It must be a directory (misuse, not OS). */
337
+ if (!GetFileInformationByHandle(w->dir, &fi)) {
338
+ gle = GetLastError();
339
+ raise_gle("GetFileInformationByHandle", gle);
340
+ }
341
+ if (!(fi.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY))
342
+ rb_raise(eNotADirectory, "winwatch: not a directory");
343
+
344
+ if (!w->external) {
345
+ w->ev = CreateEventW(NULL, TRUE, FALSE, NULL); /* manual-reset */
346
+ if (!w->ev) raise_gle("CreateEvent", GetLastError());
347
+ w->buf = (unsigned char *)malloc(w->buf_len);
348
+ if (!w->buf) rb_raise(rb_eNoMemError, "winwatch: out of memory");
349
+
350
+ /* First arm in the constructor: coverage starts before watch() returns
351
+ * (rdcw §4/§14 re-arm discipline). A sync failure maps per §5.0. */
352
+ gle = watch_arm(w);
353
+ if (gle != 0)
354
+ raise_gle("ReadDirectoryChangesW", gle); /* 1 Unsupported, 2/3 NotFound, 5 AccessDenied */
355
+ }
356
+ return obj;
357
+ }
358
+
359
+ /* Watcher#_arm -> Integer (0 = queued, else GetLastError). standalone only. */
360
+ static VALUE
361
+ watch_m_arm(VALUE self)
362
+ {
363
+ watch_t *w = watch_live(self);
364
+ return ULONG2NUM(watch_arm(w));
365
+ }
366
+
367
+ /* --- GVL-released overlapped wait, cancelable via CancelIoEx (winipc) ------ */
368
+
369
+ typedef struct {
370
+ watch_t *w;
371
+ DWORD ms;
372
+ DWORD wait;
373
+ DWORD gle;
374
+ } take_wait_t;
375
+
376
+ static void *
377
+ take_wait_fn(void *p)
378
+ {
379
+ take_wait_t *t = (take_wait_t *)p;
380
+ t->wait = WaitForSingleObject(t->w->ev, t->ms);
381
+ if (t->wait == WAIT_FAILED) t->gle = GetLastError(); /* capture before GVL */
382
+ return NULL;
383
+ }
384
+
385
+ static void
386
+ take_wait_ubf(void *p)
387
+ {
388
+ take_wait_t *t = (take_wait_t *)p;
389
+ /* Cancel THIS specific op (winipc per-op cancel) so Thread#kill / Ctrl-C /
390
+ * Timeout break an INFINITE wait. No-op when closed: close already
391
+ * cancelled and may have torn down; nothing left to cancel and the dir
392
+ * handle must not be touched (it could be closed/recycled). */
393
+ if (!t->w->closed && t->w->dir != INVALID_HANDLE_VALUE)
394
+ CancelIoEx(t->w->dir, &t->w->ov);
395
+ }
396
+
397
+ /*
398
+ * Watcher#_take(ms) -> nil | [data(String|nil), code(Int), rearm_code(Int)].
399
+ * standalone only.
400
+ * - nil : timeout (no completion within ms)
401
+ * - [data, code, rc] : a completion (code = Win32 code, rc = re-arm result)
402
+ * data is a binary String (the raw batch) when bytes>0, else nil.
403
+ * - returns the symbol :closed via a distinct shape: we return Qnil only on
404
+ * timeout, and raise nothing here for close — the Ruby layer detects close
405
+ * by re-checking closed? after _take returns nil... To keep it unambiguous
406
+ * we instead signal close by returning the Ruby symbol :closed.
407
+ */
408
+ static VALUE
409
+ watch_take(VALUE self, VALUE vms)
410
+ {
411
+ watch_t *w = watch_live(self);
412
+ /* Widen the conversion to 64-bit so a large-but-legitimate finite timeout
413
+ * (ms > LONG_MAX on LLP64 where C long is 32-bit) does NOT raise RangeError
414
+ * — the public API documents timeout: as non-negative Numeric seconds that
415
+ * waits, and RangeError is not in the Raises list (§2.5). Negative => INFINITE
416
+ * (the nil/forever spelling). Any value past the WaitForSingleObject finite
417
+ * domain (0..0xFFFFFFFE; 0xFFFFFFFF is the INFINITE sentinel) clamps to the
418
+ * max finite wait 0xFFFFFFFE (~49.7 days) rather than accidentally meaning
419
+ * INFINITE. */
420
+ long long ms_in = NUM2LL(vms);
421
+ DWORD ms;
422
+ take_wait_t t;
423
+ DWORD bytes = 0, code = 0, rearm = 0;
424
+ VALUE data = Qnil;
425
+ BOOL got;
426
+
427
+ if (ms_in < 0)
428
+ ms = INFINITE;
429
+ else if (ms_in > 0xFFFFFFFELL)
430
+ ms = 0xFFFFFFFEu; /* clamp to the max finite WaitForSingleObject wait */
431
+ else
432
+ ms = (DWORD)ms_in;
433
+
434
+ /* Serve a stashed batch first (interrupt-race losslessness, §5.12). */
435
+ if (w->stash) {
436
+ VALUE out;
437
+ if (w->stash_len > 0) {
438
+ data = rb_str_new((const char *)w->stash, (long)w->stash_len);
439
+ rb_enc_associate(data, rb_ascii8bit_encoding());
440
+ }
441
+ code = w->stash_code;
442
+ free(w->stash);
443
+ w->stash = NULL;
444
+ w->stash_len = 0;
445
+ w->stash_code = 0;
446
+ out = rb_ary_new_capa(3);
447
+ rb_ary_push(out, data);
448
+ rb_ary_push(out, ULONG2NUM(code));
449
+ rb_ary_push(out, ULONG2NUM(0)); /* re-arm already done when we stashed */
450
+ return out;
451
+ }
452
+
453
+ /* If no op is armed (e.g. a prior re-arm failed and Ruby asked us to wait),
454
+ * try to re-arm right here and report the result as the rearm code, so a
455
+ * successful re-arm restores the watch and a real failure is classified
456
+ * (never an unarmed op that returns [nil,995,0] forever — the busy-livelock
457
+ * fixed here). On a successful re-arm we still report a synthetic abort with
458
+ * rearm==0 (no event, op re-armed, keep waiting); on a failed re-arm we
459
+ * report the synthetic abort WITH the live rearm code so classify can turn a
460
+ * permanent failure into a terminal :gone (or :rescan for 1022). */
461
+ if (!w->armed) {
462
+ DWORD rc = watch_arm(w);
463
+ VALUE out = rb_ary_new_capa(3);
464
+ rb_ary_push(out, Qnil);
465
+ rb_ary_push(out, ULONG2NUM(ERROR_OPERATION_ABORTED));
466
+ rb_ary_push(out, ULONG2NUM(rc));
467
+ return out;
468
+ }
469
+
470
+ t.w = w; t.ms = ms; t.wait = WAIT_FAILED; t.gle = 0;
471
+
472
+ w->in_take++; /* under the GVL, before release (§3.5 rule 3) */
473
+ rb_thread_call_without_gvl(take_wait_fn, &t, take_wait_ubf, &t);
474
+ w->in_take--; /* under the GVL, after reacquire */
475
+
476
+ /* Closed-first check on every GVL reacquisition (§3.4). A concurrent close
477
+ * ran: touch no dir/ov/buf. If we are the last leaver, perform the deferred
478
+ * teardown tail (§3.5 rule 3). The Ruby layer raises Winwatch::Closed. */
479
+ if (w->closed) {
480
+ if (w->in_take == 0)
481
+ watch_teardown(w);
482
+ return ID2SYM(rb_intern("closed"));
483
+ }
484
+
485
+ if (t.wait == WAIT_TIMEOUT) {
486
+ /* No completion within ms. But an interrupt (Thread#kill / Timeout)
487
+ * may be pending — its ubf fired CancelIoEx, which can complete the op
488
+ * with abort (or a real batch that lost the race). Settle the op into
489
+ * the stash, re-arm, then deliver the interrupt. If no interrupt is
490
+ * pending, the (abort) result is discarded and we return nil. */
491
+ if (!w->armed) return Qnil; /* defensive */
492
+ /* Probe without blocking: did the op complete (cancel or data)? */
493
+ got = GetOverlappedResult(w->dir, &w->ov, &bytes, FALSE);
494
+ if (!got && GetLastError() == ERROR_IO_INCOMPLETE)
495
+ return Qnil; /* genuinely still pending — a real timeout */
496
+ /* fall through to settle+stash below */
497
+ } else if (t.wait != WAIT_OBJECT_0) {
498
+ /* WAIT_FAILED (rare): drain so the op is settled, then report. */
499
+ DWORD n = 0;
500
+ CancelIoEx(w->dir, &w->ov);
501
+ GetOverlappedResult(w->dir, &w->ov, &n, TRUE);
502
+ w->armed = 0;
503
+ rearm = watch_arm(w);
504
+ {
505
+ VALUE out = rb_ary_new_capa(3);
506
+ rb_ary_push(out, Qnil);
507
+ rb_ary_push(out, ULONG2NUM(t.gle ? t.gle : ERROR_OPERATION_ABORTED));
508
+ rb_ary_push(out, ULONG2NUM(rearm));
509
+ return out;
510
+ }
511
+ } else {
512
+ /* Signaled: reap the completion (bWait=FALSE, already signaled). */
513
+ got = GetOverlappedResult(w->dir, &w->ov, &bytes, FALSE);
514
+ }
515
+
516
+ /* Settle the reaped op into local code/data. */
517
+ w->armed = 0;
518
+ if (got) {
519
+ code = 0;
520
+ if (bytes > 0) {
521
+ data = rb_str_new((const char *)w->buf, (long)bytes);
522
+ rb_enc_associate(data, rb_ascii8bit_encoding());
523
+ }
524
+ } else {
525
+ code = GetLastError(); /* e.g. 995 (aborted by our cancel), 5, 1022, ... */
526
+ }
527
+
528
+ /* Re-arm BEFORE returning to Ruby for parsing (rdcw §14), minimizing the
529
+ * no-op-outstanding window. */
530
+ rearm = watch_arm(w);
531
+
532
+ /* Interrupt-stash discipline (§5.12): copy the settled batch into the
533
+ * struct, then deliver any pending interrupt. If Thread#kill / Timeout is
534
+ * pending, rb_thread_check_ints() raises here — the batch is safely stashed
535
+ * and the NEXT take returns it (lossless). If nothing is pending we pull the
536
+ * stash back out and return it normally. The raise crosses no live kernel
537
+ * ownership: the op is already re-armed (or recorded un-armed). */
538
+ if (data != Qnil || code != 0) {
539
+ long dlen = (data == Qnil) ? 0 : RSTRING_LEN(data);
540
+ /* No prior stash here (we returned early at the top if one existed). */
541
+ if (dlen > 0) {
542
+ w->stash = (unsigned char *)malloc((size_t)dlen);
543
+ if (w->stash) {
544
+ memcpy(w->stash, RSTRING_PTR(data), (size_t)dlen);
545
+ w->stash_len = (DWORD)dlen;
546
+ } else {
547
+ w->stash_len = 0;
548
+ }
549
+ } else {
550
+ w->stash = NULL;
551
+ w->stash_len = 0;
552
+ }
553
+ w->stash_code = code;
554
+
555
+ rb_thread_check_ints(); /* may raise (Thread#kill / Timeout) — batch stashed */
556
+
557
+ /* No interrupt: hand the stash back to this caller. */
558
+ {
559
+ VALUE out;
560
+ VALUE sdata = Qnil;
561
+ if (w->stash_len > 0) {
562
+ sdata = rb_str_new((const char *)w->stash, (long)w->stash_len);
563
+ rb_enc_associate(sdata, rb_ascii8bit_encoding());
564
+ }
565
+ code = w->stash_code;
566
+ if (w->stash) { free(w->stash); w->stash = NULL; }
567
+ w->stash_len = 0;
568
+ w->stash_code = 0;
569
+ out = rb_ary_new_capa(3);
570
+ rb_ary_push(out, sdata);
571
+ rb_ary_push(out, ULONG2NUM(code));
572
+ rb_ary_push(out, ULONG2NUM(rearm));
573
+ return out;
574
+ }
575
+ }
576
+
577
+ /* data==nil AND code==0: a zero-byte successful completion = kernel buffer
578
+ * overflow (the success spelling, §5.1). The op is re-armed; deliver any
579
+ * pending interrupt, then return [nil, 0, rearm] so Ruby classifies it as
580
+ * :rescan. (Overflow already means "re-enumerate the tree", so the small
581
+ * chance of an interrupt dropping this one rescan signal is harmless — the
582
+ * re-armed op re-signals if still overflowing.) */
583
+ rb_thread_check_ints();
584
+ {
585
+ VALUE out = rb_ary_new_capa(3);
586
+ rb_ary_push(out, Qnil);
587
+ rb_ary_push(out, ULONG2NUM(code));
588
+ rb_ary_push(out, ULONG2NUM(rearm));
589
+ return out;
590
+ }
591
+ }
592
+
593
+ /* Watcher#_issue(ov_addr, buf_addr, len) -> Integer (0 = queued, else GLE).
594
+ * :winloop only; calls RDCW with winloop's OVERLAPPED/buffer addresses. */
595
+ static VALUE
596
+ watch_issue(VALUE self, VALUE vov, VALUE vbuf, VALUE vlen)
597
+ {
598
+ watch_t *w = watch_get(self);
599
+ OVERLAPPED *ov = (OVERLAPPED *)(uintptr_t)NUM2ULL(vov);
600
+ void *buf = (void *)(uintptr_t)NUM2ULL(vbuf);
601
+ DWORD len = NUM2ULONG(vlen);
602
+ BOOL ok;
603
+ DWORD gle;
604
+
605
+ if (w->dir == INVALID_HANDLE_VALUE)
606
+ return ULONG2NUM(ERROR_OPERATION_ABORTED);
607
+
608
+ ok = ReadDirectoryChangesW(w->dir, buf, len, w->recursive, w->filter,
609
+ NULL, ov, NULL);
610
+ if (ok) return ULONG2NUM(0);
611
+ gle = GetLastError();
612
+ if (gle == ERROR_IO_PENDING) return ULONG2NUM(0);
613
+ return ULONG2NUM(gle);
614
+ }
615
+
616
+ /* Watcher#_cancel — CancelIoEx(dir, NULL); safe from any thread; idempotent. */
617
+ static VALUE
618
+ watch_cancel(VALUE self)
619
+ {
620
+ watch_t *w = watch_get(self);
621
+ if (w->dir != INVALID_HANDLE_VALUE)
622
+ CancelIoEx(w->dir, NULL);
623
+ return Qnil;
624
+ }
625
+
626
+ /* Watcher#_mark_closed — set the closed flag (under the issue lock in Ruby for
627
+ * :winloop; standalone close uses _close which sets it directly). Returns the
628
+ * in_take count so the Ruby/close path could observe it if needed. */
629
+ static VALUE
630
+ watch_mark_closed(VALUE self)
631
+ {
632
+ watch_t *w = watch_get(self);
633
+ w->closed = 1;
634
+ return INT2NUM(w->in_take);
635
+ }
636
+
637
+ /* Watcher#_close_handle — settle + CloseHandle(s); idempotent; both modes.
638
+ * Only performs the teardown tail if no thread is inside _take (the in_take
639
+ * counter, §3.5 rule 3); otherwise the last leaver does it. */
640
+ static VALUE
641
+ watch_close_handle(VALUE self)
642
+ {
643
+ watch_t *w = watch_get(self);
644
+ if (w->in_take == 0)
645
+ watch_teardown(w);
646
+ return Qnil;
647
+ }
648
+
649
+ /*
650
+ * Watcher#_close_standalone — the full standalone close sequence (§3.5 rule 3):
651
+ * mark closed -> CancelIoEx(dir, &ov) -> deferred teardown tail iff in_take==0.
652
+ * The cancel wakes any blocked _take (which re-checks closed first and returns
653
+ * the :closed indication; the last leaver performs the tail).
654
+ */
655
+ static VALUE
656
+ watch_close_standalone(VALUE self)
657
+ {
658
+ watch_t *w = watch_get(self);
659
+ if (w->closed) return Qnil; /* idempotent */
660
+ w->closed = 1;
661
+ if (w->dir != INVALID_HANDLE_VALUE && w->armed)
662
+ CancelIoEx(w->dir, &w->ov);
663
+ if (w->in_take == 0)
664
+ watch_teardown(w);
665
+ /* else: the last leaver of _take runs the tail on its way out. */
666
+ return Qnil;
667
+ }
668
+
669
+ /* Watcher#closed? — raw getter; works on closed objects. */
670
+ static VALUE
671
+ watch_closed_p(VALUE self)
672
+ {
673
+ watch_t *w = watch_get(self);
674
+ return (w->closed || w->dir == INVALID_HANDLE_VALUE) ? Qtrue : Qfalse;
675
+ }
676
+
677
+ /* Watcher#in_take — diagnostic (used by the deferred-teardown stress test). */
678
+ static VALUE
679
+ watch_in_take(VALUE self)
680
+ {
681
+ return INT2NUM(watch_get(self)->in_take);
682
+ }
683
+
684
+ /* Watcher#external? — :winloop mode flag (set at _open). */
685
+ static VALUE
686
+ watch_external_p(VALUE self)
687
+ {
688
+ return watch_get(self)->external ? Qtrue : Qfalse;
689
+ }
690
+
691
+ /* Watcher#_handle_value -> Integer (the directory HANDLE, for sched.op_associate
692
+ * in :winloop mode). winwatch's struct stays the single owner of the handle. */
693
+ static VALUE
694
+ watch_handle_value(VALUE self)
695
+ {
696
+ watch_t *w = watch_get(self);
697
+ return ULL2NUM((unsigned long long)(uintptr_t)w->dir);
698
+ }
699
+
700
+ /* =====================================================================
701
+ * Record parsing — Winwatch._parse
702
+ * ===================================================================== */
703
+
704
+ /*
705
+ * Winwatch._parse(data) -> [[action_int, name_utf8], ...]
706
+ *
707
+ * Walks the FILE_NOTIFY_INFORMATION NextEntryOffset chain defensively (§5.13):
708
+ * every offset must be DWORD-aligned and strictly within the batch, every
709
+ * FileNameLength must be in bounds, names are NOT NUL-terminated (length in
710
+ * bytes in FileNameLength). A malformed chain stops the walk; the already-valid
711
+ * records are returned plus one synthesized [-1, ""] sentinel (the Ruby layer
712
+ * turns it into a :rescan, because we can no longer trust the batch). Unknown
713
+ * Action values are skipped with an rb_warn under $VERBOSE. Zero-length names
714
+ * are skipped. All arithmetic uses 64-bit locals before comparison against the
715
+ * byte count (no DWORD wraparound, §5.18). No OS state is held at all.
716
+ */
717
+ #define WINWATCH_ACTION_RESCAN (-1) /* synthetic: malformed chain -> :rescan */
718
+
719
+ static VALUE
720
+ winwatch_parse(VALUE mod, VALUE data)
721
+ {
722
+ const unsigned char *base;
723
+ unsigned long long total, off;
724
+ int truncated = 0;
725
+ VALUE out = rb_ary_new();
726
+
727
+ StringValue(data);
728
+ base = (const unsigned char *)RSTRING_PTR(data);
729
+ total = (unsigned long long)RSTRING_LEN(data);
730
+
731
+ off = 0;
732
+ while (off < total) {
733
+ const FILE_NOTIFY_INFORMATION *fni;
734
+ unsigned long long next, name_bytes, name_off, rec_end;
735
+ DWORD action;
736
+
737
+ /* Offset must be DWORD-aligned and leave room for the fixed header
738
+ * (NextEntryOffset + Action + FileNameLength = 12 bytes). */
739
+ if ((off & 3u) != 0) { truncated = 1; break; }
740
+ if (off + 12 > total) { truncated = 1; break; }
741
+
742
+ fni = (const FILE_NOTIFY_INFORMATION *)(base + off);
743
+ action = fni->Action;
744
+ name_bytes = (unsigned long long)fni->FileNameLength;
745
+ next = (unsigned long long)fni->NextEntryOffset;
746
+
747
+ /* The name lives at off + offsetof(FileName) for name_bytes bytes. */
748
+ name_off = off + (unsigned long long)FIELD_OFFSET(FILE_NOTIFY_INFORMATION, FileName);
749
+ rec_end = name_off + name_bytes;
750
+ if (rec_end > total || (name_bytes & 1u) != 0) { truncated = 1; break; }
751
+
752
+ if (name_bytes > 0) {
753
+ const WCHAR *wname = (const WCHAR *)(base + name_off);
754
+ int wlen = (int)(name_bytes / 2);
755
+ int u8n = WideCharToMultiByte(CP_UTF8, 0, wname, wlen, NULL, 0, NULL, NULL);
756
+ if (u8n > 0) {
757
+ switch (action) {
758
+ case FILE_ACTION_ADDED:
759
+ case FILE_ACTION_REMOVED:
760
+ case FILE_ACTION_MODIFIED:
761
+ case FILE_ACTION_RENAMED_OLD_NAME:
762
+ case FILE_ACTION_RENAMED_NEW_NAME: {
763
+ VALUE name = rb_utf8_str_new(NULL, u8n);
764
+ VALUE rec;
765
+ WideCharToMultiByte(CP_UTF8, 0, wname, wlen,
766
+ RSTRING_PTR(name), u8n, NULL, NULL);
767
+ rec = rb_ary_new_capa(2);
768
+ rb_ary_push(rec, UINT2NUM(action));
769
+ rb_ary_push(rec, name);
770
+ rb_ary_push(out, rec);
771
+ break;
772
+ }
773
+ default:
774
+ if (RTEST(ruby_verbose))
775
+ rb_warn("winwatch: unknown FILE_ACTION %lu skipped",
776
+ (unsigned long)action);
777
+ break;
778
+ }
779
+ }
780
+ /* u8n<=0: a name that won't convert — skip it, keep walking. */
781
+ }
782
+ /* name_bytes==0: zero-length name, skip. */
783
+
784
+ if (next == 0) break; /* end of chain (clean) */
785
+ if ((next & 3u) != 0) { truncated = 1; break; } /* offset must be aligned */
786
+ if (next < (rec_end - off)) { truncated = 1; break; } /* overlaps this record */
787
+ if (off + next <= off) { truncated = 1; break; } /* no forward progress / wrap */
788
+ off += next;
789
+ }
790
+
791
+ /* Walk stopped on a malformed chain: synthesize a :rescan sentinel so the
792
+ * Ruby layer can no longer trust the batch to be complete (§5.13). */
793
+ if (truncated) {
794
+ VALUE rec = rb_ary_new_capa(2);
795
+ rb_ary_push(rec, INT2NUM(WINWATCH_ACTION_RESCAN));
796
+ rb_ary_push(rec, rb_utf8_str_new("", 0));
797
+ rb_ary_push(out, rec);
798
+ }
799
+ return out;
800
+ }
801
+
802
+ /* =====================================================================
803
+ * GetLongPathNameW bridge — Winwatch._long_path
804
+ * ===================================================================== */
805
+
806
+ /*
807
+ * Winwatch._long_path(abs_utf8) -> String | nil
808
+ *
809
+ * Best-effort 8.3 -> long-name normalization via GetLongPathNameW (rdcw §9).
810
+ * Returns nil on ANY failure (file gone, FS without short names, conversion
811
+ * error) — never raises. Input is an absolute UTF-8 path (the joined path).
812
+ * GVL held; usually no round-trip (may touch the FS on SMB), per-tilde only.
813
+ */
814
+ static VALUE
815
+ winwatch_long_path(VALUE mod, VALUE vpath)
816
+ {
817
+ int len, n;
818
+ WCHAR *wpath, *wout;
819
+ DWORD got, cap;
820
+ VALUE out;
821
+
822
+ StringValue(vpath);
823
+ len = (int)RSTRING_LEN(vpath);
824
+ if (len == 0) return Qnil;
825
+ n = MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(vpath), len, NULL, 0);
826
+ if (n <= 0) return Qnil;
827
+ wpath = (WCHAR *)malloc(sizeof(WCHAR) * ((size_t)n + 1));
828
+ if (!wpath) return Qnil;
829
+ MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(vpath), len, wpath, n);
830
+ wpath[n] = 0;
831
+
832
+ got = GetLongPathNameW(wpath, NULL, 0);
833
+ if (got == 0) { free(wpath); return Qnil; }
834
+ cap = got;
835
+ wout = (WCHAR *)malloc(sizeof(WCHAR) * (size_t)cap);
836
+ if (!wout) { free(wpath); return Qnil; }
837
+ got = GetLongPathNameW(wpath, wout, cap);
838
+ free(wpath);
839
+ if (got == 0 || got >= cap) { free(wout); return Qnil; }
840
+
841
+ {
842
+ int u8n = WideCharToMultiByte(CP_UTF8, 0, wout, (int)got, NULL, 0, NULL, NULL);
843
+ if (u8n <= 0) { free(wout); return Qnil; }
844
+ out = rb_utf8_str_new(NULL, u8n);
845
+ WideCharToMultiByte(CP_UTF8, 0, wout, (int)got, RSTRING_PTR(out), u8n, NULL, NULL);
846
+ }
847
+ free(wout);
848
+ return out;
849
+ }
850
+
851
+ /* ----------------------------------------------------------------- Init --- */
852
+
853
+ void
854
+ Init_winwatch(void)
855
+ {
856
+ mWinwatch = rb_define_module("Winwatch");
857
+
858
+ eError = rb_define_class_under(mWinwatch, "Error", rb_eStandardError);
859
+ eOSError = rb_define_class_under(mWinwatch, "OSError", eError);
860
+ eNotFound = rb_define_class_under(mWinwatch, "NotFound", eOSError);
861
+ eAccessDenied = rb_define_class_under(mWinwatch, "AccessDenied", eOSError);
862
+ eUnsupported = rb_define_class_under(mWinwatch, "Unsupported", eOSError);
863
+ eNotADirectory = rb_define_class_under(mWinwatch, "NotADirectory", eError);
864
+ eClosed = rb_define_class_under(mWinwatch, "Closed", eError);
865
+
866
+ cWatcher = rb_define_class_under(mWinwatch, "Watcher", rb_cObject);
867
+ rb_define_alloc_func(cWatcher, watch_alloc);
868
+ rb_define_singleton_method(cWatcher, "_open", watch_open, 5);
869
+ rb_define_method(cWatcher, "_arm", watch_m_arm, 0);
870
+ rb_define_method(cWatcher, "_take", watch_take, 1);
871
+ rb_define_method(cWatcher, "_issue", watch_issue, 3);
872
+ rb_define_method(cWatcher, "_cancel", watch_cancel, 0);
873
+ rb_define_method(cWatcher, "_mark_closed", watch_mark_closed, 0);
874
+ rb_define_method(cWatcher, "_close_handle", watch_close_handle, 0);
875
+ rb_define_method(cWatcher, "_close_standalone", watch_close_standalone, 0);
876
+ rb_define_method(cWatcher, "closed?", watch_closed_p, 0);
877
+ rb_define_method(cWatcher, "_in_take", watch_in_take, 0);
878
+ rb_define_method(cWatcher, "_external?", watch_external_p, 0);
879
+ rb_define_method(cWatcher, "_handle_value", watch_handle_value, 0);
880
+
881
+ rb_define_singleton_method(mWinwatch, "_parse", winwatch_parse, 1);
882
+ rb_define_singleton_method(mWinwatch, "_long_path", winwatch_long_path, 1);
883
+ }