winsvc
Host Ruby as a Windows service — real SCM integration with a console dev mode, no Ruby ever on a service-control thread.
Shipping a Ruby program as a Windows service is a minefield. The service control
manager (SCM) gives your process 30 seconds to connect to it or you get a
cryptic Error 1053: The service did not respond to the start or control request in a timely fashion. Your control handler must return in well under 30 seconds
or the SCM declares the service hung. STDOUT/STDERR are invalid handles, so a
stray puts is a landmine. And SetServiceStatus(SERVICE_STOPPED) must be called
exactly once — a second call closes the RPC context handle and can crash the
process.
The existing win32-service gem is a cautionary tale: its control callbacks run
on SCM-owned native threads, calling into Ruby from threads MRI never created
(illegal-by-fragility — MRI cannot attach a foreign thread, so ffi papers over
it by spawning an ephemeral Ruby thread per callback). It polls for controls
once a second, and it always reports checkpoint and wait-hint 0, so a slow stop
looks hung to the SCM.
winsvc fixes all of that. Its control handler is pure C — a memcpy, an event
signal, and an immediate return — drained by a real Ruby thread into a
Thread::Queue. No Ruby ever runs on an SCM thread. SERVICE_STOPPED is reported
exactly once, from ServiceMain, after Ruby has fully unwound. Checkpoints are
only ever honest (no background pumper faking progress). And because the
integration point is a Thread::Queue, the same body cooperates with a fiber
scheduler (winloop) when one is active
and works standalone otherwise. Best of all, the identical block runs unchanged
as a console program for development — ruby service.rb and Ctrl-C just work.
| What | API | ||
|---|---|---|---|
| Run (host or console) | `Winsvc.run(name, accept:, ...) { | svc | ... }` |
| The yielded object | svc.wait, svc.stop_requested?, svc.each_control, svc.ready!, svc.checkpoint!, svc.args |
||
| A control message | Winsvc::Control (#control, #event_type, #session_id, #data, #time, #stop?) |
||
| Install | Winsvc.install(name, script:, ...) |
||
| Uninstall | Winsvc.uninstall(name, timeout:) |
||
| sc.exe generator | Winsvc.install_command(name, script:, ...) |
Requirements
- Windows + a native MSVC (mswin) Ruby, 3.2 or newer (
Service#waitusesThread::Queue#pop(timeout:), which exists from Ruby 3.2). - Not supported on MinGW/UCRT Ruby.
- Visual Studio 2017+ or the Build Tools with the "Desktop development with
C++" workload (cl.exe). No Developer Command Prompt is needed — the build
loads the MSVC environment automatically via
vcvars; point failures at
vcvars doctor. - Elevation (an Administrator shell) is needed only for
install/uninstall. Running and console mode need no special rights.
Supported platform: x64-mswin64. arm64-mswin is expected to work (all code is arch-neutral) but is untested and unsupported until an arm64-mswin Ruby distribution exists.
Install
gem install winsvc
Quick start
# service.rb — a minimal service. The IDENTICAL code runs as a console program.
require "winsvc" # require winsvc EARLY: the SCM gives the process 30 s to reach
# the dispatcher; heavy requires belong INSIDE the block.
Winsvc.run("myapp", log: "C:/ProgramData/myapp/service.log") do |svc|
require_relative "app" # heavy app boot AFTER START_PENDING is reported
app = MyApp.new(svc.args) # svc.args: start parameters (service) / ARGV (console)
app.start
control = svc.wait # parks here; Ctrl-C in a console injects :stop
svc.checkpoint! # honest progress while draining
app.drain_connections
app.stop
end # block returned ⇒ STOPPED reported, run returns
ruby service.rb # console mode: prints to your terminal, Ctrl-C stops it
The 30-second rule. A Ruby service pays interpreter boot +
requires before reachingWinsvc.run. Keep the top of the script tiny:require "winsvc"and callWinsvc.runfirst; do heavyrequires and app init inside the block (START_PENDINGis already reported by then, and the start watchdog relaxes to 80 s + your wait hint). If you seeError 1053atsc start, this is why.
Controls
accept: selects which SCM controls the service accepts. It must include :stop
(an unstoppable service is a footgun).
| Symbol | Accept bit | Default | Notes |
|---|---|---|---|
:stop |
SERVICE_ACCEPT_STOP |
on | required |
:shutdown |
SERVICE_ACCEPT_SHUTDOWN |
on | budget ≈ 5 s (WaitToKillServiceTimeout); persist fast, skip niceties |
: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 |
:pause_continue |
SERVICE_ACCEPT_PAUSE_CONTINUE |
opt-in | call svc.paused! after :pause, svc.running! after :continue |
:power |
SERVICE_ACCEPT_POWEREVENT |
opt-in | Control#event_type is PBT_*; raw POWERBROADCAST_SETTING bytes in #data |
:session_change |
SERVICE_ACCEPT_SESSIONCHANGE |
opt-in | Control#event_type is WTS_*, #session_id is the session |
Each svc.wait returns the next Winsvc::Control (or nil on timeout / after
teardown). svc.each_control is sugar — it waits in a loop, yields every
control including the final stop-class one, then returns it:
Winsvc.run("watcher", accept: [:stop, :shutdown, :pause_continue, :session_change],
manual_ready: true) do |svc|
state = warm_caches { svc.checkpoint! } # progress during slow init
svc.ready! # now RUNNING; the SCM stop button works
svc.each_control do |c|
case c.control
when :pause then state.quiesce; svc.paused!
when :continue then state.resume; svc.running!
when :session_change then log "session #{c.session_id}: WTS event #{c.event_type}"
end # :stop/:shutdown end the loop after the yield
end
state.shutdown
end
For tight work loops that never call wait, poll svc.stop_requested? — it is
true from the instant the C handler saw a stop, earlier than queue delivery.
Installing the service
install / uninstall require an elevated shell; they map
ERROR_ACCESS_DENIED to a clear Winsvc::AccessDenied ("run elevated").
require "winsvc"
Winsvc.install("myapp",
script: "C:/app/service.rb",
display_name: "My Application",
description: "Does app things.",
start: :delayed_auto, # :demand (default) | :auto | :delayed_auto
account: "NT AUTHORITY\\LocalService", # nil ⇒ LocalSystem
restart_on_failure: true) # three RESTART actions; recovers on crash + nonzero exit
# => true (raises Winsvc::AccessDenied unless elevated)
Winsvc.uninstall("myapp") # stops it first (bounded), then deletes
# => true
restart_on_failure: true is shorthand for { delay: 5_000, reset: 86_400, on_non_crash: true }: three SC_ACTION_RESTART entries at delay ms each, a
reset second reset period, and the failure-actions flag that makes recovery fire
on winsvc's own exception exit (see Logging). A queued restart cannot be
canceled — a manually stopped service can restart unexpectedly when the delay
elapses.
When your deployment tooling insists on sc.exe, generate the correctly quoted
incantation (this gets the two community traps right: the mandatory space after
every option=, and binPath's nested quoting):
puts Winsvc.install_command("myapp", script: "C:/app/service.rb")
# => sc create myapp binpath= "\"C:\ruby\bin\ruby.exe\" \"C:\app\service.rb\"" start= demand
install_command is a pure function — no OS calls, no elevation — and shares the
binPath composer with install, so the two can never drift. Passwords appear in
plain text in the returned string.
MarkedForDelete. DeleteService only marks a service for deletion; the
entry lingers until every open handle closes (an open services.msc is the classic
cause), and recreating it before then raises Winsvc::MarkedForDelete. Close the
holder and retry.
Logging
Services run in session 0 with no console, so the three standard streams are
invalid handles — an unredirected puts is a landmine. In service mode winsvc
reopens them before your code runs (this is silently ignored in console mode, so
the identical code runs in both):
log: nil(default):STDIN/STDOUT/STDERR→NUL.log: "C:/path/to.log":STDOUT/STDERRappend to the file (sync = true);STDIN→NUL. A bad path fails the start visibly instead of running mute.log: some_io:STDOUT/STDERR→ that IO.
If the block raises, winsvc writes the exception class, message, and backtrace to
the redirected stderr and flushes it before STOPPED is reported — the
diagnostic is guaranteed, not left to best-effort at-exit printing. It then reports
STOPPED with dwWin32ExitCode = ERROR_SERVICE_SPECIFIC_ERROR (1066) and
dwServiceSpecificExitCode = 1, so the SCM writes System event log EventID
7023 and restart_on_failure: { on_non_crash: true } recovery fires.
For structured logging, point your app's logger at the file too; ETW/event-log emission is winlog's job, not winsvc's.
Cooperative under winloop
winloop is not a dependency and is never required; cooperation is structural —
Service#wait is Thread::Queue#pop, which parks the calling fiber under a
live Fiber.scheduler and blocks the thread without one. Identical code,
correct either way.
require "winsvc"
require "winloop"
Winsvc.run("echo") do |svc| # Winsvc.run is the OUTERMOST frame, wrapping Winloop.run
Winloop.run do
server = TCPServer.new("127.0.0.1", 9292)
Fiber.schedule do
svc.wait # fiber parks; the pump's push wakes the IOCP loop
server.close # unblocks the accept fiber ⇒ all fibers can finish
end
Fiber.schedule do
loop { conn = server.accept; Fiber.schedule { conn.write(conn.readpartial(4096)); conn.close } }
rescue IOError # server closed — done
end
end # Winloop.run returns when every fiber finished
end # ⇒ STOPPED
The stop contract. Winloop.run returns only when every scheduled fiber
finishes — there is no force-stop. So dedicate one fiber to svc.wait; when it
returns a stop-class control, make all other fibers terminate (close listeners,
cancel/drain connection fibers). During a long drain, any fiber may call
svc.checkpoint! (a cheap non-blocking call on the loop thread) to keep the SCM's
STOP_PENDING wait hint honest. Call Winsvc.run from the main thread, outside
Winloop.run — never inside a Fiber.schedule.
Errors
StandardError
└─ Winsvc::Error
├─ Winsvc::OSError # a Windows API failed; #code is GetLastError
│ ├─ Winsvc::AccessDenied # 5 — run elevated
│ ├─ Winsvc::Exists # 1073 / 1078 — name taken
│ ├─ Winsvc::NotFound # 1060 — no such service
│ ├─ Winsvc::MarkedForDelete # 1072 — a previous delete is still pending
│ └─ Winsvc::TimeoutError # uninstall stop-wait deadline exceeded
└─ Winsvc::StateError # API misuse (run called twice, run without a block)
OSError#code returns the captured Win32 error. Plain argument errors raise
Ruby's own ArgumentError/TypeError.
Notes
STOPPEDis reported exactly once, fromServiceMain, after Ruby has fully unwound — guaranteed by construction (the only report site) plus an interlocked guard set under the same critical section as the report. A straycheckpoint!from a leaked thread, or a stop the SCM delivers mid-unwind, can never make the crash-inducing second report.- No background checkpoints, by design. Checkpoints fire only when your code
calls
checkpoint!, and only while a transition is pending — the documented anti-pattern (a timer thread faking progress) is exactly what hangs starts. - One
Winsvc.runper process (Win32: oneStartServiceCtrlDispatcherWper process). A second call raisesWinsvc::StateError. - Do not call
exit!orexecinside the block — winsvc must join the dispatcher so the process never exits while the SCM connection is live. Anything that must happen on stop belongs inside the block, before it returns (post-STOPPEDRuby, includingat_exit, is best-effort by MS's own docs). - Session 0: no UI, no interactivity. Use
:session_changeto observe user sessions. A service is only notified of a logon if it is fully loaded before the logon attempt. - Supported on x64-mswin64. All code is arch-neutral; arm64-mswin is expected to work but is untested until an arm64-mswin Ruby exists.
License
MIT.