The Problem
Every Windows sysadmin has a service that occasionally just… stops. A print spooler that wedges, a custom app service that dies on an unhandled exception, a monitoring agent that gets killed by an overzealous antivirus scan. The Services console shows it as “Stopped” in red, but nobody’s staring at that console at 3am.
This tutorial builds win_service_monitor.rb: a small watchdog that polls one or more Windows services, and if any of them have stopped, restarts them automatically and logs what happened.
Prerequisites
- Ruby 2.5+ for Windows (install via RubyInstaller for Windows or
winget install RubyInstallerTeam.Ruby) - Windows 10/11 or Windows Server, with the built-in
sc.execommand (present on every install) - An elevated (Administrator) prompt — starting a service requires admin rights
- No gems. The script only uses
optparseandopen3from the standard library, shelling out tosc.exefor the actual Windows API work
Why shell out tosc.exeinstead of using a Win32 gem? Gems likewin32-serviceneed native extensions compiled against the exact Ruby/DevKit version on the box, which is one more thing to break during setup on a fleet of servers you don’t fully control.sc.exeships with every copy of Windows going back to NT4 and its text output, while not pretty, is stable enough to parse reliably.
The Complete Code
#!/usr/bin/env ruby
# win_service_monitor.rb
#
# Monitors a list of Windows services and automatically restarts any that
# have stopped unexpectedly. Solves the common on-call problem of "the app
# pool / print spooler / a custom Windows service keeps dying and nobody
# notices until a customer complains."
#
# No gems required -- shells out to the built-in `sc.exe` command (present
# on every Windows install) to query and control services. Only the
# standard library is used otherwise, so this runs on stock Ruby with no
# `bundle install` step.
#
# Usage (run from an elevated/Administrator prompt so sc.exe can start
# services):
# ruby win_service_monitor.rb --services Spooler,W32Time
# ruby win_service_monitor.rb --services MyAppService --interval 30 --restart
# ruby win_service_monitor.rb --services MyAppService --once --log C:\logs\svc_monitor.log
#
# Ruby version tested: 3.0+ (should work on 2.5+). Requires Windows --
# sc.exe is not available on Linux/macOS, so the parsing logic below is
# unit-tested against captured sc.exe output in the accompanying article.
require 'optparse'
require 'open3'
require 'time'
class ServiceCheckError < StandardError; end
# Wraps `sc.exe query <name>` and `sc.exe start <name>`, and parses the
# text output into a simple status symbol. Kept as its own class so the
# parsing logic (the part that's easy to get wrong) can be unit tested
# independently of actually shelling out to Windows.
class WindowsService
attr_reader :name
# sc.exe prints a STATE line like:
# STATE : 4 RUNNING
# STATE : 1 STOPPED
STATE_RE = /STATE\s*:\s*\d+\s+(\w+)/
def initialize(name, runner: method(:default_runner))
@name = name
@runner = runner
end
# Returns one of: :running, :stopped, :start_pending, :stop_pending,
# :paused, :unknown
def status
output, _stderr, exit_status = @runner.call(['sc.exe', 'query', name])
raise ServiceCheckError, "sc.exe query failed for #{name} (exit #{exit_status})" unless exit_status.success?
parse_state(output)
end
def start!
output, stderr, exit_status = @runner.call(['sc.exe', 'start', name])
unless exit_status.success?
raise ServiceCheckError, "failed to start #{name}: #{stderr.strip.empty? ? output.strip : stderr.strip}"
end
true
end
# Parsing is separated from execution so it can be tested with canned
# strings instead of a live Windows machine.
def self.parse_state(sc_output)
m = STATE_RE.match(sc_output)
return :unknown unless m
case m[1].upcase
when 'RUNNING' then :running
when 'STOPPED' then :stopped
when 'START_PENDING' then :start_pending
when 'STOP_PENDING' then :stop_pending
when 'PAUSED' then :paused
else :unknown
end
end
private
def parse_state(sc_output)
self.class.parse_state(sc_output)
end
def default_runner(cmd)
Open3.capture3(*cmd)
end
end
# Ties together a list of services, a polling loop, restart-on-failure
# logic, and simple text logging.
class ServiceMonitor
def initialize(service_names, restart: true, log_path: nil, runner: nil)
@services = service_names.map { |n| runner ? WindowsService.new(n, runner: runner) : WindowsService.new(n) }
@restart = restart
@log_path = log_path
end
# Runs one pass over all monitored services, restarting stopped ones
# if @restart is enabled. Returns an array of result hashes so callers
# (and tests) can inspect what happened without scraping stdout.
def check_once
@services.map do |svc|
result = { service: svc.name, timestamp: Time.now }
begin
state = svc.status
result[:status] = state
if state == :stopped && @restart
svc.start!
result[:action] = :restarted
else
result[:action] = :none
end
rescue ServiceCheckError => e
result[:status] = :error
result[:action] = :none
result[:error] = e.message
end
log(result)
result
end
end
# Polls forever at the given interval (seconds). Ctrl+C to stop.
def watch(interval:)
loop do
check_once
sleep interval
end
end
private
def log(result)
line = format_line(result)
puts line
return unless @log_path
File.open(@log_path, 'a') { |f| f.puts(line) }
end
def format_line(result)
ts = result[:timestamp].strftime('%Y-%m-%d %H:%M:%S')
base = "[#{ts}] #{result[:service]}: status=#{result[:status]} action=#{result[:action]}"
result[:error] ? "#{base} error=\"#{result[:error]}\"" : base
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
options = { interval: 60, once: false, restart: false, log: nil }
OptionParser.new do |opts|
opts.banner = "Usage: ruby win_service_monitor.rb --services NAME1,NAME2 [options]"
opts.on('-s', '--services LIST', 'Comma-separated Windows service names (required)') { |v| options[:services] = v.split(',').map(&:strip) }
opts.on('-i', '--interval N', Integer, 'Seconds between checks in watch mode (default: 60)') { |v| options[:interval] = v }
opts.on('--once', 'Check once and exit instead of looping') { options[:once] = true }
opts.on('--restart', 'Automatically restart services found stopped') { options[:restart] = true }
opts.on('--log PATH', 'Append status lines to this log file') { |v| options[:log] = v }
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
end.parse!
if options[:services].nil? || options[:services].empty?
warn "ERROR: --services is required, e.g. --services Spooler,W32Time"
exit 1
end
monitor = ServiceMonitor.new(options[:services], restart: options[:restart], log_path: options[:log])
if options[:once]
monitor.check_once
else
puts "Watching #{options[:services].join(', ')} every #{options[:interval]}s (Ctrl+C to stop)..."
monitor.watch(interval: options[:interval])
end
end
Step-by-Step Walkthrough
1. Separate parsing from execution
The trickiest part of any “shell out and parse text” script is testing it without the real target system. WindowsService takes an injectable runner (defaulting to a real Open3.capture3 call), and the state-parsing logic lives in a standalone class method, WindowsService.parse_state, that takes a plain string and returns a symbol. That split is what lets the accompanying test suite run entirely on Linux, verifying the regex and control flow against captured sc.exe output without ever touching a Windows box.
2. Reading service state
sc.exe query <name> prints a block like:
SERVICE_NAME: Spooler
TYPE : 110 WIN32_OWN_PROCESS
STATE : 4 RUNNING
(STOPPABLE, PAUSABLE, ACCEPTS_SHUTDOWN)
WIN32_EXIT_CODE : 0 (0x0)
STATE_RE pulls the word after the state number (RUNNING, STOPPED, START_PENDING, etc.) and parse_state maps it onto a Ruby symbol so the rest of the code never has to compare against magic strings.
3. The restart decision
ServiceMonitor#check_once queries every configured service, and if a service comes back :stopped and --restart was passed, calls svc.start!, which shells out to sc.exe start <name>. Without --restart, the monitor only observes and logs — useful for a first dry run to confirm the service list is right before letting the script take action.
4. Watch mode vs. one-shot
--once runs a single check-and-restart pass and exits — ideal for wiring into Windows Task Scheduler on a recurring trigger. Leave it off and the script loops forever on sleep interval — useful for running as a persistent background job (e.g. via NSSM).
5. Logging
Every check writes a line like [2026-07-16 09:01:01] MyAppService: status=stopped action=restarted to stdout and, if --log is given, appends the same line to a file.
Verifying It Without a Windows Box: the Test Suite
Because the parsing and control flow are decoupled from the actual sc.exe call, the whole thing can be unit tested on any machine — including the Linux sandbox this article was written and verified in — by mocking the runner with canned sc.exe output:
# test_win_service_monitor.rb
#
# Unit tests for win_service_monitor.rb's parsing and control-flow logic,
# run on Linux by mocking out the sc.exe calls with canned output. This is
# how the article verifies correctness without a live Windows box.
#
# Run: ruby test_win_service_monitor.rb
require_relative 'win_service_monitor'
require 'ostruct'
failures = 0
assert = lambda do |desc, cond|
if cond
puts "PASS: #{desc}"
else
puts "FAIL: #{desc}"
failures += 1
end
end
# --- Test parse_state against real sc.exe output samples -------------------
running_output = <<~OUT
SERVICE_NAME: Spooler
TYPE : 110 WIN32_OWN_PROCESS
STATE : 4 RUNNING
(STOPPABLE, PAUSABLE, ACCEPTS_SHUTDOWN)
WIN32_EXIT_CODE : 0 (0x0)
OUT
stopped_output = <<~OUT
SERVICE_NAME: MyAppService
TYPE : 10 WIN32_OWN_PROCESS
STATE : 1 STOPPED
WIN32_EXIT_CODE : 1077 (0x435)
OUT
assert.call('parses RUNNING state', WindowsService.parse_state(running_output) == :running)
assert.call('parses STOPPED state', WindowsService.parse_state(stopped_output) == :stopped)
assert.call('returns :unknown for garbage input', WindowsService.parse_state('not sc output') == :unknown)
# --- Test WindowsService#status / #start! with a fake runner ---------------
fake_success = ->(cmd) { [running_output, '', OpenStruct.new(success?: true)] }
svc = WindowsService.new('Spooler', runner: fake_success)
assert.call('WindowsService#status returns :running via mocked runner', svc.status == :running)
fake_query_error = ->(cmd) { ['', 'access denied', OpenStruct.new(success?: false)] }
svc_err = WindowsService.new('Spooler', runner: fake_query_error)
begin
svc_err.status
assert.call('raises ServiceCheckError on failed query', false)
rescue ServiceCheckError
assert.call('raises ServiceCheckError on failed query', true)
end
# --- Test ServiceMonitor#check_once restarts a stopped service -------------
call_log = []
fake_runner = lambda do |cmd|
call_log << cmd
if cmd.include?('query')
[stopped_output, '', OpenStruct.new(success?: true)]
elsif cmd.include?('start')
['SUCCESS', '', OpenStruct.new(success?: true)]
end
end
monitor = ServiceMonitor.new(['MyAppService'], restart: true, runner: fake_runner)
results = monitor.check_once
assert.call('check_once reports stopped status', results.first[:status] == :stopped)
assert.call('check_once restarts a stopped service when restart: true', results.first[:action] == :restarted)
assert.call('runner was called for both query and start', call_log.any? { |c| c.include?('query') } && call_log.any? { |c| c.include?('start') })
# --- Test ServiceMonitor#check_once does NOT restart when restart: false ---
call_log2 = []
fake_runner2 = lambda do |cmd|
call_log2 << cmd
[stopped_output, '', OpenStruct.new(success?: true)]
end
monitor_no_restart = ServiceMonitor.new(['MyAppService'], restart: false, runner: fake_runner2)
results2 = monitor_no_restart.check_once
assert.call('does not restart when restart: false', results2.first[:action] == :none)
assert.call('never calls start when restart: false', call_log2.none? { |c| c.include?('start') })
puts
if failures.zero?
puts "All tests passed."
else
puts "#{failures} test(s) failed."
exit 1
end
Run it with ruby test_win_service_monitor.rb. Keep this test file alongside the script — it catches regressions in the regex or restart logic before they hit a production server at 3am.
Example Output
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
failed to start <name>: Access is denied | Run the prompt (or scheduled task) as Administrator. UAC still applies even if your account is a local admin. |
ServiceCheckError on every query | Use the service’s internal name, not its display name — run sc.exe query state=all and match on SERVICE_NAME:. |
Status keeps showing :unknown | STATE_RE didn’t match. Run sc.exe query <name> manually and confirm the STATE line’s format. |
| Service restarts, then immediately stops again | The service itself is crashing right after start — check Event Viewer’s Application log for the real error. |
| Watch loop doesn’t respond to Ctrl+C promptly | Lower --interval, since Ruby only checks for interrupts between statements. |
Extending It
- Exponential backoff on flapping services: stop auto-restarting after N attempts within a time window instead of restart-looping forever.
- Email/Slack alerts: notify the team whenever
action == :restarted. - WMI event subscriptions: use
WIN32OLEto subscribe to service-change events instead of polling. - Dependency-aware restarts: query
sc.exe qc <name>and restart dependency chains in the right order.
The state-parsing and restart-decision logic in this script is fully unit-tested (see the test suite above, run and passing during the writing of this article). The live sc.exe shell-outs require a Windows host to exercise end-to-end — test in a staging VM before pointing this at production services.


