winsvc 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: b80eebd3a6fc287a6843a7fda819379f1db2fce75da360b1c2b1c3c81471f034
4
+ data.tar.gz: 2768b981eaf7131757821528dd9e61a3f2a2d3bcadf1e1241cb522b8151d5222
5
+ SHA512:
6
+ metadata.gz: 4c5cb525932c94d6209358ec5ec0af346f65de1435ba7f71db8515f9fbaca6c563990b6f99a0be7c58569faba434855ab28d0941a642936060477d944da3eb5b
7
+ data.tar.gz: ca5e198736f677289ee45bdd8ca2c825af101e073ff36fe61d46678c071e774d38914fc2f6ac494f89fc841bb6b3fa9b350209bc259b6a0904fc113389a3e131
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-27
4
+
5
+ Initial release.
6
+
7
+ - **`Winsvc.run(name, accept:, start_wait_hint:, stop_wait_hint:, manual_ready:,
8
+ log:) { |svc| ... }`** — host a Ruby process as a
9
+ `SERVICE_WIN32_OWN_PROCESS` Windows service. The control handler is pure C (no
10
+ Ruby ever runs on an SCM thread); controls are marshalled through a fixed-size
11
+ C ring + kernel event into a real Ruby pump thread that drains them onto a
12
+ `Thread::Queue`. The identical block runs unchanged as a **console program**
13
+ (dispatcher failure 1063 ⇒ console mode), where Ctrl-C/Ctrl-Break injects a
14
+ synthetic `:stop` and a second Ctrl-C terminates. `SERVICE_STOPPED` is reported
15
+ **exactly once**, from `ServiceMain`, after Ruby has fully unwound; every
16
+ status report executes atomically under one host critical section. A block
17
+ exception writes its backtrace to the log and flushes it **before** STOPPED,
18
+ then reports `1066`/specific `1` and re-raises.
19
+ - **`Winsvc::Service`** — the yielded object: `wait(timeout:)` (parks the fiber
20
+ under a scheduler, blocks the thread without one), `stop_requested?`
21
+ (interlocked, earlier than queue delivery), `each_control`, `ready!` /
22
+ `running!` / `paused!` / `checkpoint!(wait_hint:)` (no-ops in console mode,
23
+ after a latched stop, and after STOPPED — guarded under the host lock),
24
+ `args` (start parameters / ARGV), `service?` / `console?`.
25
+ - **`Winsvc::Control`** — a frozen struct (`control`, `event_type`, `session_id`,
26
+ `data`, `time`, `stop?`) for `:stop`, `:shutdown`, `:preshutdown`, `:pause`,
27
+ `:continue`, `:power` (raw `POWERBROADCAST_SETTING` bytes, ≤ 512, truncated),
28
+ and `:session_change` (`WTS_*` + session id).
29
+ - **`Winsvc.install` / `Winsvc.uninstall` / `Winsvc.install_command`** — a
30
+ minimal installer over `CreateServiceW` with `:demand` / `:auto` /
31
+ `:delayed_auto` start, account/password, description, `preshutdown_timeout:`,
32
+ and `restart_on_failure:` (three `SC_ACTION_RESTART` entries +
33
+ failure-actions-on-non-crash flag, composing with the 1066 exception exit).
34
+ `install` composes binPath with correct quoting and refuses embedded double
35
+ quotes; config-step failures roll the create back via `DeleteService`.
36
+ `uninstall` stops the service first (bounded poll, retrying 1061/1062) and
37
+ raises `TimeoutError` rather than zombifying a running service.
38
+ `install_command` emits the correctly quoted `sc.exe` incantation(s) and is a
39
+ pure function sharing the binPath composer with `install`.
40
+ - **Cooperative under [winloop](https://github.com/main-path/winloop)** with zero
41
+ winloop-specific code — the integration point is `Thread::Queue`. Blocking C
42
+ waits release the GVL with a cancel-capable unblock function, so `Thread#kill` /
43
+ Ctrl-C / VM shutdown break them.
44
+ - Error taxonomy under `Winsvc::Error`: `OSError` (with `#code`) → `AccessDenied`,
45
+ `Exists`, `NotFound`, `MarkedForDelete`, `TimeoutError`; plus `StateError` for
46
+ API misuse.
47
+
48
+ Pure C extension over advapi32 (+ kernel32) — `rb_raise`/longjmp-safe; native
49
+ state is a documented process singleton (one `StartServiceCtrlDispatcherW` per
50
+ process, ever). Requires Ruby **3.2+** (`Thread::Queue#pop(timeout:)`). Windows
51
+ MSVC (mswin) Ruby only.
52
+
53
+ [0.1.0]: https://github.com/main-path/winsvc/releases/tag/v0.1.0
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,270 @@
1
+ # winsvc
2
+
3
+ **Host Ruby as a Windows service — real SCM integration with a console dev mode, no Ruby ever on a service-control thread.**
4
+
5
+ Shipping a Ruby program as a Windows service is a minefield. The service control
6
+ manager (SCM) gives your process **30 seconds** to connect to it or you get a
7
+ cryptic `Error 1053: The service did not respond to the start or control request
8
+ in a timely fashion`. Your control handler must return in well under 30 seconds
9
+ or the SCM declares the service hung. `STDOUT`/`STDERR` are invalid handles, so a
10
+ stray `puts` is a landmine. And `SetServiceStatus(SERVICE_STOPPED)` must be called
11
+ **exactly once** — a second call closes the RPC context handle and can crash the
12
+ process.
13
+
14
+ The existing `win32-service` gem is a cautionary tale: its control callbacks run
15
+ on **SCM-owned native threads**, calling into Ruby from threads MRI never created
16
+ (illegal-by-fragility — MRI cannot attach a foreign thread, so `ffi` papers over
17
+ it by spawning an ephemeral Ruby thread per callback). It **polls** for controls
18
+ once a second, and it always reports checkpoint and wait-hint `0`, so a slow stop
19
+ looks hung to the SCM.
20
+
21
+ winsvc fixes all of that. Its control handler is **pure C** — a memcpy, an event
22
+ signal, and an immediate return — drained by a real Ruby thread into a
23
+ `Thread::Queue`. No Ruby ever runs on an SCM thread. `SERVICE_STOPPED` is reported
24
+ exactly once, from `ServiceMain`, after Ruby has fully unwound. Checkpoints are
25
+ only ever honest (no background pumper faking progress). And because the
26
+ integration point is a `Thread::Queue`, the same body **cooperates with a fiber
27
+ scheduler** ([winloop](https://github.com/main-path/winloop)) when one is active
28
+ and works standalone otherwise. Best of all, the **identical block runs unchanged
29
+ as a console program** for development — `ruby service.rb` and Ctrl-C just work.
30
+
31
+ | What | API |
32
+ |---|---|
33
+ | Run (host or console) | `Winsvc.run(name, accept:, ...) { |svc| ... }` |
34
+ | The yielded object | `svc.wait`, `svc.stop_requested?`, `svc.each_control`, `svc.ready!`, `svc.checkpoint!`, `svc.args` |
35
+ | A control message | `Winsvc::Control` (`#control`, `#event_type`, `#session_id`, `#data`, `#time`, `#stop?`) |
36
+ | Install | `Winsvc.install(name, script:, ...)` |
37
+ | Uninstall | `Winsvc.uninstall(name, timeout:)` |
38
+ | sc.exe generator | `Winsvc.install_command(name, script:, ...)` |
39
+
40
+ ## Requirements
41
+
42
+ - Windows + a native **MSVC (mswin) Ruby**, **3.2 or newer** (`Service#wait` uses
43
+ `Thread::Queue#pop(timeout:)`, which exists from Ruby 3.2).
44
+ - Not supported on MinGW/UCRT Ruby.
45
+ - Visual Studio 2017+ or the Build Tools with the **"Desktop development with
46
+ C++"** workload (cl.exe). No Developer Command Prompt is needed — the build
47
+ loads the MSVC environment automatically via
48
+ [vcvars](https://github.com/main-path/vcvars); point failures at `vcvars doctor`.
49
+ - Elevation (an Administrator shell) is needed **only** for `install` /
50
+ `uninstall`. Running and console mode need no special rights.
51
+
52
+ Supported platform: x64-mswin64. arm64-mswin is expected to work (all code is
53
+ arch-neutral) but is untested and unsupported until an arm64-mswin Ruby
54
+ distribution exists.
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ gem install winsvc
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ ```ruby
65
+ # service.rb — a minimal service. The IDENTICAL code runs as a console program.
66
+ require "winsvc" # require winsvc EARLY: the SCM gives the process 30 s to reach
67
+ # the dispatcher; heavy requires belong INSIDE the block.
68
+
69
+ Winsvc.run("myapp", log: "C:/ProgramData/myapp/service.log") do |svc|
70
+ require_relative "app" # heavy app boot AFTER START_PENDING is reported
71
+ app = MyApp.new(svc.args) # svc.args: start parameters (service) / ARGV (console)
72
+ app.start
73
+
74
+ control = svc.wait # parks here; Ctrl-C in a console injects :stop
75
+ svc.checkpoint! # honest progress while draining
76
+ app.drain_connections
77
+ app.stop
78
+ end # block returned ⇒ STOPPED reported, run returns
79
+ ```
80
+
81
+ ```sh
82
+ ruby service.rb # console mode: prints to your terminal, Ctrl-C stops it
83
+ ```
84
+
85
+ > **The 30-second rule.** A Ruby service pays interpreter boot + `require`s before
86
+ > reaching `Winsvc.run`. Keep the top of the script tiny: `require "winsvc"` and
87
+ > call `Winsvc.run` first; do heavy `require`s and app init **inside the block**
88
+ > (`START_PENDING` is already reported by then, and the start watchdog relaxes to
89
+ > 80 s + your wait hint). If you see `Error 1053` at `sc start`, this is why.
90
+
91
+ ## Controls
92
+
93
+ `accept:` selects which SCM controls the service accepts. It must include `:stop`
94
+ (an unstoppable service is a footgun).
95
+
96
+ | Symbol | Accept bit | Default | Notes |
97
+ |---|---|---|---|
98
+ | `:stop` | `SERVICE_ACCEPT_STOP` | **on** | required |
99
+ | `:shutdown` | `SERVICE_ACCEPT_SHUTDOWN` | **on** | budget ≈ 5 s (`WaitToKillServiceTimeout`); persist fast, skip niceties |
100
+ | `:preshutdown` | `SERVICE_ACCEPT_PRESHUTDOWN` | opt-in | default timeout 10 s since Win10 1703; raise it with `preshutdown_timeout:`. A service that accepts preshutdown will **not** also receive shutdown |
101
+ | `:pause_continue` | `SERVICE_ACCEPT_PAUSE_CONTINUE` | opt-in | call `svc.paused!` after `:pause`, `svc.running!` after `:continue` |
102
+ | `:power` | `SERVICE_ACCEPT_POWEREVENT` | opt-in | `Control#event_type` is `PBT_*`; raw `POWERBROADCAST_SETTING` bytes in `#data` |
103
+ | `:session_change` | `SERVICE_ACCEPT_SESSIONCHANGE` | opt-in | `Control#event_type` is `WTS_*`, `#session_id` is the session |
104
+
105
+ Each `svc.wait` returns the next `Winsvc::Control` (or `nil` on timeout / after
106
+ teardown). `svc.each_control` is sugar — it `wait`s in a loop, yields every
107
+ control including the final stop-class one, then returns it:
108
+
109
+ ```ruby
110
+ Winsvc.run("watcher", accept: [:stop, :shutdown, :pause_continue, :session_change],
111
+ manual_ready: true) do |svc|
112
+ state = warm_caches { svc.checkpoint! } # progress during slow init
113
+ svc.ready! # now RUNNING; the SCM stop button works
114
+ svc.each_control do |c|
115
+ case c.control
116
+ when :pause then state.quiesce; svc.paused!
117
+ when :continue then state.resume; svc.running!
118
+ when :session_change then log "session #{c.session_id}: WTS event #{c.event_type}"
119
+ end # :stop/:shutdown end the loop after the yield
120
+ end
121
+ state.shutdown
122
+ end
123
+ ```
124
+
125
+ For tight work loops that never call `wait`, poll `svc.stop_requested?` — it is
126
+ true from the instant the C handler saw a stop, earlier than queue delivery.
127
+
128
+ ## Installing the service
129
+
130
+ `install` / `uninstall` require an elevated shell; they map
131
+ `ERROR_ACCESS_DENIED` to a clear `Winsvc::AccessDenied` ("run elevated").
132
+
133
+ ```ruby
134
+ require "winsvc"
135
+ Winsvc.install("myapp",
136
+ script: "C:/app/service.rb",
137
+ display_name: "My Application",
138
+ description: "Does app things.",
139
+ start: :delayed_auto, # :demand (default) | :auto | :delayed_auto
140
+ account: "NT AUTHORITY\\LocalService", # nil ⇒ LocalSystem
141
+ restart_on_failure: true) # three RESTART actions; recovers on crash + nonzero exit
142
+ # => true (raises Winsvc::AccessDenied unless elevated)
143
+
144
+ Winsvc.uninstall("myapp") # stops it first (bounded), then deletes
145
+ # => true
146
+ ```
147
+
148
+ `restart_on_failure: true` is shorthand for `{ delay: 5_000, reset: 86_400,
149
+ on_non_crash: true }`: three `SC_ACTION_RESTART` entries at `delay` ms each, a
150
+ `reset` second reset period, and the failure-actions flag that makes recovery fire
151
+ on winsvc's own exception exit (see Logging). A queued restart **cannot be
152
+ canceled** — a manually stopped service can restart unexpectedly when the delay
153
+ elapses.
154
+
155
+ When your deployment tooling insists on `sc.exe`, generate the correctly quoted
156
+ incantation (this gets the two community traps right: the mandatory space after
157
+ every `option=`, and binPath's nested quoting):
158
+
159
+ ```ruby
160
+ puts Winsvc.install_command("myapp", script: "C:/app/service.rb")
161
+ # => sc create myapp binpath= "\"C:\ruby\bin\ruby.exe\" \"C:\app\service.rb\"" start= demand
162
+ ```
163
+
164
+ `install_command` is a pure function — no OS calls, no elevation — and shares the
165
+ binPath composer with `install`, so the two can never drift. Passwords appear in
166
+ plain text in the returned string.
167
+
168
+ **MarkedForDelete.** `DeleteService` only *marks* a service for deletion; the
169
+ entry lingers until every open handle closes (an open services.msc is the classic
170
+ cause), and recreating it before then raises `Winsvc::MarkedForDelete`. Close the
171
+ holder and retry.
172
+
173
+ ## Logging
174
+
175
+ Services run in **session 0** with no console, so the three standard streams are
176
+ invalid handles — an unredirected `puts` is a landmine. In **service mode** winsvc
177
+ reopens them before your code runs (this is silently ignored in console mode, so
178
+ the identical code runs in both):
179
+
180
+ - `log: nil` (default): `STDIN`/`STDOUT`/`STDERR` → `NUL`.
181
+ - `log: "C:/path/to.log"`: `STDOUT`/`STDERR` append to the file (`sync = true`);
182
+ `STDIN` → `NUL`. A bad path fails the start visibly instead of running mute.
183
+ - `log: some_io`: `STDOUT`/`STDERR` → that IO.
184
+
185
+ If the block raises, winsvc writes the exception class, message, and backtrace to
186
+ the redirected stderr and **flushes it before `STOPPED` is reported** — the
187
+ diagnostic is guaranteed, not left to best-effort at-exit printing. It then reports
188
+ `STOPPED` with `dwWin32ExitCode = ERROR_SERVICE_SPECIFIC_ERROR (1066)` and
189
+ `dwServiceSpecificExitCode = 1`, so the SCM writes System event log **EventID
190
+ 7023** and `restart_on_failure: { on_non_crash: true }` recovery fires.
191
+
192
+ For structured logging, point your app's logger at the file too; ETW/event-log
193
+ emission is [winlog](https://github.com/main-path/winlog)'s job, not winsvc's.
194
+
195
+ ## Cooperative under winloop
196
+
197
+ winloop is **not** a dependency and is never required; cooperation is structural —
198
+ `Service#wait` is `Thread::Queue#pop`, which parks the calling **fiber** under a
199
+ live `Fiber.scheduler` and blocks the **thread** without one. Identical code,
200
+ correct either way.
201
+
202
+ ```ruby
203
+ require "winsvc"
204
+ require "winloop"
205
+
206
+ Winsvc.run("echo") do |svc| # Winsvc.run is the OUTERMOST frame, wrapping Winloop.run
207
+ Winloop.run do
208
+ server = TCPServer.new("127.0.0.1", 9292)
209
+ Fiber.schedule do
210
+ svc.wait # fiber parks; the pump's push wakes the IOCP loop
211
+ server.close # unblocks the accept fiber ⇒ all fibers can finish
212
+ end
213
+ Fiber.schedule do
214
+ loop { conn = server.accept; Fiber.schedule { conn.write(conn.readpartial(4096)); conn.close } }
215
+ rescue IOError # server closed — done
216
+ end
217
+ end # Winloop.run returns when every fiber finished
218
+ end # ⇒ STOPPED
219
+ ```
220
+
221
+ **The stop contract.** `Winloop.run` returns only when every scheduled fiber
222
+ finishes — there is no force-stop. So dedicate one fiber to `svc.wait`; when it
223
+ returns a stop-class control, make all other fibers terminate (close listeners,
224
+ cancel/drain connection fibers). During a long drain, any fiber may call
225
+ `svc.checkpoint!` (a cheap non-blocking call on the loop thread) to keep the SCM's
226
+ `STOP_PENDING` wait hint honest. Call `Winsvc.run` from the main thread, *outside*
227
+ `Winloop.run` — never inside a `Fiber.schedule`.
228
+
229
+ ## Errors
230
+
231
+ ```
232
+ StandardError
233
+ └─ Winsvc::Error
234
+ ├─ Winsvc::OSError # a Windows API failed; #code is GetLastError
235
+ │ ├─ Winsvc::AccessDenied # 5 — run elevated
236
+ │ ├─ Winsvc::Exists # 1073 / 1078 — name taken
237
+ │ ├─ Winsvc::NotFound # 1060 — no such service
238
+ │ ├─ Winsvc::MarkedForDelete # 1072 — a previous delete is still pending
239
+ │ └─ Winsvc::TimeoutError # uninstall stop-wait deadline exceeded
240
+ └─ Winsvc::StateError # API misuse (run called twice, run without a block)
241
+ ```
242
+
243
+ `OSError#code` returns the captured Win32 error. Plain argument errors raise
244
+ Ruby's own `ArgumentError`/`TypeError`.
245
+
246
+ ## Notes
247
+
248
+ - **`STOPPED` is reported exactly once**, from `ServiceMain`, after Ruby has fully
249
+ unwound — guaranteed by construction (the only report site) plus an interlocked
250
+ guard set under the same critical section as the report. A stray `checkpoint!`
251
+ from a leaked thread, or a stop the SCM delivers mid-unwind, can never make the
252
+ crash-inducing second report.
253
+ - **No background checkpoints, by design.** Checkpoints fire only when your code
254
+ calls `checkpoint!`, and only while a transition is pending — the documented
255
+ anti-pattern (a timer thread faking progress) is exactly what hangs starts.
256
+ - **One `Winsvc.run` per process** (Win32: one `StartServiceCtrlDispatcherW` per
257
+ process). A second call raises `Winsvc::StateError`.
258
+ - **Do not call `exit!` or `exec` inside the block** — winsvc must join the
259
+ dispatcher so the process never exits while the SCM connection is live.
260
+ Anything that must happen on stop belongs inside the block, before it returns
261
+ (post-`STOPPED` Ruby, including `at_exit`, is best-effort by MS's own docs).
262
+ - **Session 0**: no UI, no interactivity. Use `:session_change` to observe user
263
+ sessions. A service is only notified of a logon if it is fully loaded before the
264
+ logon attempt.
265
+ - Supported on x64-mswin64. All code is arch-neutral; arm64-mswin is expected to
266
+ work but is untested until an arm64-mswin Ruby exists.
267
+
268
+ ## License
269
+
270
+ [MIT](LICENSE.txt).
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the winsvc extension — hosts a Ruby process as a Windows
4
+ # service (SERVICE_WIN32_OWN_PROCESS) with correct SCM integration.
5
+
6
+ require "mkmf"
7
+
8
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
9
+ abort <<~MSG
10
+ winsvc requires a native Windows MSVC (mswin) Ruby — it binds the Windows
11
+ service control manager (StartServiceCtrlDispatcherW / SetServiceStatus /
12
+ CreateServiceW) and is built with cl.exe. Your Ruby is "#{RbConfig::CONFIG['arch']}".
13
+ MSG
14
+ end
15
+
16
+ # Pure C — no -EHsc, so rb_raise/longjmp is the normal, safe error mechanism.
17
+ # System import libs (bare "NAME.lib" tokens on mswin); kernel32 (events, CS,
18
+ # _beginthreadex via the CRT) is linked by default:
19
+ # advapi32 - all winsvc.h APIs (dispatcher, handler registration, status, SCM client)
20
+ $libs = [$libs, "advapi32.lib"].join(" ")
21
+
22
+ create_makefile("winsvc/winsvc")