winipc
Windows local IPC for Ruby: named pipes, shared memory, and named synchronization — safe by default, and cooperative under a fiber scheduler.
winipc is a native extension that exposes the three pillars of Windows
inter-process communication through an ergonomic, hard-to-misuse Ruby API:
- Named pipes — a duplex server and a connect-with-retry client, byte or message framed. Opened for overlapped I/O, so blocking calls release the GVL and (under a fiber scheduler such as winloop) run cooperatively instead of stalling the loop.
- Shared memory — pagefile-backed named regions (
CreateFileMapping), with bounds-checked reads and writes. - Named synchronization — cross-process Mutex, Event, and Semaphore, with auto-releasing block forms.
It is secure by default: pipe servers reject remote clients and restrict the
pipe to the current user (closing the documented default ACL that grants
Everyone read), the first instance is created with
FILE_FLAG_FIRST_PIPE_INSTANCE so a squatter can't pre-own the name, and no
handle is inheritable.
Requirements
- Windows with a native MSVC (mswin) Ruby. Not supported on MinGW/UCRT.
- Visual Studio 2017+ / Build Tools with the Desktop development with C++ workload.
Install
gem install winipc
Named pipes
require "winipc"
# Server (one process)
Winipc::Pipe.listen("myapp/control") do |server|
server.serve do |conn| # accept + yield + close, in a loop
request = conn.read(4096)
conn.write("ack: #{request}")
end
end
# Client (another process)
Winipc::Pipe.connect("myapp/control", timeout: 5) do |c|
c.write("hello")
puts c.read(4096) # => "ack: hello"
end
read returns nil at a clean EOF (peer closed), exactly like IO#read; a peer
dying mid-stream raises Winipc::BrokenPipe. Message mode preserves message
boundaries:
Winipc::Pipe.listen("svc", mode: :message) { |s| c = s.accept; c. }
Winipc::Pipe.connect("svc", mode: :message) { |c| c.("one whole message") }
Cooperative under winloop
Because pipe handles are overlapped and the blocking calls release the GVL, a server and many clients can run as fibers on a single IOCP loop:
require "winloop"
Winloop.run do
Fiber.schedule { Winipc::Pipe.listen("svc") { |s| s.serve { |c| c.write(c.read(1024)) } } }
Fiber.schedule { Winipc::Pipe.connect("svc") { |c| c.write("hi"); c.read(1024) } }
end
Under a scheduler, each blocking winipc call is offloaded to a worker thread and the calling fiber parks, so the loop keeps serving other fibers. With no scheduler, the same calls just block the calling thread (releasing the GVL).
Shared memory
# Process A
shm = Winipc::SharedMemory.create("myapp/buffer", 4096)
shm.write(0, "shared payload")
# Process B
shm = Winipc::SharedMemory.open("myapp/buffer")
shm.read(0, 14) # => "shared payload"
Reads and writes are bounds-checked (Winipc::RangeError outside the region).
Pair shared memory with a named Event to hand off deterministically rather
than polling.
Size note: the creator's
#sizeis exactly the requested size; an opener's#sizeis the page-rounded mapped region (Windows rounds a mapping up to the page boundary and doesn't record the logical size), so an opener may read/write within the rounded region. If the exact length matters, carry it out-of-band (e.g. store it in the first bytes of the region).
Named synchronization
# Cross-process mutex
m = Winipc::Mutex.create("myapp/lock")
m.synchronize { critical_section } # always released, even on exception
# Event (one process waits, another signals)
ev = Winipc::Event.create("myapp/ready", manual_reset: true)
ev.wait(timeout: 10) # => true (signaled) / false (timed out)
ev.signal
# Counting semaphore
sem = Winipc::Semaphore.create("myapp/slots", initial: 3, maximum: 3)
sem.synchronize { use_a_slot }
Mutex is owned by the acquiring thread, so its waits are not offloaded to a
worker (they release the GVL but, under a fiber scheduler, block the loop for the
duration). Use Event/Semaphore — which any thread may wait on and signal —
for fiber-cooperative coordination. An abandoned mutex (its holder died) is
surfaced as Winipc::Abandoned / #abandoned? rather than silently ignored.
Errors
StandardError
└─ Winipc::Error
├─ Winipc::OSError # a Windows API failed; #code is GetLastError
│ ├─ Winipc::TimeoutError ├─ Winipc::BrokenPipe ├─ Winipc::NotOwner
│ ├─ Winipc::NotFound ├─ Winipc::PipeBusy └─ Winipc::WouldExceedMax
│ ├─ Winipc::Exists ├─ Winipc::Canceled
│ └─ Winipc::AccessDenied
├─ Winipc::ModeError # byte/message-mode misuse
├─ Winipc::RangeError # shared-memory access out of bounds
├─ Winipc::Closed # operating on a closed object
└─ Winipc::Abandoned # a mutex's previous owner died holding it
Notes
- Names are bare (
"myapp/control"); winipc maps them to\\.\pipe\…for pipes and theLocal\kernel namespace for shared memory / sync objects (passscope: :globalfor theGlobal\namespace, which may need privileges). - Every kernel handle lives in a wrapper whose finalizer closes it, so a
forgotten
#closenever leaks — but closing explicitly is good manners. - Blocking calls (
accept,read,write,wait/lock/acquire) release the GVL and are interruptible (Thread#kill,Ctrl-C,Timeout). - Windows/MSVC only. Links
kernel32+advapi32, built withcl.exe.
License
MIT.