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.("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.('%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
:changedmay stand for many changes; you get a symbol, not what changed. Diff it yourself. RegRestoreKeydeletion is not detected. A key replaced viaRegRestoreKeydoes 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\…raisesArgumentErrorat parse time. - Every kernel handle lives in a wrapper whose finalizer closes it, so a
forgotten
#closenever leaks — but closing explicitly (or using the block forms) is good manners. - Windows/MSVC only. Links
advapi32+kernel32, built withcl.exe.
License
MIT.