Module: Winwatch
- Defined in:
- lib/winwatch.rb,
lib/winwatch/version.rb,
ext/winwatch/winwatch.c
Overview
winwatch — watch a directory tree the way Windows means it: one ReadDirectoryChangesW watcher with lossless overflow signaling (:rescan, never silent drops). Parks fibers on the winloop IOCP when a scheduler is active, a plain blocking pull everywhere else.
require "winwatch"
Winwatch.watch("C:/projects/app", recursive: true) do |w|
w.each do |e|
case e.type
when :renamed then puts "#{e.from} -> #{e.path}"
when :rescan then full_resync! # kernel dropped the backlog
when :gone then warn "watch died (#{e.code})"; break
else puts "#{e.type} #{e.path}"
end
end
end
Defined Under Namespace
Classes: AccessDenied, Closed, Error, Event, NotADirectory, NotFound, OSError, Unsupported, Watcher
Constant Summary collapse
- FILTER_FLAGS =
---- Win32 notify-filter flag values (verified, rdcw §3) ----
{ file_name: 0x001, # FILE_NOTIFY_CHANGE_FILE_NAME -> :added/:removed/:renamed for files dir_name: 0x002, # FILE_NOTIFY_CHANGE_DIR_NAME -> :added/:removed/:renamed for directories attributes: 0x004, # FILE_NOTIFY_CHANGE_ATTRIBUTES -> :modified size: 0x008, # FILE_NOTIFY_CHANGE_SIZE -> :modified last_write: 0x010, # FILE_NOTIFY_CHANGE_LAST_WRITE -> :modified last_access: 0x020, # FILE_NOTIFY_CHANGE_LAST_ACCESS -> :modified (noisy; opt-in) creation: 0x040, # FILE_NOTIFY_CHANGE_CREATION -> :modified security: 0x100 # FILE_NOTIFY_CHANGE_SECURITY -> :modified }.freeze
- DEFAULT_FILTER =
rdcw §3 recommendation
%i[file_name dir_name last_write size].freeze
- MIN_BUFFER_SIZE =
.NET FSW floor (rdcw §4)
4_096- MAX_BUFFER_SIZE =
SMB protocol cap; non-paged-pool sanity (rdcw §4)
65_536- ACTION_ADDED =
---- FILE_ACTION_* values (verified, rdcw §3) ----
1- ACTION_REMOVED =
2- ACTION_MODIFIED =
3- ACTION_RENAMED_OLD_NAME =
4- ACTION_RENAMED_NEW_NAME =
5- ACTION_RESCAN =
synthetic, from a malformed record chain (_parse)
-1 # synthetic, from a malformed record chain (_parse)
- ERROR_NOTIFY_ENUM_DIR =
---- completion-classification Win32 codes (§5.0) ----
1022- ERROR_OPERATION_ABORTED =
overflow, failure spelling -> :rescan
995- ERROR_NOTIFY_CLEANUP =
a cancel (ours or thread rundown)
745- ERROR_ACCESS_DENIED =
our own handle close completed the op
5- VERSION =
"0.1.0"
Class Method Summary collapse
-
._long_path(vpath) ⇒ Object
Winwatch._long_path(abs_utf8) -> String | nil.
- ._parse(data) ⇒ Object
-
.ms_for(timeout) ⇒ Object
nil -> -1 (INFINITE); non-negative seconds -> ms, tiny positive rounds up to 1 ms (never collapses to a poll); negative -> ArgumentError.
-
.run_blocking ⇒ Object
Run a blocking native call cooperatively.
-
.watch(path, recursive: false, filter: DEFAULT_FILTER, buffer_size: MAX_BUFFER_SIZE, normalize_names: true) ⇒ Object
The only constructor.
Class Method Details
._long_path(vpath) ⇒ Object
Winwatch._long_path(abs_utf8) -> String | nil
Best-effort 8.3 -> long-name normalization via GetLongPathNameW (rdcw §9). Returns nil on ANY failure (file gone, FS without short names, conversion error) — never raises. Input is an absolute UTF-8 path (the joined path). GVL held; usually no round-trip (may touch the FS on SMB), per-tilde only.
814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 |
# File 'ext/winwatch/winwatch.c', line 814
static VALUE
winwatch_long_path(VALUE mod, VALUE vpath)
{
int len, n;
WCHAR *wpath, *wout;
DWORD got, cap;
VALUE out;
StringValue(vpath);
len = (int)RSTRING_LEN(vpath);
if (len == 0) return Qnil;
n = MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(vpath), len, NULL, 0);
if (n <= 0) return Qnil;
wpath = (WCHAR *)malloc(sizeof(WCHAR) * ((size_t)n + 1));
if (!wpath) return Qnil;
MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(vpath), len, wpath, n);
wpath[n] = 0;
got = GetLongPathNameW(wpath, NULL, 0);
if (got == 0) { free(wpath); return Qnil; }
cap = got;
wout = (WCHAR *)malloc(sizeof(WCHAR) * (size_t)cap);
if (!wout) { free(wpath); return Qnil; }
got = GetLongPathNameW(wpath, wout, cap);
free(wpath);
if (got == 0 || got >= cap) { free(wout); return Qnil; }
{
int u8n = WideCharToMultiByte(CP_UTF8, 0, wout, (int)got, NULL, 0, NULL, NULL);
if (u8n <= 0) { free(wout); return Qnil; }
out = rb_utf8_str_new(NULL, u8n);
WideCharToMultiByte(CP_UTF8, 0, wout, (int)got, RSTRING_PTR(out), u8n, NULL, NULL);
}
free(wout);
return out;
}
|
._parse(data) ⇒ Object
719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 |
# File 'ext/winwatch/winwatch.c', line 719
static VALUE
winwatch_parse(VALUE mod, VALUE data)
{
const unsigned char *base;
unsigned long long total, off;
int truncated = 0;
VALUE out = rb_ary_new();
StringValue(data);
base = (const unsigned char *)RSTRING_PTR(data);
total = (unsigned long long)RSTRING_LEN(data);
off = 0;
while (off < total) {
const FILE_NOTIFY_INFORMATION *fni;
unsigned long long next, name_bytes, name_off, rec_end;
DWORD action;
/* Offset must be DWORD-aligned and leave room for the fixed header
* (NextEntryOffset + Action + FileNameLength = 12 bytes). */
if ((off & 3u) != 0) { truncated = 1; break; }
if (off + 12 > total) { truncated = 1; break; }
fni = (const FILE_NOTIFY_INFORMATION *)(base + off);
action = fni->Action;
name_bytes = (unsigned long long)fni->FileNameLength;
next = (unsigned long long)fni->NextEntryOffset;
/* The name lives at off + offsetof(FileName) for name_bytes bytes. */
name_off = off + (unsigned long long)FIELD_OFFSET(FILE_NOTIFY_INFORMATION, FileName);
rec_end = name_off + name_bytes;
if (rec_end > total || (name_bytes & 1u) != 0) { truncated = 1; break; }
if (name_bytes > 0) {
const WCHAR *wname = (const WCHAR *)(base + name_off);
int wlen = (int)(name_bytes / 2);
int u8n = WideCharToMultiByte(CP_UTF8, 0, wname, wlen, NULL, 0, NULL, NULL);
if (u8n > 0) {
switch (action) {
case FILE_ACTION_ADDED:
case FILE_ACTION_REMOVED:
case FILE_ACTION_MODIFIED:
case FILE_ACTION_RENAMED_OLD_NAME:
case FILE_ACTION_RENAMED_NEW_NAME: {
VALUE name = rb_utf8_str_new(NULL, u8n);
VALUE rec;
WideCharToMultiByte(CP_UTF8, 0, wname, wlen,
RSTRING_PTR(name), u8n, NULL, NULL);
rec = rb_ary_new_capa(2);
rb_ary_push(rec, UINT2NUM(action));
rb_ary_push(rec, name);
rb_ary_push(out, rec);
break;
}
default:
if (RTEST(ruby_verbose))
rb_warn("winwatch: unknown FILE_ACTION %lu skipped",
(unsigned long)action);
break;
}
}
/* u8n<=0: a name that won't convert — skip it, keep walking. */
}
/* name_bytes==0: zero-length name, skip. */
if (next == 0) break; /* end of chain (clean) */
if ((next & 3u) != 0) { truncated = 1; break; } /* offset must be aligned */
if (next < (rec_end - off)) { truncated = 1; break; } /* overlaps this record */
if (off + next <= off) { truncated = 1; break; } /* no forward progress / wrap */
off += next;
}
/* Walk stopped on a malformed chain: synthesize a :rescan sentinel so the
* Ruby layer can no longer trust the batch to be complete (§5.13). */
if (truncated) {
VALUE rec = rb_ary_new_capa(2);
rb_ary_push(rec, INT2NUM(WINWATCH_ACTION_RESCAN));
rb_ary_push(rec, rb_utf8_str_new("", 0));
rb_ary_push(out, rec);
}
return out;
}
|
.ms_for(timeout) ⇒ Object
nil -> -1 (INFINITE); non-negative seconds -> ms, tiny positive rounds up to 1 ms (never collapses to a poll); negative -> ArgumentError.
132 133 134 135 136 137 138 139 140 |
# File 'lib/winwatch.rb', line 132 def ms_for(timeout) return -1 if timeout.nil? t = Float(timeout) raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative? ms = (t * 1000).round ms.zero? && t.positive? ? 1 : ms end |
.run_blocking ⇒ Object
Run a blocking native call cooperatively. Under a Fiber scheduler (without await_op — async, winloop 0.1) the call is offloaded to a worker Thread so the calling fiber parks (Thread#value routes through the scheduler) and the loop keeps serving; with no scheduler it runs inline (the C call already releases the GVL). On fiber unwind the worker is killed+joined so it can't leak or consume data destined for a later op.
Caveat: if the fiber is unwound (e.g. Timeout) in the instant after the worker's _take returned a batch but before Thread#value delivered it, that batch is lost with the killed worker — the documented winipc lost- acquisition window (the in-C stash, §5.12, does not cover this path).
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/winwatch.rb', line 112 def run_blocking sched = Fiber.scheduler return yield unless sched worker = Thread.new do Thread.current.report_on_exception = false yield end begin worker.value ensure if worker.alive? worker.kill worker.join end end end |
.watch(path, recursive: false, filter: DEFAULT_FILTER, buffer_size: MAX_BUFFER_SIZE, normalize_names: true) ⇒ Object
The only constructor. Resolves path with File.absolute_path, opens the
directory and issues the first ReadDirectoryChangesW before returning (so
changes between watch and the first take are captured). Selects the mode
once: :winloop when Fiber.scheduler&.respond_to?(:await_op) at call time,
else :standalone. Yields the watcher (ensure-closed) when a block is given.
Raises: Winwatch::NotFound / NotADirectory / AccessDenied / Unsupported / OSError (open/first-issue), ArgumentError / TypeError (bad options).
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/winwatch.rb', line 81 def watch(path, recursive: false, filter: DEFAULT_FILTER, buffer_size: MAX_BUFFER_SIZE, normalize_names: true) abs = File.absolute_path(path.to_s) keys = filter_keys(filter) flags = keys.inject(0) { |acc, k| acc | FILTER_FLAGS[k] } bytes = normalize_buffer_size(buffer_size) sched = Fiber.scheduler winloop = sched.respond_to?(:await_op) watcher = Watcher.send(:build, abs, keys.freeze, flags, recursive ? true : false, bytes, normalize_names ? true : false, winloop, sched) return watcher unless block_given? begin yield watcher ensure watcher.close end end |