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.
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