winproc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9197e883045d6e76b5dacc657103c377b4ae4ad40a9bd0b92eec8df2fe4c03f4
4
+ data.tar.gz: 1882aedae0b563784feb28585bea6da6887d80489606de8c74bac27b3631fdef
5
+ SHA512:
6
+ metadata.gz: e13824edecf831eaf21e0111e61e5f62d0c39be1dc4593fbcaec5c46fcd66f5448e692040d2115a2969168e7c217869c85bf7faeee4743c5c696f8cb92a8a47a
7
+ data.tar.gz: 9e52f01d45d520668d89634c998db2992b22e1167ea71b22b73855d5677a2f255faf79dbd2317952ed0feba7a1fa80a7a77f9c6b0922aac6837f445d2dc0b236
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-27
4
+
5
+ Initial release.
6
+
7
+ - `Winproc.spawn(*argv, ...)` — argv-array process spawning (no shell ever
8
+ invoked) with CommandLineToArgvW-compatible quoting, exact per-handle
9
+ inheritance (`PROC_THREAD_ATTRIBUTE_HANDLE_LIST`), case-insensitive env
10
+ merge, `:pipe`/`:null`/IO/`:stdout`-merge stdio redirection, atomic job
11
+ placement (`PROC_THREAD_ATTRIBUTE_JOB_LIST`), and `new_process_group:` /
12
+ `no_window:` flags.
13
+ - `Winproc::Process` — `#wait(timeout:)` (GVL-released, interruptible,
14
+ memoizing), `#alive?`, `#exitstatus` (259 STILL_ACTIVE guarded), `#kill`,
15
+ `#pid`, `#stdin`/`#stdout`/`#stderr`, idempotent `#close`/`#closed?`.
16
+ - `Winproc::Job` — anonymous job objects with `kill_on_close:` (crash-safe
17
+ process-tree reaping via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`), `memory:`,
18
+ `process_memory:`, `cpu_percent:` (hard cap), `active_processes:`, and
19
+ `cpu_time:` limits; `#assign`, `#terminate`, `#wait_empty(timeout:)` over a
20
+ private completion port, `#active_processes`, `#close`.
21
+ - `Winproc.pty(*argv, ...)` / `Winproc::PTY` — ConPTY pseudoconsole sessions
22
+ with `#read`/`#write` (binary, VT pass-through), `#resize`, `#process`, and
23
+ deadlock-free `#close`. Resolved at runtime via `GetProcAddress` so the gem
24
+ loads on Windows < 10 1809 (`pty` raises `Unsupported`); `pty_available?`.
25
+ - `Winproc::Stream` — one end of a redirection/ConPTY pipe: `#read`/`#write`
26
+ (binary-safe, GVL-released, `CancelSynchronousIo`-interruptible), `#<<`,
27
+ idempotent `#close`.
28
+ - Elevation helpers — `Winproc.elevated?` (TokenElevation), `Winproc.admin?`
29
+ (linked-token `CheckTokenMembership`), `Winproc.runas` (ShellExecuteEx
30
+ "runas", UAC), `Winproc.with_privilege(name) { ... }` (scoped token
31
+ privileges with the `ERROR_NOT_ALL_ASSIGNED` lie checked).
32
+ - `Winproc.run_blocking` — the winipc cooperation shim: offloads blocking
33
+ native calls to a worker Thread under a fiber scheduler (e.g. winloop) and
34
+ runs inline otherwise. Zero runtime dependencies.
35
+ - A typed error hierarchy (`Error` → `OSError` → `NotFound`/`AccessDenied`/
36
+ `Canceled`/`BrokenPipe`/`ElevationRequired`/`PrivilegeNotHeld`/`Unsupported`,
37
+ plus `ModeError`/`Closed`), each `OSError` carrying `#code`.
38
+
39
+ Windows MSVC (mswin) Ruby only. x64; arm64-mswin is expected to work but is
40
+ untested and unsupported until an arm64-mswin Ruby distribution exists.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ned
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,312 @@
1
+ # winproc
2
+
3
+ **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.**
4
+
5
+ Ruby's `Process.spawn` on Windows gives you a pid and little else. You cannot
6
+ control a process *tree*: kill the shell you launched and its grandchildren
7
+ sail on as orphans.
8
+
9
+ ```
10
+ > p = Process.spawn("cmd", "/c", "start /b ping -n 600 127.0.0.1")
11
+ > Process.kill(:KILL, p) # cmd dies...
12
+ > # ...the ping keeps running. Forever. Nothing reaps it.
13
+ ```
14
+
15
+ There is no terminal emulation either — a spawned CLI detects a pipe instead of
16
+ a console and goes dumb (no color, no prompts, line-buffered) — and there is no
17
+ `runas`, so you cannot elevate from Ruby at all. `childprocess` and `open3`
18
+ don't fill the gap: no job objects, no ConPTY, and they are MinGW-oriented.
19
+
20
+ **This is the gap nothing else filled.** winproc binds the Win32 primitives
21
+ the OS actually intends for this: job objects with kill-on-close (a spawned
22
+ tree dies with you, *even if your Ruby process crashes*), `CreateProcessW` with
23
+ exact argv quoting and per-handle inheritance (`PROC_THREAD_ATTRIBUTE_HANDLE_LIST`,
24
+ no inheritance races), ConPTY pseudoconsoles for real interactive terminal I/O,
25
+ and the UAC/token helpers (`elevated?`, `admin?`, `runas`, scoped privileges).
26
+ Blocking waits release the GVL and cooperate with a fiber scheduler.
27
+
28
+ | What | API |
29
+ |---|---|
30
+ | Spawn (argv array, no shell) | `Winproc.spawn(*argv, …)` → `Winproc::Process` |
31
+ | Process control | `#wait` `#alive?` `#exitstatus` `#kill` `#pid` `#close` |
32
+ | Kill-proof process trees | `Winproc::Job.new(kill_on_close: true, …)` |
33
+ | Job control | `#assign` `#terminate` `#wait_empty` `#active_processes` |
34
+ | Interactive terminals | `Winproc.pty(*argv, …)` → `Winproc::PTY` |
35
+ | Redirection pipes | `Winproc::Stream#read` `#write` `#close` |
36
+ | Elevation | `Winproc.elevated?` `.admin?` `.runas` `.with_privilege` |
37
+
38
+ ## Requirements
39
+
40
+ - **Windows 10 or newer.** ConPTY (`Winproc.pty`) needs Windows 10 1809+
41
+ (build 17763); everything else needs Windows 10. `Winproc.pty_available?`
42
+ probes at runtime, and `Winproc.pty` raises `Winproc::Unsupported` on older
43
+ builds — the gem still loads.
44
+ - A **native MSVC (mswin) Ruby**, version ≥ 3.1 (`x64-mswin64`). **Not
45
+ supported on MinGW/UCRT** Rubies — winproc binds Win32 process/job/
46
+ pseudoconsole/token APIs and is built with `cl.exe`.
47
+ - Visual Studio 2017+ or the Build Tools with the **"Desktop development with
48
+ C++"** workload. Building from source needs no Developer Command Prompt — the
49
+ Rakefile loads the toolchain via the [vcvars](https://github.com/main-path/vcvars)
50
+ gem (`require "vcvars/rake"`); point failures at `vcvars doctor`.
51
+
52
+ x64 only. arm64-mswin is expected to work but is untested and unsupported until
53
+ an arm64-mswin Ruby distribution exists.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ gem install winproc
59
+ ```
60
+
61
+ ## Spawn & capture
62
+
63
+ argv is always an **array** — winproc quotes every element (including
64
+ `argv[0]`) per the CommandLineToArgvW rules, so the classic
65
+ `C:\Program Files\…` token-splitting trap is closed. No shell is ever invoked.
66
+
67
+ ```ruby
68
+ require "winproc"
69
+
70
+ p = Winproc.spawn("ruby", "-e", "puts 6*7; STDERR.puts 'log'",
71
+ stdout: :pipe, stderr: :stdout)
72
+ out = +""
73
+ while (chunk = p.stdout.read) # binary chunks, nil at EOF
74
+ out << chunk
75
+ end
76
+ p.wait # => 0
77
+ out # => "42\nlog\n" (stderr merged into stdout)
78
+ p.close
79
+ ```
80
+
81
+ Feed stdin through a pipe and close it for EOF:
82
+
83
+ ```ruby
84
+ Winproc.spawn("ruby", "-e", "print STDIN.read.upcase",
85
+ stdin: :pipe, stdout: :pipe) do |p|
86
+ p.stdin.write("hello")
87
+ p.stdin.close # EOF for the child
88
+ p.stdout.read # => "HELLO"
89
+ p.wait # => 0
90
+ end # block form ensure-closes the handles
91
+ ```
92
+
93
+ ## Never leak a process tree
94
+
95
+ A `Job` with `kill_on_close: true` (the default) is the safety net: when the
96
+ job handle is closed — by `#close`, by GC, **or by your Ruby process crashing**
97
+ — the OS terminates every process in the tree. Children are placed in the job
98
+ *atomically at creation*, so a child can never spawn a grandchild before being
99
+ jobbed.
100
+
101
+ ```ruby
102
+ Winproc::Job.new do |job| # kill_on_close: true
103
+ p = Winproc.spawn("cmd.exe", "/c", "ping -n 600 127.0.0.1 >NUL", job: job)
104
+ # ... do work ...
105
+ p.close
106
+ end # job closed: the whole tree dies
107
+ ```
108
+
109
+ Kill a tree explicitly with an exit code:
110
+
111
+ ```ruby
112
+ job = Winproc::Job.new
113
+ p = Winproc.spawn("cmd.exe", "/c", "ping -n 60 127.0.0.1 >NUL", job: job)
114
+ job.terminate(9) # ping AND cmd die together
115
+ p.wait # => 9
116
+ p.close; job.close
117
+ ```
118
+
119
+ ## Limits
120
+
121
+ ```ruby
122
+ job = Winproc::Job.new(memory: 256 * 1024 * 1024, # job-wide commit cap (bytes)
123
+ active_processes: 8, # max concurrent processes
124
+ cpu_time: 30.0, # user-mode CPU seconds
125
+ cpu_percent: 50) # hard CPU cap (1..100)
126
+ p = Winproc.spawn("cmd.exe", "/c", "exit 3", job: job, no_window: true)
127
+ p.wait # => 3
128
+ job.wait_empty(timeout: 5) # => true (tree fully gone)
129
+ p.close; job.close
130
+ ```
131
+
132
+ `cpu_percent:` raises `Winproc::Unsupported` under Remote Desktop / Dynamic
133
+ Fair Share Scheduling (the other limits still apply).
134
+
135
+ ## Interactive terminals (ConPTY)
136
+
137
+ ```ruby
138
+ if Winproc.pty_available?
139
+ Winproc.pty("cmd.exe", cols: 120, rows: 30) do |pty|
140
+ pty.write("echo hi conpty\r") # "\r" is Enter (not "\n")
141
+ buf = +""
142
+ buf << pty.read until buf.include?("hi conpty")
143
+ pty.write("exit\r")
144
+ pty.process.wait(timeout: 5) # => 0
145
+ end # close(kill: true) if still alive
146
+ end
147
+ ```
148
+
149
+ `pty.read` returns **binary** bytes: UTF-8 text interleaved with VT escape
150
+ sequences, passed through verbatim. A read may split a multi-byte character or
151
+ a VT sequence — reassembly (and `force_encoding("UTF-8")`) is the caller's job.
152
+
153
+ **Drain before close for full output:** `PTY#close` tears the output pipe down
154
+ to stay deadlock-free, which discards any final frame. To capture everything,
155
+ read until `nil` (after the child exits) *before* closing.
156
+
157
+ ## Elevation
158
+
159
+ ```ruby
160
+ Winproc.elevated? # => false (typical desktop session)
161
+ Winproc.admin? # => true (admin user with a split token)
162
+
163
+ begin
164
+ p = Winproc.runas("cmd.exe", ["/c", "exit 0"]) # UAC prompt appears here
165
+ p&.wait
166
+ p&.close
167
+ rescue Winproc::Canceled
168
+ warn "user declined elevation"
169
+ end
170
+
171
+ Winproc.with_privilege(:debug) do
172
+ # SeDebugPrivilege enabled for the whole process inside this block
173
+ end
174
+ # raises Winproc::PrivilegeNotHeld if the token doesn't hold it
175
+ ```
176
+
177
+ `runas` takes an **argv array** (quoted exactly like `spawn`), never a raw
178
+ command line. It returns a `Winproc::Process`, or `nil` when the shell launched
179
+ without a process handle.
180
+
181
+ ## With a fiber scheduler (winloop)
182
+
183
+ Every blocking call (`Process#wait`, `Stream#read`/`#write`, `Job#wait_empty`,
184
+ `PTY#read`/`#write`, `runas`) releases the GVL and is interruptible standalone.
185
+ Under a live `Fiber.scheduler` (e.g. [winloop](https://github.com/main-path/winloop))
186
+ the call is offloaded to a worker Thread so the calling fiber parks and the
187
+ event loop keeps serving other fibers — with no link-time or require-time
188
+ dependency on the scheduler (it is duck-typed through `Fiber.scheduler`).
189
+
190
+ ```ruby
191
+ Winloop.run do
192
+ Fiber.schedule do
193
+ p = Winproc.spawn("ruby", "-e", "sleep 1")
194
+ p.wait # parks this fiber; the loop keeps running
195
+ p.close
196
+ end
197
+ Fiber.schedule { 50.times { do_other_work; sleep 0.02 } }
198
+ end
199
+ ```
200
+
201
+ ## Library API
202
+
203
+ ```ruby
204
+ Winproc.spawn(*argv, app:, cwd:, env:, stdin:, stdout:, stderr:, job:,
205
+ new_process_group:, no_window:) # => Winproc::Process
206
+ Winproc.pty(*argv, cols:, rows:, app:, cwd:, env:, job:) # => Winproc::PTY
207
+ Winproc.pty_available? # => true | false
208
+ Winproc.elevated? # => true | false (TokenElevation)
209
+ Winproc.admin? # => true | false (linked-token membership)
210
+ Winproc.runas(exe, args = [], cwd:, show:) # => Winproc::Process | nil
211
+ Winproc.with_privilege(name) { ... } # => block value
212
+ Winproc.run_blocking { ... } # => block value (scheduler shim)
213
+
214
+ process.wait(timeout: nil) # => Integer exit code | nil (timeout)
215
+ process.alive? # => true | false
216
+ process.exitstatus # => Integer | nil (nil while running)
217
+ process.kill(exit_code = 1) # => self (TerminateProcess; no children)
218
+ process.pid # => Integer
219
+ process.stdin / .stdout / .stderr # => Winproc::Stream | nil (:pipe slots)
220
+ process.close / .closed?
221
+
222
+ Winproc::Job.new(kill_on_close: true, memory:, process_memory:,
223
+ cpu_percent:, active_processes:, cpu_time:) # => Job
224
+ job.assign(process) # => self (fallback; prefer spawn(job:))
225
+ job.terminate(exit_code = 1)# => self (kill the whole tree, now)
226
+ job.wait_empty(timeout: nil)# => true (empty) | false (timeout)
227
+ job.active_processes # => Integer
228
+ job.close / .closed?
229
+
230
+ stream.read(maxlen = 65536) # => binary String | nil (EOF)
231
+ stream.write(bytes) # => Integer bytes written (all of them)
232
+ stream << bytes # => stream
233
+ stream.close / .closed?
234
+
235
+ pty.read(maxlen = 65536) # => binary String | nil (EOF)
236
+ pty.write(bytes) # => Integer
237
+ pty.resize(cols, rows) # => self
238
+ pty.cols / .rows # => Integer
239
+ pty.process # => Winproc::Process
240
+ pty.close(kill: true) / .closed?
241
+ ```
242
+
243
+ ## How it works
244
+
245
+ - **Inheritance hygiene.** Redirected spawns name *exactly* the ≤3 child std
246
+ handles in `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` (deduped, never pseudo-handles)
247
+ and pass `bInheritHandles = TRUE`, so concurrent spawns from multiple threads
248
+ can never leak each other's pipe ends. Non-redirected spawns inherit nothing.
249
+ - **Crash-safe trees.** `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` is a kernel
250
+ guarantee: the OS reaps the job's processes when its last handle closes — and
251
+ the handle closes when your process dies, however it dies. The job's IOCP is
252
+ associated *before* any process can join, so `wait_empty` never misses the
253
+ "tree empty" message; it also re-verifies the accounting query, so a stale
254
+ message can never produce a false positive.
255
+ - **ConPTY teardown.** `PTY#close` closes the output pipe *before*
256
+ `ClosePseudoConsole` (and never on the thread blocked in `read`), the
257
+ documented ordering that avoids the pre-24H2 deadlock.
258
+ - **Cooperation.** Blocking native calls release the GVL with a cancel-capable
259
+ unblock function, then `Winproc.run_blocking` offloads them to a worker
260
+ Thread under a fiber scheduler. All `close`/`wait`/`read` operations are
261
+ idempotent and safe to call concurrently with an in-flight wait (the waiter
262
+ is woken and raises `Winproc::Closed` before any handle is closed).
263
+
264
+ ## Errors
265
+
266
+ ```
267
+ Winproc::Error < StandardError
268
+ Winproc::OSError < Error # carries #code (Win32 GetLastError)
269
+ Winproc::NotFound # 2 / 3 program not found
270
+ Winproc::AccessDenied # 5
271
+ Winproc::Canceled # 995 / 1223 (UAC "No")
272
+ Winproc::BrokenPipe # 109 / 232 / 233
273
+ Winproc::ElevationRequired # 740 (exe needs UAC — use runas)
274
+ Winproc::PrivilegeNotHeld # 1300
275
+ Winproc::Unsupported # 50 / 120 / 127 (e.g. ConPTY < 1809)
276
+ Winproc::ModeError < Error # read a write end, write a read end
277
+ Winproc::Closed < Error # operation on a closed object
278
+ ```
279
+
280
+ Plain argument mistakes raise Ruby's own `ArgumentError`/`TypeError`.
281
+
282
+ ## Caveats
283
+
284
+ - **Hard kills only.** `Process#kill` is `TerminateProcess` (no children);
285
+ `Job#terminate` / closing a kill-on-close job kills the whole tree. Windows
286
+ has no SIGTERM-style graceful kill; for console apps see `new_process_group:`
287
+ (Ctrl-Break delivery is out of scope for v1).
288
+ - **Ruby can't reap these children.** `Process.wait(p.pid)` raises `ECHILD` —
289
+ winproc spawns via raw `CreateProcessW` and owns all waiting via the native
290
+ handle. Don't mix winproc processes with Ruby's `Process.wait`.
291
+ - **PTY close discards the final frame** unless you drain `pty.read` to `nil`
292
+ first. And a PTY that is GC'd without `#close` **leaks its conhost** until
293
+ process exit (the GC free hook deliberately never calls `ClosePseudoConsole`,
294
+ to avoid an unbounded GVL-held hang during GC). **Always close PTYs
295
+ explicitly.**
296
+ - **The UAC wait is uninterruptible** — there is no API to cancel a consent
297
+ dialog; `runas` blocks until the user answers (cooperative under a scheduler,
298
+ but the worker can't be cancelled mid-prompt).
299
+ - **Quoting covers CRT/CommandLineToArgvW parsers.** Programs that re-parse
300
+ `GetCommandLineW` themselves (notably `cmd.exe` batch files) have their own
301
+ metacharacter rules; winproc never invokes a shell and does not escape for
302
+ `cmd.exe`.
303
+ - **STILL_ACTIVE (259).** `#exitstatus`/`#wait` only read the exit code after
304
+ the process is confirmed exited, so 259 is never reported for a live process
305
+ — but a child that genuinely `exit 259`s is reported as 259 (a Windows
306
+ oddity).
307
+ - **`with_privilege` is process-wide** — the privilege is enabled for every
308
+ thread while the block runs.
309
+
310
+ ## License
311
+
312
+ [MIT](LICENSE.txt).
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the winproc extension — Windows process control (CreateProcessW
4
+ # spawning, job objects, ConPTY pseudoconsoles, and UAC elevation helpers).
5
+
6
+ require "mkmf"
7
+
8
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
9
+ abort <<~MSG
10
+ winproc requires a native Windows MSVC (mswin) Ruby — it binds Win32
11
+ process/job/pseudoconsole control and is built with cl.exe. Your Ruby is
12
+ "#{RbConfig::CONFIG['arch']}".
13
+ MSG
14
+ end
15
+
16
+ # System import libs (bare "NAME.lib" tokens on mswin):
17
+ # advapi32 - token/privilege/SID APIs (OpenProcessToken, AdjustTokenPrivileges,
18
+ # CheckTokenMembership, LookupPrivilegeValueW, AllocateAndInitializeSid)
19
+ # shell32 - ShellExecuteExW ("runas" elevation)
20
+ # ole32 - CoInitializeEx/CoUninitialize (ShellExecuteEx may activate COM shell extensions)
21
+ # kernel32 (process/job/pipe/pseudoconsole) is linked by default.
22
+ # ConPTY entry points (CreatePseudoConsole etc.) are resolved at RUNTIME via
23
+ # GetProcAddress, so the gem loads on Windows < 10 1809 (pty raises Unsupported).
24
+ #
25
+ # Pure C — no -EHsc, so rb_raise/longjmp is the normal, safe error mechanism.
26
+ #
27
+ # Arch-neutral: the guard above keys on /mswin/ only (never the x64 literal) and
28
+ # no /MACHINE:/-arch flags are passed; the C source gates on _WIN64, not _M_X64,
29
+ # and uses ULONG_PTR for handle-sized values, so an arm64-mswin Ruby would build
30
+ # this unchanged once such a distribution exists.
31
+ $libs = [$libs, "advapi32.lib", "shell32.lib", "ole32.lib"].join(" ")
32
+
33
+ create_makefile("winproc/winproc")