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, and Winproc.pty raises Winproc::Unsupported on 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 with cl.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 at vcvars 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 pass bInheritHandles = 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_CLOSE is 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, so wait_empty never 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#close closes the output pipe before ClosePseudoConsole (and never on the thread blocked in read), 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_blocking offloads them to a worker Thread under a fiber scheduler. All close/wait/read operations are idempotent and safe to call concurrently with an in-flight wait (the waiter is woken and raises Winproc::Closed before 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#kill is TerminateProcess (no children); Job#terminate / closing a kill-on-close job kills the whole tree. Windows has no SIGTERM-style graceful kill; for console apps see new_process_group: (Ctrl-Break delivery is out of scope for v1).
  • Ruby can't reap these children. Process.wait(p.pid) raises ECHILD — winproc spawns via raw CreateProcessW and owns all waiting via the native handle. Don't mix winproc processes with Ruby's Process.wait.
  • PTY close discards the final frame unless you drain pty.read to nil first. And a PTY that is GC'd without #close leaks its conhost until process exit (the GC free hook deliberately never calls ClosePseudoConsole, 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; runas blocks 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 GetCommandLineW themselves (notably cmd.exe batch files) have their own metacharacter rules; winproc never invokes a shell and does not escape for cmd.exe.
  • STILL_ACTIVE (259). #exitstatus/#wait only read the exit code after the process is confirmed exited, so 259 is never reported for a live process — but a child that genuinely exit 259s is reported as 259 (a Windows oddity).
  • with_privilege is process-wide — the privilege is enabled for every thread while the block runs.

License

MIT.