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.read_message }
Winipc::Pipe.connect("svc", mode: :message) { |c| c.write_message("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 #size is exactly the requested size; an opener's #size is 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 the Local\ kernel namespace for shared memory / sync objects (pass scope: :global for the Global\ namespace, which may need privileges).
  • Every kernel handle lives in a wrapper whose finalizer closes it, so a forgotten #close never 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 with cl.exe.

License

MIT.