winproc
Windows process control for Ruby: job-object process trees that can never leak, ConPTY terminals, hygienic spawning, and UAC-aware elevation helpers — safe by default, cooperative with a fiber scheduler.
Ruby's Process.spawn on Windows gives you a pid and little else. You cannot
control a process tree: kill the shell you launched and its grandchildren
sail on as orphans.
> p = Process.spawn("cmd", "/c", "start /b ping -n 600 127.0.0.1")
> Process.kill(:KILL, p) # cmd dies...
> # ...the ping keeps running. Forever. Nothing reaps it.
There is no terminal emulation either — a spawned CLI detects a pipe instead of
a console and goes dumb (no color, no prompts, line-buffered) — and there is no
runas, so you cannot elevate from Ruby at all. childprocess and open3
don't fill the gap: no job objects, no ConPTY, and they are MinGW-oriented.
This is the gap nothing else filled. winproc binds the Win32 primitives
the OS actually intends for this: job objects with kill-on-close (a spawned
tree dies with you, even if your Ruby process crashes), CreateProcessW with
exact argv quoting and per-handle inheritance (PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
no inheritance races), ConPTY pseudoconsoles for real interactive terminal I/O,
and the UAC/token helpers (elevated?, admin?, runas, scoped privileges).
Blocking waits release the GVL and cooperate with a fiber scheduler.
| What | API |
|---|---|
| Spawn (argv array, no shell) | Winproc.spawn(*argv, …) → Winproc::Process |
| Process control | #wait #alive? #exitstatus #kill #pid #close |
| Kill-proof process trees | Winproc::Job.new(kill_on_close: true, …) |
| Job control | #assign #terminate #wait_empty #active_processes |
| Interactive terminals | Winproc.pty(*argv, …) → Winproc::PTY |
| Redirection pipes | Winproc::Stream#read #write #close |
| Elevation | Winproc.elevated? .admin? .runas .with_privilege |
Requirements
- Windows 10 or newer. ConPTY (
Winproc.pty) needs Windows 10 1809+ (build 17763); everything else needs Windows 10.Winproc.pty_available?probes at runtime, andWinproc.ptyraisesWinproc::Unsupportedon older builds — the gem still loads. - A native MSVC (mswin) Ruby, version ≥ 3.1 (
x64-mswin64). Not supported on MinGW/UCRT Rubies — winproc binds Win32 process/job/ pseudoconsole/token APIs and is built withcl.exe. - Visual Studio 2017+ or the Build Tools with the "Desktop development with
C++" workload. Building from source needs no Developer Command Prompt — the
Rakefile loads the toolchain via the vcvars
gem (
require "vcvars/rake"); point failures atvcvars doctor.
x64 only. arm64-mswin is expected to work but is untested and unsupported until an arm64-mswin Ruby distribution exists.
Install
gem install winproc
Spawn & capture
argv is always an array — winproc quotes every element (including
argv[0]) per the CommandLineToArgvW rules, so the classic
C:\Program Files\… token-splitting trap is closed. No shell is ever invoked.
require "winproc"
p = Winproc.spawn("ruby", "-e", "puts 6*7; STDERR.puts 'log'",
stdout: :pipe, stderr: :stdout)
out = +""
while (chunk = p.stdout.read) # binary chunks, nil at EOF
out << chunk
end
p.wait # => 0
out # => "42\nlog\n" (stderr merged into stdout)
p.close
Feed stdin through a pipe and close it for EOF:
Winproc.spawn("ruby", "-e", "print STDIN.read.upcase",
stdin: :pipe, stdout: :pipe) do |p|
p.stdin.write("hello")
p.stdin.close # EOF for the child
p.stdout.read # => "HELLO"
p.wait # => 0
end # block form ensure-closes the handles
Never leak a process tree
A Job with kill_on_close: true (the default) is the safety net: when the
job handle is closed — by #close, by GC, or by your Ruby process crashing
— the OS terminates every process in the tree. Children are placed in the job
atomically at creation, so a child can never spawn a grandchild before being
jobbed.
Winproc::Job.new do |job| # kill_on_close: true
p = Winproc.spawn("cmd.exe", "/c", "ping -n 600 127.0.0.1 >NUL", job: job)
# ... do work ...
p.close
end # job closed: the whole tree dies
Kill a tree explicitly with an exit code:
job = Winproc::Job.new
p = Winproc.spawn("cmd.exe", "/c", "ping -n 60 127.0.0.1 >NUL", job: job)
job.terminate(9) # ping AND cmd die together
p.wait # => 9
p.close; job.close
Limits
job = Winproc::Job.new(memory: 256 * 1024 * 1024, # job-wide commit cap (bytes)
active_processes: 8, # max concurrent processes
cpu_time: 30.0, # user-mode CPU seconds
cpu_percent: 50) # hard CPU cap (1..100)
p = Winproc.spawn("cmd.exe", "/c", "exit 3", job: job, no_window: true)
p.wait # => 3
job.wait_empty(timeout: 5) # => true (tree fully gone)
p.close; job.close
cpu_percent: raises Winproc::Unsupported under Remote Desktop / Dynamic
Fair Share Scheduling (the other limits still apply).
Interactive terminals (ConPTY)
if Winproc.pty_available?
Winproc.pty("cmd.exe", cols: 120, rows: 30) do |pty|
pty.write("echo hi conpty\r") # "\r" is Enter (not "\n")
buf = +""
buf << pty.read until buf.include?("hi conpty")
pty.write("exit\r")
pty.process.wait(timeout: 5) # => 0
end # close(kill: true) if still alive
end
pty.read returns binary bytes: UTF-8 text interleaved with VT escape
sequences, passed through verbatim. A read may split a multi-byte character or
a VT sequence — reassembly (and force_encoding("UTF-8")) is the caller's job.
Drain before close for full output: PTY#close tears the output pipe down
to stay deadlock-free, which discards any final frame. To capture everything,
read until nil (after the child exits) before closing.
Elevation
Winproc.elevated? # => false (typical desktop session)
Winproc.admin? # => true (admin user with a split token)
begin
p = Winproc.runas("cmd.exe", ["/c", "exit 0"]) # UAC prompt appears here
p&.wait
p&.close
rescue Winproc::Canceled
warn "user declined elevation"
end
Winproc.with_privilege(:debug) do
# SeDebugPrivilege enabled for the whole process inside this block
end
# raises Winproc::PrivilegeNotHeld if the token doesn't hold it
runas takes an argv array (quoted exactly like spawn), never a raw
command line. It returns a Winproc::Process, or nil when the shell launched
without a process handle.
With a fiber scheduler (winloop)
Every blocking call (Process#wait, Stream#read/#write, Job#wait_empty,
PTY#read/#write, runas) releases the GVL and is interruptible standalone.
Under a live Fiber.scheduler (e.g. winloop)
the call is offloaded to a worker Thread so the calling fiber parks and the
event loop keeps serving other fibers — with no link-time or require-time
dependency on the scheduler (it is duck-typed through Fiber.scheduler).
Winloop.run do
Fiber.schedule do
p = Winproc.spawn("ruby", "-e", "sleep 1")
p.wait # parks this fiber; the loop keeps running
p.close
end
Fiber.schedule { 50.times { do_other_work; sleep 0.02 } }
end
Library API
Winproc.spawn(*argv, app:, cwd:, env:, stdin:, stdout:, stderr:, job:,
new_process_group:, no_window:) # => Winproc::Process
Winproc.pty(*argv, cols:, rows:, app:, cwd:, env:, job:) # => Winproc::PTY
Winproc.pty_available? # => true | false
Winproc.elevated? # => true | false (TokenElevation)
Winproc.admin? # => true | false (linked-token membership)
Winproc.runas(exe, args = [], cwd:, show:) # => Winproc::Process | nil
Winproc.with_privilege(name) { ... } # => block value
Winproc.run_blocking { ... } # => block value (scheduler shim)
process.wait(timeout: nil) # => Integer exit code | nil (timeout)
process.alive? # => true | false
process.exitstatus # => Integer | nil (nil while running)
process.kill(exit_code = 1) # => self (TerminateProcess; no children)
process.pid # => Integer
process.stdin / .stdout / .stderr # => Winproc::Stream | nil (:pipe slots)
process.close / .closed?
Winproc::Job.new(kill_on_close: true, memory:, process_memory:,
cpu_percent:, active_processes:, cpu_time:) # => Job
job.assign(process) # => self (fallback; prefer spawn(job:))
job.terminate(exit_code = 1)# => self (kill the whole tree, now)
job.wait_empty(timeout: nil)# => true (empty) | false (timeout)
job.active_processes # => Integer
job.close / .closed?
stream.read(maxlen = 65536) # => binary String | nil (EOF)
stream.write(bytes) # => Integer bytes written (all of them)
stream << bytes # => stream
stream.close / .closed?
pty.read(maxlen = 65536) # => binary String | nil (EOF)
pty.write(bytes) # => Integer
pty.resize(cols, rows) # => self
pty.cols / .rows # => Integer
pty.process # => Winproc::Process
pty.close(kill: true) / .closed?
How it works
- Inheritance hygiene. Redirected spawns name exactly the ≤3 child std
handles in
PROC_THREAD_ATTRIBUTE_HANDLE_LIST(deduped, never pseudo-handles) and passbInheritHandles = TRUE, so concurrent spawns from multiple threads can never leak each other's pipe ends. Non-redirected spawns inherit nothing. - Crash-safe trees.
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEis a kernel guarantee: the OS reaps the job's processes when its last handle closes — and the handle closes when your process dies, however it dies. The job's IOCP is associated before any process can join, sowait_emptynever misses the "tree empty" message; it also re-verifies the accounting query, so a stale message can never produce a false positive. - ConPTY teardown.
PTY#closecloses the output pipe beforeClosePseudoConsole(and never on the thread blocked inread), the documented ordering that avoids the pre-24H2 deadlock. - Cooperation. Blocking native calls release the GVL with a cancel-capable
unblock function, then
Winproc.run_blockingoffloads them to a worker Thread under a fiber scheduler. Allclose/wait/readoperations are idempotent and safe to call concurrently with an in-flight wait (the waiter is woken and raisesWinproc::Closedbefore any handle is closed).
Errors
Winproc::Error < StandardError
Winproc::OSError < Error # carries #code (Win32 GetLastError)
Winproc::NotFound # 2 / 3 program not found
Winproc::AccessDenied # 5
Winproc::Canceled # 995 / 1223 (UAC "No")
Winproc::BrokenPipe # 109 / 232 / 233
Winproc::ElevationRequired # 740 (exe needs UAC — use runas)
Winproc::PrivilegeNotHeld # 1300
Winproc::Unsupported # 50 / 120 / 127 (e.g. ConPTY < 1809)
Winproc::ModeError < Error # read a write end, write a read end
Winproc::Closed < Error # operation on a closed object
Plain argument mistakes raise Ruby's own ArgumentError/TypeError.
Caveats
- Hard kills only.
Process#killisTerminateProcess(no children);Job#terminate/ closing a kill-on-close job kills the whole tree. Windows has no SIGTERM-style graceful kill; for console apps seenew_process_group:(Ctrl-Break delivery is out of scope for v1). - Ruby can't reap these children.
Process.wait(p.pid)raisesECHILD— winproc spawns via rawCreateProcessWand owns all waiting via the native handle. Don't mix winproc processes with Ruby'sProcess.wait. - PTY close discards the final frame unless you drain
pty.readtonilfirst. And a PTY that is GC'd without#closeleaks its conhost until process exit (the GC free hook deliberately never callsClosePseudoConsole, to avoid an unbounded GVL-held hang during GC). Always close PTYs explicitly. - The UAC wait is uninterruptible — there is no API to cancel a consent
dialog;
runasblocks until the user answers (cooperative under a scheduler, but the worker can't be cancelled mid-prompt). - Quoting covers CRT/CommandLineToArgvW parsers. Programs that re-parse
GetCommandLineWthemselves (notablycmd.exebatch files) have their own metacharacter rules; winproc never invokes a shell and does not escape forcmd.exe. - STILL_ACTIVE (259).
#exitstatus/#waitonly read the exit code after the process is confirmed exited, so 259 is never reported for a live process — but a child that genuinelyexit 259s is reported as 259 (a Windows oddity). with_privilegeis process-wide — the privilege is enabled for every thread while the block runs.
License
MIT.