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

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.

Raises:

  • (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_blockingObject

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