winloop
A native Windows Fiber Scheduler for MRI Ruby, built on I/O Completion Ports.
winloop makes ordinary blocking code — socket reads and writes, sleep,
Timeout.timeout, Mutex, Queue, Thread#join, DNS lookups — run
cooperatively on a single thread. Wrap your code in Winloop.run { ... },
spawn fibers with Fiber.schedule, and thousands of connections share one
thread without you touching a single nonblocking API.
require "winloop"
require "socket"
Winloop.run do
server = TCPServer.new("127.0.0.1", 9292)
loop do
client = server.accept # readiness via AFD poll on the IOCP
Fiber.schedule do # one lightweight fiber per connection
while (line = client.gets) # recv driven by the completion port
client.write(line) # send, never blocking the loop
end
client.close
end
end
end
Why this exists
Ruby's Fiber::Scheduler (the engine behind socketry/async and friends) is the
modern way to write concurrent I/O. But on Windows the selectors that back it —
socketry/io-event ships select, epoll, kqueue, and io_uring — have no
IOCP backend. The result has long been the weakest async story of any major
platform: select()-bound, capped at 64 handles, and slow.
winloop fills that hole the same way libuv, Rust's mio, and wepoll do it:
- The event loop is a real I/O Completion Port. One
GetQueuedCompletionStatusExcall per turn reaps every ready event at once. - Readiness (accept, connect,
IO#wait_readable) — which IOCP doesn't natively provide — is obtained by submittingIOCTL_AFD_POLLagainst\Device\Afdas an overlapped operation on the port. This is the undocumented mechanism libuv/mio rely on, and it is the only way to get readiness semantics onto a completion port. - Reads and writes are driven by
recv/sendonce the port reports readiness. - Timers (
sleep,Timeout.timeout) use a monotonic min-heap that sets the port's wait timeout — no kernel timer objects, no extra threads. Mutex/Queue/Thread#joinpark the fiber on an in-process waiter list; a wakeup from another OS thread breaks the loop's wait viaPostQueuedCompletionStatus.
Requirements
- A native Windows MSVC build of Ruby (
x64-mswin64). winloop compiles a C extension withcl.exeand linksws2_32. It will refuse to install on any non-mswinplatform. - Ruby >= 3.1 (for a stable
Fiber::SchedulerandIO::Buffer). \Device\Afdreadiness polling is a Windows NT facility; winloop is not expected to work under Wine.
Installation
gem install winloop
Or build from a checkout (it dogfoods the vcvars
gem so it can find the MSVC toolchain without a Developer Command Prompt):
rake compile
rake test
What runs cooperatively
Inside Winloop.run { ... } (and any Fiber.schedule started within it):
| Operation | Hook | Mechanism |
|---|---|---|
TCPServer#accept, IO#wait_readable |
io_wait |
IOCTL_AFD_POLL overlapped on the IOCP |
IO#read / gets / recv on a socket |
io_read |
recv_nonblock + io_wait |
IO#write / puts on a socket |
io_write |
write_nonblock + io_wait |
sleep |
kernel_sleep |
monotonic timer heap |
Timeout.timeout |
timeout_after |
timer heap + Fiber#raise |
Mutex, ConditionVariable, Queue, Thread#join |
block/unblock |
waiter lists + PostQueuedCompletionStatus |
Addrinfo.getaddrinfo, DNS in TCPSocket.new |
address_resolve |
resolved on a worker thread |
Process.wait |
process_wait |
reaped on a worker thread |
| your own OVERLAPPED ops (any gem) | await_op |
generic completion packets on the same IOCP |
API
Winloop.run { ... } # install a scheduler, run the block in a fiber, drive the
# loop until every scheduled fiber finishes; returns the
# block's value (and re-raises anything it raised).
Winloop.supported? # true when the native IOCP/AFD backend loaded.
For full control you can drive it yourself, exactly like any other
Fiber::Scheduler:
scheduler = Winloop::Scheduler.new
Fiber.set_scheduler(scheduler)
Fiber.schedule { ... }
scheduler.close # runs the event loop to completion
The generic-op surface (winloop 0.2 — see the next section):
# Scheduler (the cross-gem protocol; loop-thread-only):
sched.op_associate(handle) # => true (permanent; handle stays yours)
sched.op_prepare(handle, tag: 0, capacity: 0) # => [op_id, ov_addr, buf_addr]
sched.op_submitted(op_id) # => true (native call returned TRUE or ERROR_IO_PENDING)
sched.op_abandon(op_id) # => true (native call failed synchronously)
sched.op_cancel(op_id) # => true | false (false: already completed, or CancelIoEx failed — warned, never raised)
sched.op_state(op_id) # => :prepared | :submitted | :completed
sched.await_op(op_id, timeout: nil) # => [bytes, error, data] | nil (timeout; op auto-cancelled)
# Backend (the power layer; also the standalone-embedding layer):
backend.associate(handle) # => true
backend.op_prepare(handle, tag: 0, capacity: 0) # => [op_id, ov_addr, buf_addr]
backend.op_submitted(op_id) # => true
backend.op_abandon(op_id) # => true
backend.op_cancel(op_id) # => true | false
backend.op_result(op_id) # => [bytes, error, data] (frees the record)
backend.op_free(op_id) # => true (retire a completion nobody wants)
backend.op_state(op_id) # => :prepared | :submitted | :completed
backend.port_handle # => Integer (read-only; tests/diagnostics)
backend.wait(timeout_ms) # => [[id, events], ..., [op_id, bytes, error, tag], ...]
Winloop::EXTERNAL_KEY # => 0x45585431 — completion key of all generic ops
Winloop::OP_CAPACITY_MAX # => 16 MiB — op_prepare capacity ceiling
Bring your own OVERLAPPED (generic completions)
winloop 0.2 lets any gem associate a Windows handle with the loop's completion port, submit its own overlapped operation, and park the calling fiber until the completion packet arrives — cancel and synchronous-completion semantics done right, with zero change to everything winloop already does. This is a seam for gem authors (file watchers, named pipes, mailslots…), not app code.
The contract loop: op_associate → op_prepare → your native submit →
op_submitted (success or ERROR_IO_PENDING — both queue a packet) /
op_abandon (synchronous failure — the only no-packet case) → await_op →
[bytes, error, data].
A real overlapped file read driven from plain Ruby via Fiddle (stdlib):
require "winloop"
require "fiddle/import"
module K32
extend Fiddle::Importer
dlload "kernel32.dll"
extern "void* CreateFileW(void*, unsigned long, unsigned long, void*, unsigned long, unsigned long, void*)"
extern "int ReadFile(void*, void*, unsigned long, void*, void*)"
extern "int CloseHandle(void*)"
end
GENERIC_READ = 0x8000_0000
FILE_SHARE_READ = 0x1
OPEN_EXISTING = 3
FILE_FLAG_OVERLAPPED = 0x4000_0000
ERROR_IO_PENDING = 997
Winloop.run do
sched = Fiber.scheduler # a Winloop::Scheduler
wpath = (File.(__FILE__) + "\0").encode("UTF-16LE")
h = K32.CreateFileW(wpath, GENERIC_READ, FILE_SHARE_READ, nil,
OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nil).to_i
sched.op_associate(h)
op_id, ov, buf = sched.op_prepare(h, capacity: 4096)
ok = K32.ReadFile(h, buf, 4096, nil, ov)
if ok != 0 || Fiddle.win32_last_error == ERROR_IO_PENDING
sched.op_submitted(op_id) # success AND pending both queue a packet
bytes, error, data = sched.await_op(op_id, timeout: 5) # fiber parks HERE
raise Winloop::Error, "read failed (#{error})" unless error.zero?
puts "read #{bytes} bytes: #{data[0, 40].inspect}"
else
sched.op_abandon(op_id) # synchronous failure: no packet will come
raise Winloop::Error, "ReadFile failed (#{Fiddle.win32_last_error})"
end
K32.CloseHandle(h)
end
Rules (read this twice)
- Handles stay yours. winloop owns the op memory (OVERLAPPED + buffer, one
backend-owned heap allocation); you open and close your handles — winloop
never calls
CloseHandleon them. - Association is permanent — one port per handle, no disassociation (Win32
rule). After the loop shuts down an associated handle can never join another
winloop loop; re-open per
Winloop.runsession. - Associate before prepare.
op_preparerejects handles it has never seen, because an op on a handle that is not on the port never completes: not an error, a silent hang. The one case the check cannot catch — closing a handle mid-flight so the OS recycles its value — is on you: cancel → await → only thenCloseHandle. - Use
await_optimeouts while first integrating a new native API; switch tonilonce the submit path is proven. - Never set
FILE_SKIP_COMPLETION_PORT_ON_SUCCESSon an associated handle (it is irreversible and per-handle): winloop's invariant is exactly one packet per submitted op, always. Don't useReadFileEx/WriteFileEx(APC I/O) on associated handles, and don't duplicate/inherit them. - Cancel never frees — the cancelled op still posts a packet (usually error 995), which is reaped normally.
- After
await_opreturns — value ornil— the op id is dead. Don't use it again. - Loop thread only: ops are submitted on the loop thread (fibers run there anyway); the scheduler wrappers enforce it.
- Completion errors are values, not exceptions:
995cancelled,1022RDCW rescan,38EOF, … A zero-byte successful completion is a real signal (RDCW overflow ⇒ rescan), delivered verbatim. - No PQCS injection — out of scope in v1.
Backend#port_handleexists for tests and diagnostics only.
Standalone (no scheduler)
Client gems never hard-depend on winloop. The protocol is duck-typed on the scheduler:
sched = Fiber.scheduler
if sched&.respond_to?(:await_op)
# winloop (or compatible) IOCP path: op_associate / op_prepare / native
# submit / op_submitted / await_op — as above.
else
# the client gem's own standalone mechanism, e.g. the winipc pattern:
# stack OVERLAPPED + manual-reset hEvent, WaitForSingleObject without the
# GVL with ubf = CancelIoEx, GetOverlappedResult; or a gem-owned wait thread
# for continuous streams.
end
winloop ships no shared standalone wait thread or service (dependency
direction, irreversible association, ownership — clients own their threads).
Winloop::Backend itself does work standalone — create, associate,
prepare/submit, wait-poll (dispatch on tuple size: AFD 2-tuples vs op
4-tuples in the same array), op_result, shutdown — which is how an embedder
or a test drives it deterministically.
Scope and limitations (v0.2)
- Sockets are the focus. TCP/UDP sockets get the full IOCP/AFD path. Pipes and
regular files fall back to a blocking read inside
io_read(async file I/O on Windows is a separate mechanism, planned for later). - Single thread-of-use. One thread runs the loop and resumes fibers; other OS
threads may safely wake it (e.g. a background
Queueproducer), but the loop itself is not a multi-threaded worker pool. IOCTL_AFD_POLLis undocumented; winloop resolves itsntdllentry points at runtime and degrades by raising a clear error if\Device\Afdis unavailable.- The ops API is for gem authors. Buffers are backend-owned only (no IO::Buffer/String pinning — copy-out removes the whole lifetime/compaction bug class). Ops still in flight at shutdown are cancelled and drained for a bounded interval; stragglers may be deliberately leaked rather than freed under possible kernel ownership.
- x64 only. arm64-mswin is expected to work (all code is
_WIN64/uintptr_t-clean, no arch-specific anything — AFD + IOCP are production-proven on arm64 via libuv/Node and mio/Rust) but is untested and unsupported until an arm64-mswin Ruby distribution exists.
How it compares
winloop is a drop-in Fiber::Scheduler, so it works with anything that targets
the scheduler interface. It is not a reimplementation of async — it's the
missing Windows engine such libraries can run on. The reference scheduler shipped
with Ruby is intended for testing and is IO.select-bound; winloop replaces that
selector with a true completion port.
License
MIT. See LICENSE.txt.