winreg

Typed Windows registry access for Ruby — exact wire formats, least-privilege opens, WOW64 views as a first-class option, and change notification that cooperates with a fiber scheduler.

Ruby's bundled win32/registry is pure Ruby over Fiddle, and it has accumulated a decade of wire-format bugs: it writes REG_MULTI_SZ without the trailing empty string (a 22-byte image where 24 is correct), it NoMethodErrors on read(nil)/[nil] for a key's default value, and it .chops the last character off any string whose stored bytes are not NUL-terminated. Its enumeration name buffer is 514 chars, so a value name of 515+ (the registry allows 16,383) raises. And it does not bind RegNotifyChangeKeyValue at all, so there is no way to watch a key for changes.

winreg is a thin MSVC C extension that gets the bytes right. It exposes the Win32 registry through a typed, hard-to-misuse API: strict typed readers and writers that own serialization (so the wire format is correct by construction), raw escape hatches for adversarial data, working default-value access, range-checked integers, full-length enumeration, KEY_READ-not-KEY_ALL_ACCESS defaults, 32/64-bit views applied consistently to child operations, and change watching.

The watch surface releases the GVL and is interruptible standalone, and runs cooperatively under a fiber scheduler such as winloop by offloading the blocking wait to a worker thread — so a parked watcher never stalls the loop. Plain registry data operations are microsecond-scale local calls; they run inline and never park a fiber.

API summary

What API
Open / create Winreg.open(path, access:, view:), Winreg.create(path, access:, view:), Key#open, Key#create
Typed read Key#string #dword #qword #multi_string #binary (+ ? variants), #read/#read?[type, value]
Typed write Key#write_string #write_expand_string #write_multi_string #write_dword #write_qword #write_binary, #write(name, type, value), #delete_value
Raw escape hatch Key#raw[tag, bytes], Key#write_raw(name, tag, bytes)
Enumerate Key#each_value #each_key #value_names #key_names
Info Key#info → counts + last_write_time
Delete Key#delete_value, Key#delete_key(name, recursive:)
Probes Key#value?, Key#key?
Views `view: :default :v64 :v32` (inherited by children)
Watch Key#watch(subtree:, filter:)Winreg::Watch, Watch#wait(timeout:)
Expand Winreg.expand_string(str), Key#string(name, expand: true)

Requirements

  • Windows 10 or newer with a native MSVC (mswin) Ruby. Not supported on MinGW/UCRT. (The change-notification path relies on REG_NOTIFY_THREAD_AGNOSTIC, which is Windows 8+.)
  • Visual Studio 2017+ / Build Tools with the Desktop development with C++ workload.
  • x64. arm64-mswin is expected to work (the code is arch-neutral) but is untested and unsupported until an arm64-mswin Ruby distribution exists.

Install

gem install winreg

Reading typed values

No elevation is needed to read most of HKLM\SOFTWARE:

require "winreg"

Winreg.open('HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion') do |k|
  k.string("ProductName")             # => "Windows 10 ..."
  k.read("CurrentMajorVersionNumber") # => [:dword, 10]
  k.string?("NoSuchValue")            # => nil  (the ? readers return nil when missing)
end

read returns [type, value] where type is a Symbol from Winreg::TYPES (or the raw Integer tag for types outside the table). Strict typed readers (string, dword, …) raise Winreg::TypeMismatch if the stored type differs — a wrong type is a bug, not an absence, so even the ? variants raise on a type mismatch (they only swallow "missing").

Writing

Writers own serialization, so the wire format is correct by construction (double-NUL REG_MULTI_SZ, byte counts including terminators). They require a key opened access: :read_write:

Winreg.create('HKCU\Software\Vendor\App') do |k|
  k.write_string("InstallDir", 'C:\Vendor\App')
  k.write_expand_string("Cache", '%LOCALAPPDATA%\Vendor')
  k.write_multi_string("Plugins", %w[alpha beta])  # correct double-NUL wire format
  k.write_dword("Port", 8080)

  k.multi_string("Plugins")           # => ["alpha", "beta"]
  k.dword("Port")                     # => 8080

  k.write_dword("Port", -1)           # raises RangeError (stdlib silently wraps to 0xFFFFFFFF)
  k.dword("InstallDir")               # raises Winreg::TypeMismatch (it is REG_SZ)
end

Integers are unsigned and range-checked (0..2**32-1 / 0..2**64-1); embedded NULs in string data and empty/NUL-containing REG_MULTI_SZ elements are rejected with ArgumentError — the gem never produces a malformed value.

The default value

The unnamed (default) value is addressed by nil or "" for every operation — the case where the stdlib raises NoMethodError:

Winreg.open('HKCU\Software\Classes\CLSID') do |k|
  k.read?(nil)                        # => [:sz, "..."] or nil
end

Expand strings

REG_EXPAND_SZ values are returned literally — the %VAR% text and the true :expand_sz type are preserved, never silently expanded or masked to :sz. Expansion is explicit and uses ExpandEnvironmentStringsW (not a Ruby gsub over ENV, whose semantics differ — unknown variables are left literal exactly as the OS defines):

k.string("Cache")                     # => "%LOCALAPPDATA%\\Vendor"   (unexpanded)
k.string("Cache", expand: true)       # => "C:\\Users\\me\\AppData\\Local\\Vendor"
Winreg.expand_string('%TEMP%\\x')     # => "C:\\Users\\me\\AppData\\Local\\Temp\\x"

Enumerating

Winreg.open('HKCU\Software\Vendor\App') do |k|
  k.each_value { |name, type, value| puts "#{name} (#{type}) = #{value.inspect}" }
  k.value_names                       # => ["InstallDir", "Cache", "Plugins", "Port"]
  k.key_names                         # => subkey names

  info = k.info
  info.value_count                    # => Integer
  info.last_write_time                # => Time (100ns FILETIME precision)
end

Enumeration is live and snapshot-less; kernel order is arbitrary and concurrent mutation may skip or repeat entries. each_value decodes data and so can raise MalformedValue on a hostile value mid-iteration — use value_names + raw for forensic robustness (it never decodes).

WOW64 views

A 32/64-bit view is a constructor option, stored on the key and automatically re-applied to every child open/create, delete_key, key? probe, and Watch. Children cannot override it — this is the API encoding of the view-consistency rule, so you never accidentally cross the redirection boundary:

Winreg.create('HKCU\Software\Classes\CLSID\{...}', view: :v64) do |k|
  child = k.create("Sub")             # also :v64, no way to ask for :v32 here
end

Avoid addressing Wow6432Node literally; use view: instead.

Watching for changes

Key#watch returns an armed Winreg::Watch, or loops in block form. The block form yields :changed per coalesced change and yields :deleted once (then returns) when the watched key is deleted:

Winreg.create('HKCU\Software\Vendor\App') do |k|
  k.watch(filter: :values) do |event|
    case event
    when :changed then reload_config!
    when :deleted then break
    end
  end
end

The primitive form takes a timeout (seconds; nil = infinite):

w = key.watch(subtree: true)
case w.wait(timeout: 5)
when :changed then puts "something under #{key.path} changed"
when :deleted then puts "key is gone"
when nil      then puts "no change in 5s"
end
w.close

filter: is a Symbol or Array of :values, :keys, :attributes, :security, :default (= keys + values), or :all. Notifications coalesce (:changed means "≥ 1 matching change since the previous delivery") and carry no payload — no value name, no change kind. The contract is "something changed; go look"; diff it yourself (e.g. compare info.last_write_time snapshots). The registration is rearmed before each delivery, so the final state is never missed; the cost is that one spurious wakeup is possible. The Watch opens its own private KEY_NOTIFY handle, so closing the originating Key does not disturb it.

Fiber schedulers (winloop)

Watch#wait is the only blocking call. Under a live Fiber.scheduler such as winloop the native wait is offloaded to a worker thread and Thread#value parks the calling fiber through the scheduler's hooks, so the loop keeps serving other fibers:

require "winloop"
Winloop.run do
  Fiber.schedule do
    Winreg.create('HKCU\Software\Vendor\App') do |k|
      k.watch(filter: :values) { |e| break if e == :deleted }
    end
  end
  Fiber.schedule { do_other_io }
end

With no scheduler the same call just blocks the calling thread (releasing the GVL, interruptible by Thread#kill / Ctrl-C / Timeout). Caveat: a fiber unwound between the worker observing :changed and value delivery loses that one delivery — harmless, because :changed is stateless and the registration stays armed, so the next change still fires.

Raw escape hatches

raw/write_raw move exact bytes under an arbitrary type tag and never decode, for adversarial data or types outside the typed surface:

tag, bytes = k.raw("Plugins")         # => [7, "a\x00l\x00...\x00\x00\x00\x00"]
k.write_raw("Custom", 8, bytes)       # arbitrary type tag (REG_RESOURCE_LIST here)

Errors

StandardError
└─ Winreg::Error
    ├─ Winreg::OSError          # a Windows API failed; #code is the LSTATUS / Win32 code
       ├─ Winreg::NotFound       # ERROR_FILE_NOT_FOUND (2)
       ├─ Winreg::AccessDenied   # ERROR_ACCESS_DENIED  (5)
       └─ Winreg::KeyDeleted     # ERROR_KEY_DELETED    (1018): the handle is valid, the key is gone
    ├─ Winreg::TypeMismatch     # a typed reader's stored type differs
    ├─ Winreg::MalformedValue   # stored bytes don't decode as the claimed type
    └─ Winreg::Closed           # an operation on a closed Key or Watch

Plain argument mistakes raise Ruby's own ArgumentError / TypeError / RangeError (e.g. an integer out of range) — those are misuse, not OS state.

Notes & limitations

  • Notifications coalesce and carry no payload. A single :changed may stand for many changes; you get a symbol, not what changed. Diff it yourself.
  • RegRestoreKey deletion is not detected. A key replaced via RegRestoreKey does not surface as :deleted (an OS limitation).
  • Names with invalid UTF-16 are lenient-decoded. Enumeration never aborts on a hostile name; such names come back with U+FFFD replacement and therefore cannot be round-trip addressed through the gem (a deliberate v1 trade — the alternative makes a whole key unenumerable).
  • Embedded NULs in stored string data are preserved, not silently truncated to the Win32-consumer view ("a\0b" reads as "a\0b"); writers reject embedded NULs so the gem never creates such a value.
  • Forward slash is a legal key-name character. The path parser splits on backslash only and never translates /.
  • Keep values ≤ ~2 KiB per Microsoft guidance (not enforced; the only hard limit is the 4 GiB DWORD byte-count guard).
  • HKLM writes need elevation. 64-bit processes are never virtualized, so a non-elevated HKLM write surfaces as an honest Winreg::AccessDenied.
  • Remote registry is unsupported. A path starting with \\server\… raises ArgumentError at parse time.
  • Every kernel handle lives in a wrapper whose finalizer closes it, so a forgotten #close never leaks — but closing explicitly (or using the block forms) is good manners.
  • Windows/MSVC only. Links advapi32 + kernel32, built with cl.exe.

License

MIT.