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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +312 -0
- data/ext/winproc/extconf.rb +33 -0
- data/ext/winproc/winproc.c +1922 -0
- data/lib/winproc/version.rb +5 -0
- data/lib/winproc.rb +454 -0
- metadata +122 -0
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")
|