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 +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/ext/winsvc/extconf.rb +22 -0
- data/ext/winsvc/winsvc.c +1162 -0
- data/lib/winsvc/version.rb +5 -0
- data/lib/winsvc.rb +723 -0
- metadata +122 -0
data/lib/winsvc.rb
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "winsvc/version"
|
|
4
|
+
require "winsvc/winsvc" # native extension: module bridges + error classes
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
# winsvc — host a Ruby process as a Windows service (SERVICE_WIN32_OWN_PROCESS)
|
|
8
|
+
# with correct SCM integration, plus a minimal installer.
|
|
9
|
+
#
|
|
10
|
+
# require "winsvc" # require winsvc EARLY (30 s dispatcher window from start);
|
|
11
|
+
# # heavy app requires belong INSIDE the block.
|
|
12
|
+
#
|
|
13
|
+
# Winsvc.run("myapp", log: "C:/ProgramData/myapp/service.log") do |svc|
|
|
14
|
+
# require_relative "app" # heavy boot AFTER START_PENDING is reported
|
|
15
|
+
# app = MyApp.new(svc.args)
|
|
16
|
+
# app.start
|
|
17
|
+
# svc.wait # parks; Ctrl-C in a console injects :stop
|
|
18
|
+
# app.stop
|
|
19
|
+
# end # block returned ⇒ STOPPED reported, run returns
|
|
20
|
+
#
|
|
21
|
+
# The same block runs unchanged as a console program (`ruby service.rb`): the
|
|
22
|
+
# dispatcher fails with 1063 when not launched by the SCM, which winsvc detects
|
|
23
|
+
# as console mode. No Ruby ever runs on an SCM thread; controls arrive on a
|
|
24
|
+
# Thread::Queue, so the body cooperates with a Fiber scheduler (winloop) when one
|
|
25
|
+
# is active and works standalone otherwise.
|
|
26
|
+
module Winsvc
|
|
27
|
+
# ---- Win32 service-control codes (verified) ----------------------------
|
|
28
|
+
SERVICE_CONTROL_STOP = 0x00000001
|
|
29
|
+
SERVICE_CONTROL_PAUSE = 0x00000002
|
|
30
|
+
SERVICE_CONTROL_CONTINUE = 0x00000003
|
|
31
|
+
SERVICE_CONTROL_SHUTDOWN = 0x00000005
|
|
32
|
+
SERVICE_CONTROL_POWEREVENT = 0x0000000D
|
|
33
|
+
SERVICE_CONTROL_SESSIONCHANGE = 0x0000000E
|
|
34
|
+
SERVICE_CONTROL_PRESHUTDOWN = 0x0000000F
|
|
35
|
+
|
|
36
|
+
# ---- Win32 accept-mask bits (verified) ---------------------------------
|
|
37
|
+
SERVICE_ACCEPT_STOP = 0x00000001
|
|
38
|
+
SERVICE_ACCEPT_PAUSE_CONTINUE = 0x00000002
|
|
39
|
+
SERVICE_ACCEPT_SHUTDOWN = 0x00000004
|
|
40
|
+
SERVICE_ACCEPT_POWEREVENT = 0x00000040
|
|
41
|
+
SERVICE_ACCEPT_SESSIONCHANGE = 0x00000080
|
|
42
|
+
SERVICE_ACCEPT_PRESHUTDOWN = 0x00000100
|
|
43
|
+
|
|
44
|
+
# ---- Win32 service states (verified) -----------------------------------
|
|
45
|
+
SERVICE_STOPPED = 0x00000001
|
|
46
|
+
SERVICE_START_PENDING = 0x00000002
|
|
47
|
+
SERVICE_STOP_PENDING = 0x00000003
|
|
48
|
+
SERVICE_RUNNING = 0x00000004
|
|
49
|
+
SERVICE_CONTINUE_PENDING = 0x00000005
|
|
50
|
+
SERVICE_PAUSE_PENDING = 0x00000006
|
|
51
|
+
SERVICE_PAUSED = 0x00000007
|
|
52
|
+
|
|
53
|
+
# ---- Win32 start types (verified) --------------------------------------
|
|
54
|
+
SERVICE_AUTO_START = 0x00000002
|
|
55
|
+
SERVICE_DEMAND_START = 0x00000003
|
|
56
|
+
|
|
57
|
+
# ---- Win32 event-type values (verified) --------------------------------
|
|
58
|
+
PBT_APMSUSPEND = 0x0004
|
|
59
|
+
PBT_APMRESUMESUSPEND = 0x0007
|
|
60
|
+
PBT_APMPOWERSTATUSCHANGE = 0x000A
|
|
61
|
+
PBT_APMRESUMEAUTOMATIC = 0x0012
|
|
62
|
+
PBT_POWERSETTINGCHANGE = 0x8013
|
|
63
|
+
|
|
64
|
+
WTS_CONSOLE_CONNECT = 1
|
|
65
|
+
WTS_CONSOLE_DISCONNECT = 2
|
|
66
|
+
WTS_REMOTE_CONNECT = 3
|
|
67
|
+
WTS_REMOTE_DISCONNECT = 4
|
|
68
|
+
WTS_SESSION_LOGON = 5
|
|
69
|
+
WTS_SESSION_LOGOFF = 6
|
|
70
|
+
WTS_SESSION_LOCK = 7
|
|
71
|
+
WTS_SESSION_UNLOCK = 8
|
|
72
|
+
WTS_SESSION_REMOTE_CONTROL = 9
|
|
73
|
+
WTS_SESSION_CREATE = 0xA
|
|
74
|
+
WTS_SESSION_TERMINATE = 0xB
|
|
75
|
+
|
|
76
|
+
# Map accept: symbols -> the accept-mask bit.
|
|
77
|
+
ACCEPT_BITS = {
|
|
78
|
+
stop: SERVICE_ACCEPT_STOP,
|
|
79
|
+
shutdown: SERVICE_ACCEPT_SHUTDOWN,
|
|
80
|
+
preshutdown: SERVICE_ACCEPT_PRESHUTDOWN,
|
|
81
|
+
pause_continue: SERVICE_ACCEPT_PAUSE_CONTINUE,
|
|
82
|
+
power: SERVICE_ACCEPT_POWEREVENT,
|
|
83
|
+
session_change: SERVICE_ACCEPT_SESSIONCHANGE
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
# Map a SERVICE_CONTROL_* code -> the Control#control symbol.
|
|
87
|
+
CONTROL_SYMBOLS = {
|
|
88
|
+
SERVICE_CONTROL_STOP => :stop,
|
|
89
|
+
SERVICE_CONTROL_SHUTDOWN => :shutdown,
|
|
90
|
+
SERVICE_CONTROL_PRESHUTDOWN => :preshutdown,
|
|
91
|
+
SERVICE_CONTROL_PAUSE => :pause,
|
|
92
|
+
SERVICE_CONTROL_CONTINUE => :continue,
|
|
93
|
+
SERVICE_CONTROL_POWEREVENT => :power,
|
|
94
|
+
SERVICE_CONTROL_SESSIONCHANGE => :session_change
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
97
|
+
STOP_CLASS = %i[stop shutdown preshutdown].freeze
|
|
98
|
+
|
|
99
|
+
# A Windows API failure carries the originating error code (GetLastError),
|
|
100
|
+
# set on the exception in C.
|
|
101
|
+
class OSError
|
|
102
|
+
def code
|
|
103
|
+
@code
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A control message delivered to the service body. Every instance is frozen.
|
|
108
|
+
#
|
|
109
|
+
# control :stop :shutdown :preshutdown :pause :continue :power :session_change
|
|
110
|
+
# event_type Integer|nil — PBT_* (:power), WTS_* (:session_change)
|
|
111
|
+
# session_id Integer|nil — WTSSESSION_NOTIFICATION.dwSessionId (:session_change)
|
|
112
|
+
# data String|nil — frozen ASCII-8BIT POWERBROADCAST_SETTING bytes
|
|
113
|
+
# (≤ 512, truncated beyond) for PBT_POWERSETTINGCHANGE
|
|
114
|
+
# time Float — Process::CLOCK_MONOTONIC seconds at delivery
|
|
115
|
+
Control = Struct.new(:control, :event_type, :session_id, :data, :time,
|
|
116
|
+
keyword_init: true) do
|
|
117
|
+
# Every instance is frozen at construction (§2.4).
|
|
118
|
+
def initialize(*)
|
|
119
|
+
super
|
|
120
|
+
freeze
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True for the stop-class controls (:stop, :shutdown, :preshutdown).
|
|
124
|
+
def stop?
|
|
125
|
+
STOP_CLASS.include?(control)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build a frozen Control from one raw C record [code, etype, sid, data, tick].
|
|
129
|
+
def self.from_raw(raw)
|
|
130
|
+
code, etype, sid, data, _tick = raw
|
|
131
|
+
sym = CONTROL_SYMBOLS[code] || :unknown
|
|
132
|
+
event_type = sym == :power || sym == :session_change ? etype : nil
|
|
133
|
+
session_id = sym == :session_change ? sid : nil
|
|
134
|
+
new(control: sym,
|
|
135
|
+
event_type: event_type,
|
|
136
|
+
session_id: session_id,
|
|
137
|
+
data: data, # already frozen ASCII-8BIT or nil
|
|
138
|
+
time: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The object yielded to the Winsvc.run block. A plain Ruby object holding no
|
|
143
|
+
# native pointer; all methods are safe from any thread or fiber.
|
|
144
|
+
class Service
|
|
145
|
+
# +name+ frozen UTF-8; +mode+ :service or :console; +queue+ the control queue.
|
|
146
|
+
def initialize(name, mode, queue)
|
|
147
|
+
@name = name
|
|
148
|
+
@mode = mode
|
|
149
|
+
@queue = queue
|
|
150
|
+
@args = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The name passed to run (frozen UTF-8).
|
|
154
|
+
def name
|
|
155
|
+
@name
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def service?
|
|
159
|
+
@mode == :service
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def console?
|
|
163
|
+
@mode == :console
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# In service mode, the ServiceMain start parameters minus argv[0] (which
|
|
167
|
+
# Windows sets to the service name). In console mode, a frozen copy of ARGV.
|
|
168
|
+
# Frozen array of frozen UTF-8 strings; computed once, memoized.
|
|
169
|
+
def args
|
|
170
|
+
@args ||=
|
|
171
|
+
if service?
|
|
172
|
+
raw = Winsvc.send(:_host_args)
|
|
173
|
+
raw.shift # drop argv[0] (the service name)
|
|
174
|
+
raw.map(&:freeze).freeze
|
|
175
|
+
else
|
|
176
|
+
ARGV.map { |a| a.dup.freeze }.freeze
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Pop the next control from the queue. +timeout+ in seconds (nil = block
|
|
181
|
+
# forever). Returns nil on timeout, and nil immediately once run has torn
|
|
182
|
+
# down (the queue is closed). Under a Fiber scheduler the calling fiber
|
|
183
|
+
# parks; without one it blocks the thread (GVL released by MRI).
|
|
184
|
+
def wait(timeout: nil)
|
|
185
|
+
ms = Winsvc.send(:secs_to_timeout, timeout)
|
|
186
|
+
if ms.nil?
|
|
187
|
+
@queue.pop # block forever (returns nil when the queue is closed)
|
|
188
|
+
else
|
|
189
|
+
@queue.pop(timeout: ms)
|
|
190
|
+
end
|
|
191
|
+
rescue ClosedQueueError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Non-blocking. True from the instant the C handler saw STOP/SHUTDOWN/
|
|
196
|
+
# PRESHUTDOWN (interlocked flag — earlier than queue delivery), or after the
|
|
197
|
+
# first console Ctrl-C.
|
|
198
|
+
def stop_requested?
|
|
199
|
+
Winsvc.send(:_stop_requested)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# wait in a loop, yielding every control INCLUDING the final stop-class one,
|
|
203
|
+
# then return it. Raises ArgumentError without a block.
|
|
204
|
+
def each_control
|
|
205
|
+
raise ArgumentError, "winsvc: each_control requires a block" unless block_given?
|
|
206
|
+
|
|
207
|
+
loop do
|
|
208
|
+
c = wait
|
|
209
|
+
break c if c.nil?
|
|
210
|
+
|
|
211
|
+
yield c
|
|
212
|
+
break c if c.stop?
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Report SERVICE_RUNNING with the configured accept mask. Used to leave
|
|
217
|
+
# START_PENDING (manual_ready: true) and to resume after :continue.
|
|
218
|
+
def running!
|
|
219
|
+
Winsvc.send(:_set_running) if service?
|
|
220
|
+
self
|
|
221
|
+
end
|
|
222
|
+
alias ready! running!
|
|
223
|
+
|
|
224
|
+
# Report SERVICE_PAUSED (accept mask kept). Call after handling :pause.
|
|
225
|
+
def paused!
|
|
226
|
+
Winsvc.send(:_set_paused) if service?
|
|
227
|
+
self
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Re-report the current pending state with dwCheckPoint + 1. +wait_hint+ (ms)
|
|
231
|
+
# overrides the hint for this report. No-op when no transition is pending.
|
|
232
|
+
# Call only on real progress. Raises ArgumentError on a non-positive hint.
|
|
233
|
+
def checkpoint!(wait_hint: nil)
|
|
234
|
+
hint =
|
|
235
|
+
if wait_hint.nil?
|
|
236
|
+
-1
|
|
237
|
+
else
|
|
238
|
+
unless wait_hint.is_a?(Integer) && wait_hint > 0
|
|
239
|
+
raise ArgumentError, "winsvc: wait_hint must be a positive Integer, got #{wait_hint.inspect}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
wait_hint
|
|
243
|
+
end
|
|
244
|
+
Winsvc.send(:_checkpoint, hint) if service?
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
module_function
|
|
250
|
+
|
|
251
|
+
# Validate a service name: String, 1..256 chars, no "/" or "\". Raises
|
|
252
|
+
# ArgumentError otherwise. Returns the frozen name. (Exposed for unit tests.)
|
|
253
|
+
def validate_name!(name)
|
|
254
|
+
raise ArgumentError, "winsvc: name must be a String, got #{name.class}" unless name.is_a?(String)
|
|
255
|
+
|
|
256
|
+
n = name.length
|
|
257
|
+
if n < 1 || n > 256
|
|
258
|
+
raise ArgumentError, "winsvc: name must be 1..256 chars, got #{n}"
|
|
259
|
+
end
|
|
260
|
+
if name.include?("/") || name.include?("\\")
|
|
261
|
+
raise ArgumentError, "winsvc: name must not contain '/' or '\\' (got #{name.inspect})"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
name
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Compose the accept mask from accept: symbols. Must include :stop. Unknown
|
|
268
|
+
# symbols raise ArgumentError naming the offender and the valid set.
|
|
269
|
+
def accept_mask!(accept)
|
|
270
|
+
unless accept.is_a?(Array)
|
|
271
|
+
raise ArgumentError, "winsvc: accept: must be an Array of Symbols, got #{accept.class}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
mask = 0
|
|
275
|
+
accept.each do |sym|
|
|
276
|
+
bit = ACCEPT_BITS[sym]
|
|
277
|
+
unless bit
|
|
278
|
+
raise ArgumentError,
|
|
279
|
+
"winsvc: unknown accept symbol #{sym.inspect}; valid: #{ACCEPT_BITS.keys.inspect}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
mask |= bit
|
|
283
|
+
end
|
|
284
|
+
unless accept.include?(:stop)
|
|
285
|
+
raise ArgumentError, "winsvc: accept: must include :stop (an unstoppable service is a footgun)"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
mask
|
|
289
|
+
end
|
|
290
|
+
private_class_method :accept_mask!
|
|
291
|
+
|
|
292
|
+
def positive_hint!(value, label)
|
|
293
|
+
unless value.is_a?(Integer) && value > 0
|
|
294
|
+
raise ArgumentError, "winsvc: #{label} must be a positive Integer (ms), got #{value.inspect}"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
value
|
|
298
|
+
end
|
|
299
|
+
private_class_method :positive_hint!
|
|
300
|
+
|
|
301
|
+
# seconds -> ms Integer for Thread::Queue#pop(timeout:). nil -> nil (block
|
|
302
|
+
# forever). Negative raises; a tiny-but-positive timeout never collapses to a
|
|
303
|
+
# non-blocking poll (the winipc ms_for discipline, in seconds for Queue#pop).
|
|
304
|
+
def secs_to_timeout(timeout)
|
|
305
|
+
return nil if timeout.nil?
|
|
306
|
+
|
|
307
|
+
t = Float(timeout)
|
|
308
|
+
raise ArgumentError, "winsvc: timeout must be non-negative, got #{timeout.inspect}" if t.negative?
|
|
309
|
+
|
|
310
|
+
# Queue#pop(timeout:) takes seconds; keep a floor so a tiny positive value
|
|
311
|
+
# still blocks rather than polling.
|
|
312
|
+
t.zero? ? 0.0 : [t, 0.001].max
|
|
313
|
+
end
|
|
314
|
+
private_class_method :secs_to_timeout
|
|
315
|
+
|
|
316
|
+
@ran = false
|
|
317
|
+
|
|
318
|
+
# Run your Ruby program as a Windows service. See the module docs and README
|
|
319
|
+
# for the full contract. Returns the block's value (re-raises a block
|
|
320
|
+
# exception after reporting STOPPED).
|
|
321
|
+
def run(name,
|
|
322
|
+
accept: %i[stop shutdown],
|
|
323
|
+
start_wait_hint: 30_000,
|
|
324
|
+
stop_wait_hint: 30_000,
|
|
325
|
+
manual_ready: false,
|
|
326
|
+
log: nil)
|
|
327
|
+
validate_name!(name)
|
|
328
|
+
mask = accept_mask!(accept)
|
|
329
|
+
positive_hint!(start_wait_hint, "start_wait_hint")
|
|
330
|
+
positive_hint!(stop_wait_hint, "stop_wait_hint")
|
|
331
|
+
validate_log!(log)
|
|
332
|
+
raise ArgumentError, "winsvc: Winsvc.run requires a block" unless block_given?
|
|
333
|
+
|
|
334
|
+
# Callable once per process (Win32: one StartServiceCtrlDispatcherW per
|
|
335
|
+
# process). A second call raises StateError, forever.
|
|
336
|
+
if @ran
|
|
337
|
+
raise StateError, "winsvc: Winsvc.run may be called only once per process"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
@ran = true
|
|
341
|
+
frozen_name = name.dup.freeze
|
|
342
|
+
|
|
343
|
+
_host_start(frozen_name, mask, start_wait_hint, stop_wait_hint)
|
|
344
|
+
|
|
345
|
+
# EVERYTHING after a successful _host_start runs inside one begin/ensure so
|
|
346
|
+
# ServiceMain can never park on hRubyDone forever.
|
|
347
|
+
pump = nil
|
|
348
|
+
queue = nil
|
|
349
|
+
saved_int = saved_break = nil
|
|
350
|
+
exit_specific = 0
|
|
351
|
+
mode = nil
|
|
352
|
+
|
|
353
|
+
begin
|
|
354
|
+
mode = _host_wait_mode # :service or :console (raises OSError on dispatch fail)
|
|
355
|
+
|
|
356
|
+
redirect_stdio(log) if mode == :service
|
|
357
|
+
|
|
358
|
+
queue = Thread::Queue.new
|
|
359
|
+
svc = Service.new(frozen_name, mode, queue)
|
|
360
|
+
|
|
361
|
+
pump = start_pump(queue)
|
|
362
|
+
|
|
363
|
+
if mode == :console
|
|
364
|
+
saved_int, saved_break = install_console_traps(queue)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
_set_running unless manual_ready || mode == :console
|
|
368
|
+
|
|
369
|
+
begin
|
|
370
|
+
return yield svc
|
|
371
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
372
|
+
# Service mode: write the diagnostic to the redirected stderr and FLUSH
|
|
373
|
+
# it BEFORE STOPPED is reported (post-STOPPED Ruby is best-effort).
|
|
374
|
+
if mode == :service
|
|
375
|
+
begin
|
|
376
|
+
$stderr.puts(e.full_message(highlight: false))
|
|
377
|
+
$stderr.flush
|
|
378
|
+
rescue StandardError
|
|
379
|
+
# diagnostics are best-effort; never mask the original exception
|
|
380
|
+
end
|
|
381
|
+
exit_specific = 1
|
|
382
|
+
end
|
|
383
|
+
raise
|
|
384
|
+
end
|
|
385
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
386
|
+
# Any failure between a successful _host_start and the block's return — a
|
|
387
|
+
# log: open error, an interrupt during mode-wait — reports STOPPED with
|
|
388
|
+
# 1066/specific 1 (service mode), then propagates.
|
|
389
|
+
exit_specific = 1 if mode == :service && exit_specific.zero?
|
|
390
|
+
raise
|
|
391
|
+
ensure
|
|
392
|
+
restore_console_traps(saved_int, saved_break) if saved_int || saved_break
|
|
393
|
+
if queue
|
|
394
|
+
queue.close
|
|
395
|
+
end
|
|
396
|
+
if pump
|
|
397
|
+
pump.kill
|
|
398
|
+
pump.join
|
|
399
|
+
end
|
|
400
|
+
_host_done(exit_specific)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Validate the log: kwarg (service mode only; ignored in console mode).
|
|
405
|
+
def validate_log!(log)
|
|
406
|
+
return if log.nil? || log.is_a?(String) || log.is_a?(IO)
|
|
407
|
+
|
|
408
|
+
raise ArgumentError, "winsvc: log: must be nil, a path String, or an IO, got #{log.class}"
|
|
409
|
+
end
|
|
410
|
+
private_class_method :validate_log!
|
|
411
|
+
|
|
412
|
+
# Redirect stdio for service mode (SCM-launched processes have invalid std
|
|
413
|
+
# handles). nil ⇒ all → NUL; String ⇒ stdout/stderr append to the file;
|
|
414
|
+
# IO ⇒ stdout/stderr → that IO. STDIN always → NUL.
|
|
415
|
+
def redirect_stdio(log)
|
|
416
|
+
$stdin.reopen(IO::NULL, "r")
|
|
417
|
+
case log
|
|
418
|
+
when nil
|
|
419
|
+
$stdout.reopen(IO::NULL, "w")
|
|
420
|
+
$stderr.reopen(IO::NULL, "w")
|
|
421
|
+
when String
|
|
422
|
+
f = File.open(log, "a") # raises before the block runs on a bad path
|
|
423
|
+
$stdout.reopen(f)
|
|
424
|
+
$stderr.reopen(f)
|
|
425
|
+
$stdout.sync = true
|
|
426
|
+
$stderr.sync = true
|
|
427
|
+
when IO
|
|
428
|
+
$stdout.reopen(log)
|
|
429
|
+
$stderr.reopen(log)
|
|
430
|
+
$stdout.sync = true
|
|
431
|
+
$stderr.sync = true
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
private_class_method :redirect_stdio
|
|
435
|
+
|
|
436
|
+
# The pump Thread: drain raw control records from C into the Queue as frozen
|
|
437
|
+
# Controls. An ordinary Ruby Thread (so pushing to the Queue is legal); the
|
|
438
|
+
# SCM threads never touch Ruby.
|
|
439
|
+
def start_pump(queue)
|
|
440
|
+
Thread.new do
|
|
441
|
+
Thread.current.report_on_exception = false
|
|
442
|
+
loop do
|
|
443
|
+
recs = _wait_control(-1)
|
|
444
|
+
next if recs.nil?
|
|
445
|
+
|
|
446
|
+
recs.each { |r| queue.push(Control.from_raw(r)) }
|
|
447
|
+
end
|
|
448
|
+
rescue ClosedQueueError
|
|
449
|
+
# queue closed during teardown — done
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
private_class_method :start_pump
|
|
453
|
+
|
|
454
|
+
# Console mode: first Ctrl-C/Ctrl-Break injects a synthetic :stop into the
|
|
455
|
+
# same queue (the pywin32 debug-mode pattern), then rebinds both signals to
|
|
456
|
+
# DEFAULT so a second Ctrl-C terminates the process. Returns the previous
|
|
457
|
+
# [INT, BREAK] handlers to restore on return.
|
|
458
|
+
def install_console_traps(_queue)
|
|
459
|
+
rebind = lambda do
|
|
460
|
+
Signal.trap("INT", "DEFAULT")
|
|
461
|
+
begin
|
|
462
|
+
Signal.trap("BREAK", "DEFAULT")
|
|
463
|
+
rescue ArgumentError
|
|
464
|
+
# BREAK may be unavailable in some contexts; ignore
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
handler = lambda do |_sig|
|
|
468
|
+
_inject(SERVICE_CONTROL_STOP, nil, nil, nil)
|
|
469
|
+
rebind.call
|
|
470
|
+
end
|
|
471
|
+
saved_int = Signal.trap("INT", handler)
|
|
472
|
+
saved_break =
|
|
473
|
+
begin
|
|
474
|
+
Signal.trap("BREAK", handler)
|
|
475
|
+
rescue ArgumentError
|
|
476
|
+
nil
|
|
477
|
+
end
|
|
478
|
+
[saved_int, saved_break]
|
|
479
|
+
end
|
|
480
|
+
private_class_method :install_console_traps
|
|
481
|
+
|
|
482
|
+
def restore_console_traps(saved_int, saved_break)
|
|
483
|
+
Signal.trap("INT", saved_int) if saved_int
|
|
484
|
+
begin
|
|
485
|
+
Signal.trap("BREAK", saved_break) if saved_break
|
|
486
|
+
rescue ArgumentError
|
|
487
|
+
# ignore
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
private_class_method :restore_console_traps
|
|
491
|
+
|
|
492
|
+
# ---------------------------------------------------------------- install --
|
|
493
|
+
|
|
494
|
+
# Install the service. REQUIRED kwarg: script: (path to the entry .rb).
|
|
495
|
+
# Requires elevation. Returns true. See README for the full contract.
|
|
496
|
+
def install(name,
|
|
497
|
+
script:,
|
|
498
|
+
display_name: name,
|
|
499
|
+
description: nil,
|
|
500
|
+
args: [],
|
|
501
|
+
start: :demand,
|
|
502
|
+
account: nil,
|
|
503
|
+
password: nil,
|
|
504
|
+
ruby: RbConfig.ruby,
|
|
505
|
+
preshutdown_timeout: nil,
|
|
506
|
+
restart_on_failure: nil)
|
|
507
|
+
validate_name!(name)
|
|
508
|
+
raise ArgumentError, "winsvc: display_name must be a String" unless display_name.is_a?(String)
|
|
509
|
+
unless description.nil? || description.is_a?(String)
|
|
510
|
+
raise ArgumentError, "winsvc: description must be a String or nil"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
binpath = compose_binpath(ruby, script, args)
|
|
514
|
+
start_type, delayed = start_flags(start)
|
|
515
|
+
|
|
516
|
+
if password && account.nil?
|
|
517
|
+
raise ArgumentError, "winsvc: password: requires account:"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
pre_ms = validate_preshutdown(preshutdown_timeout)
|
|
521
|
+
restart, rdelay, reset, noncrash = restart_actions(restart_on_failure)
|
|
522
|
+
|
|
523
|
+
_install(name, display_name, binpath, description, account, password,
|
|
524
|
+
start_type, delayed, pre_ms,
|
|
525
|
+
restart, rdelay, reset, noncrash)
|
|
526
|
+
true
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Stop (bounded) then delete the service. Returns true. Requires elevation.
|
|
530
|
+
def uninstall(name, timeout: 30)
|
|
531
|
+
validate_name!(name)
|
|
532
|
+
t = Float(timeout)
|
|
533
|
+
raise ArgumentError, "winsvc: timeout must be non-negative, got #{timeout.inspect}" if t.negative?
|
|
534
|
+
|
|
535
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + t
|
|
536
|
+
|
|
537
|
+
# Stop the service first (a delete of a running service would mark-for-delete
|
|
538
|
+
# and zombify). 1062 ERROR_SERVICE_NOT_ACTIVE (already stopped — proceed) and
|
|
539
|
+
# 1061 ERROR_SERVICE_CANNOT_ACCEPT_CTRL (START_PENDING/STOP_PENDING — keep
|
|
540
|
+
# polling and re-send) are retriable, not errors. Polls every 250 ms with a
|
|
541
|
+
# plain Ruby sleep, so it stays interruptible and scheduler-friendly.
|
|
542
|
+
unless _service_state(name) == SERVICE_STOPPED # raises NotFound if absent
|
|
543
|
+
until current_state_or_stopped(name) == SERVICE_STOPPED
|
|
544
|
+
case _control_stop(name)
|
|
545
|
+
when :stopped
|
|
546
|
+
break # 1062 — it stopped under us
|
|
547
|
+
# :sent / :retry — keep polling within the budget
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
551
|
+
raise TimeoutError,
|
|
552
|
+
"winsvc: service '#{name}' did not stop within #{timeout}s (not deleted)"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
sleep 0.25 # interruptible + scheduler-friendly for free
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
_delete_service(name)
|
|
560
|
+
true
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Current state of +name+, or SERVICE_STOPPED if it has already disappeared.
|
|
564
|
+
def current_state_or_stopped(name)
|
|
565
|
+
_service_state(name)
|
|
566
|
+
rescue NotFound
|
|
567
|
+
SERVICE_STOPPED
|
|
568
|
+
end
|
|
569
|
+
private_class_method :current_state_or_stopped
|
|
570
|
+
|
|
571
|
+
# Return the equivalent, correctly quoted sc.exe incantation(s), one command
|
|
572
|
+
# per line. Pure function: touches no OS API, needs no elevation. Same
|
|
573
|
+
# validation and binPath composer as install, so the two can never drift.
|
|
574
|
+
def install_command(name,
|
|
575
|
+
script:,
|
|
576
|
+
display_name: name,
|
|
577
|
+
description: nil,
|
|
578
|
+
args: [],
|
|
579
|
+
start: :demand,
|
|
580
|
+
account: nil,
|
|
581
|
+
password: nil,
|
|
582
|
+
ruby: RbConfig.ruby,
|
|
583
|
+
preshutdown_timeout: nil,
|
|
584
|
+
restart_on_failure: nil)
|
|
585
|
+
validate_name!(name)
|
|
586
|
+
binpath = compose_binpath(ruby, script, args)
|
|
587
|
+
start_type, delayed = start_flags(start)
|
|
588
|
+
pre_ms = validate_preshutdown(preshutdown_timeout)
|
|
589
|
+
restart, rdelay, reset, _noncrash = restart_actions(restart_on_failure)
|
|
590
|
+
|
|
591
|
+
# The service name must be quoted exactly like every other value: a valid
|
|
592
|
+
# Windows name may contain spaces (or, if the caller's validate_name! is ever
|
|
593
|
+
# loosened, other metacharacters), and a bare interpolation would let sc.exe
|
|
594
|
+
# mis-parse `my svc` as name `my` + stray token `svc`, or let `foo & calc`
|
|
595
|
+
# inject a shell command. sc_quote keeps whitespace-free names bare, so the
|
|
596
|
+
# common case is unchanged while the install/install_command pair stay aligned.
|
|
597
|
+
qname = sc_quote(name)
|
|
598
|
+
|
|
599
|
+
lines = []
|
|
600
|
+
create = +"sc create #{qname} binpath= #{sc_quote(binpath)}"
|
|
601
|
+
# sc create takes start= demand|auto; delayed-auto is set by a follow-up
|
|
602
|
+
# `sc config` line (the standard sc.exe idiom), so the create line uses auto.
|
|
603
|
+
create << " start= #{sc_start_word(start_type)}"
|
|
604
|
+
create << " obj= #{sc_quote(account)}" if account
|
|
605
|
+
create << " password= #{sc_quote(password)}" if password
|
|
606
|
+
create << " displayname= #{sc_quote(display_name)}" if display_name != name
|
|
607
|
+
lines << create
|
|
608
|
+
|
|
609
|
+
lines << "sc config #{qname} start= delayed-auto" if delayed && start_type == SERVICE_AUTO_START
|
|
610
|
+
lines << "sc description #{qname} #{sc_quote(description)}" if description
|
|
611
|
+
lines << "sc preshutdown #{qname} #{pre_ms}" if pre_ms
|
|
612
|
+
if restart
|
|
613
|
+
actions = "restart/#{rdelay}/restart/#{rdelay}/restart/#{rdelay}"
|
|
614
|
+
lines << "sc failure #{qname} reset= #{reset} actions= #{actions}"
|
|
615
|
+
end
|
|
616
|
+
lines.join("\n")
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# ----------------------------------------------------- shared helpers ------
|
|
620
|
+
|
|
621
|
+
# Compose lpBinaryPathName: "<ruby>" "<script>" <quoted args...>. The ruby and
|
|
622
|
+
# script elements are ALWAYS double-quoted (the interpreter + entry script are
|
|
623
|
+
# the canonical quoted pair); extra args are double-quoted iff they contain
|
|
624
|
+
# whitespace. Embedded double quotes anywhere in ruby/script/args raise
|
|
625
|
+
# ArgumentError (refuse rather than mis-escape — the hard-to-misuse choice).
|
|
626
|
+
# Shared by install and install_command so they can never drift.
|
|
627
|
+
def compose_binpath(ruby, script, args)
|
|
628
|
+
raise ArgumentError, "winsvc: script: is required" if script.nil?
|
|
629
|
+
|
|
630
|
+
ruby_s = ruby.to_s
|
|
631
|
+
script_s = File.expand_path(script.to_s)
|
|
632
|
+
arg_strs = Array(args).map(&:to_s)
|
|
633
|
+
[ruby_s, script_s, *arg_strs].each do |p|
|
|
634
|
+
if p.include?('"')
|
|
635
|
+
raise ArgumentError, "winsvc: embedded double quote in #{p.inspect} (refusing to mis-escape)"
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
quoted = [%("#{ruby_s}"), %("#{script_s}")] + arg_strs.map { |a| quote_if_space(a) }
|
|
639
|
+
quoted.join(" ")
|
|
640
|
+
end
|
|
641
|
+
private_class_method :compose_binpath
|
|
642
|
+
|
|
643
|
+
def quote_if_space(s)
|
|
644
|
+
s =~ /\s/ ? %("#{s}") : s
|
|
645
|
+
end
|
|
646
|
+
private_class_method :quote_if_space
|
|
647
|
+
|
|
648
|
+
# sc.exe value quoting: the binPath needs nested escaped quotes, and any value
|
|
649
|
+
# with whitespace or inner quotes is wrapped + its quotes doubled-as-escaped.
|
|
650
|
+
def sc_quote(value)
|
|
651
|
+
s = value.to_s
|
|
652
|
+
if s.include?('"') || s =~ /\s/
|
|
653
|
+
%("#{s.gsub('"', '\\"')}")
|
|
654
|
+
else
|
|
655
|
+
s
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
private_class_method :sc_quote
|
|
659
|
+
|
|
660
|
+
def sc_start_word(start_type)
|
|
661
|
+
start_type == SERVICE_AUTO_START ? "auto" : "demand"
|
|
662
|
+
end
|
|
663
|
+
private_class_method :sc_start_word
|
|
664
|
+
|
|
665
|
+
# :demand | :auto | :delayed_auto -> [start_type, delayed_flag].
|
|
666
|
+
def start_flags(start)
|
|
667
|
+
case start
|
|
668
|
+
when :demand then [SERVICE_DEMAND_START, false]
|
|
669
|
+
when :auto then [SERVICE_AUTO_START, false]
|
|
670
|
+
when :delayed_auto then [SERVICE_AUTO_START, true]
|
|
671
|
+
else
|
|
672
|
+
raise ArgumentError, "winsvc: start: must be :demand, :auto, or :delayed_auto, got #{start.inspect}"
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
private_class_method :start_flags
|
|
676
|
+
|
|
677
|
+
def validate_preshutdown(value)
|
|
678
|
+
return nil if value.nil?
|
|
679
|
+
|
|
680
|
+
unless value.is_a?(Integer) && value > 0
|
|
681
|
+
raise ArgumentError, "winsvc: preshutdown_timeout must be a positive Integer (ms), got #{value.inspect}"
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
value
|
|
685
|
+
end
|
|
686
|
+
private_class_method :validate_preshutdown
|
|
687
|
+
|
|
688
|
+
# restart_on_failure: nil | true | { delay:, reset:, on_non_crash: } ->
|
|
689
|
+
# [restart?, delay_ms, reset_secs, on_non_crash?].
|
|
690
|
+
def restart_actions(spec)
|
|
691
|
+
case spec
|
|
692
|
+
when nil
|
|
693
|
+
[false, nil, nil, false]
|
|
694
|
+
when true
|
|
695
|
+
[true, 5_000, 86_400, true]
|
|
696
|
+
when Hash
|
|
697
|
+
allowed = %i[delay reset on_non_crash]
|
|
698
|
+
unknown = spec.keys - allowed
|
|
699
|
+
raise ArgumentError, "winsvc: restart_on_failure: unknown keys #{unknown.inspect}" unless unknown.empty?
|
|
700
|
+
|
|
701
|
+
delay = spec.fetch(:delay, 5_000)
|
|
702
|
+
reset = spec.fetch(:reset, 86_400)
|
|
703
|
+
noncrash = spec.fetch(:on_non_crash, true)
|
|
704
|
+
unless delay.is_a?(Integer) && delay >= 0
|
|
705
|
+
raise ArgumentError, "winsvc: restart_on_failure delay: must be a non-negative Integer (ms), got #{delay.inspect}"
|
|
706
|
+
end
|
|
707
|
+
unless reset.is_a?(Integer) && reset >= 0
|
|
708
|
+
raise ArgumentError, "winsvc: restart_on_failure reset: must be a non-negative Integer (s), got #{reset.inspect}"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
[true, delay, reset, noncrash ? true : false]
|
|
712
|
+
else
|
|
713
|
+
raise ArgumentError, "winsvc: restart_on_failure must be nil, true, or a Hash, got #{spec.class}"
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
private_class_method :restart_actions
|
|
717
|
+
|
|
718
|
+
# Hide the C bridges so callers can't skip the validation layer.
|
|
719
|
+
private_class_method :_host_start, :_host_wait_mode, :_host_args, :_wait_control,
|
|
720
|
+
:_inject, :_set_running, :_set_paused, :_checkpoint,
|
|
721
|
+
:_stop_requested, :_dropped_count, :_host_done,
|
|
722
|
+
:_install, :_service_state, :_control_stop, :_delete_service
|
|
723
|
+
end
|