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#wait uses Thread::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 reaching Winsvc.run. Keep the top of the script tiny: require "winsvc" and call Winsvc.run first; do heavy requires and app init inside the block (START_PENDING is already reported by then, and the start watchdog relaxes to 80 s + your wait hint). If you see Error 1053 at sc 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/STDERRNUL.
  • log: "C:/path/to.log": STDOUT/STDERR append to the file (sync = true); STDINNUL. 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

  • STOPPED is reported exactly once, from ServiceMain, 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 stray checkpoint! 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.run per process (Win32: one StartServiceCtrlDispatcherW per process). A second call raises Winsvc::StateError.
  • Do not call exit! or exec inside 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-STOPPED Ruby, including at_exit, is best-effort by MS's own docs).
  • Session 0: no UI, no interactivity. Use :session_change to 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.