A critical Windows service — a web app, a print spooler, a custom background agent — occasionally crashes, and nobody notices until a user complains or a customer-facing system goes down. This tutorial builds a Ruby script that checks a list of Windows services, automatically restarts any that have stopped, retries with exponential backoff if the restart doesn’t take immediately, and logs every action, so you get self-healing services and a full audit trail with almost no ongoing effort.
The problem this solves
Windows Task Scheduler can run a script every few minutes, but out of the box it can’t make intelligent decisions about a flapping service, cap the number of restart attempts, or back off between retries. This script adds that policy layer on top of the Windows Service Control Manager: check status, restart if stopped, retry a bounded number of times with increasing delays, and clearly flag anything that still won’t come back up so a human can intervene.
Prerequisites
- Ruby 2.7 or newer running on Windows (tested with Ruby 3.0).
- The
win32-servicegem for the real Windows adapter:gem install win32-service. This gem wraps the Windows Service Control Manager APIs and is Windows-only — it isn’t required to run the test suite in this tutorial. - Administrator privileges to start Windows services (the account running the script, or the Task Scheduler task, needs the “start service” permission for the services you’re monitoring).
How it works
The design deliberately separates the policy (what to do when a service is down) from the mechanism (how to actually talk to Windows), using a small adapter interface:
- ServiceAdapter is an abstract interface with two methods:
status(name)andstart(name). - WindowsServiceAdapter implements that interface for real, using the
win32-servicegem to talk to the Windows Service Control Manager. It only loadswin32/servicewhen actually instantiated, so the file can still be loaded and tested on non-Windows systems. - MockServiceAdapter implements the same interface entirely in memory. It’s used by the automated test suite (and works for local development on any OS), letting you verify the retry and backoff logic without a live Windows host or a service you’re willing to crash on purpose.
- ServiceMonitor contains all the actual policy logic — check, restart, retry with backoff, give up and flag — and works identically regardless of which adapter you hand it.

The complete code
Save this as service_monitor.rb.
#!/usr/bin/env ruby
# service_monitor.rb
#
# Monitors a list of Windows services and automatically restarts any
# that have stopped unexpectedly, with capped retry attempts and a
# simple exponential backoff, logging every action to a file.
#
# This solves a common Windows sysadmin problem: a critical service
# (a web app, a print spooler, a custom agent) occasionally crashes,
# and nobody notices until a user complains. Running this script on a
# schedule (Task Scheduler, every 1-5 minutes) gives you self-healing
# services plus an audit trail of every restart.
#
# The service-control logic is isolated behind a small adapter
# interface (ServiceAdapter) so that:
# - On Windows, WindowsServiceAdapter drives the real Windows Service
# Control Manager via the `win32-service` gem.
# - Anywhere else (including this tutorial's automated tests, and
# Linux/macOS dev boxes), MockServiceAdapter simulates services in
# memory so the monitoring/retry/backoff logic can be exercised
# without a live Windows host.
#
# Usage (on Windows):
# ruby service_monitor.rb --services Spooler,MyAppService --log C:\logs\service_monitor.log
#
# Author: tha-shed.com tutorials
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# ServiceAdapter: abstract interface both adapters implement.
# #status(name) -> :running | :stopped | :unknown
# #start(name) -> true/false
# ---------------------------------------------------------------------------
class ServiceAdapter
def status(_name)
raise NotImplementedError
end
def start(_name)
raise NotImplementedError
end
end
# ---------------------------------------------------------------------------
# WindowsServiceAdapter: real adapter for Windows, using the `win32-service`
# gem (`gem install win32-service`). Only loads win32/service when actually
# instantiated, so this file can still be required/tested on non-Windows
# systems (the require would fail immediately on Windows if the gem is
# missing, which is the desired, explicit failure mode).
# ---------------------------------------------------------------------------
class WindowsServiceAdapter < ServiceAdapter
def initialize
require 'win32/service' # provided by the win32-service gem, Windows-only
@service = Win32::Service
end
def status(name)
info = @service.status(name)
case info.current_state
when 'running' then :running
when 'stopped' then :stopped
else :unknown
end
rescue Win32::Service::Error
:unknown
end
def start(name)
@service.start(name)
# Poll briefly for the service to report "running" since start()
# returns as soon as the request is accepted, not once it's live.
10.times do
sleep 1
return true if status(name) == :running
end
false
rescue Win32::Service::Error
false
end
end
# ---------------------------------------------------------------------------
# MockServiceAdapter: in-memory fake used for local development and for
# the automated tests in this tutorial. Lets you simulate a service that
# is down and see the monitor bring it back up, entirely off Windows.
# ---------------------------------------------------------------------------
class MockServiceAdapter < ServiceAdapter
def initialize(initial_states = {})
@states = initial_states.dup
@start_calls = Hash.new(0)
end
def status(name)
@states.fetch(name, :unknown)
end
def start(name)
@start_calls[name] += 1
@states[name] = :running
true
end
def start_calls_for(name)
@start_calls[name]
end
# Test helper: simulate the service crashing again after being restarted.
def crash!(name)
@states[name] = :stopped
end
end
# ---------------------------------------------------------------------------
# ServiceMonitor: the core policy logic (adapter-agnostic, fully unit
# testable). For each watched service:
# - if running, do nothing
# - if stopped, attempt to start it, up to max_attempts times, with
# an exponential backoff sleep between attempts
# - log every check and every action
# ---------------------------------------------------------------------------
class ServiceMonitor
Result = Struct.new(:name, :initial_status, :action, :final_status, :attempts)
def initialize(adapter:, services:, max_attempts: 3, backoff_base_seconds: 2, logger: $stdout, sleeper: ->(s) { sleep(s) })
@adapter = adapter
@services = services
@max_attempts = max_attempts
@backoff_base_seconds = backoff_base_seconds
@logger = logger
@sleeper = sleeper
end
# Runs one monitoring pass over all configured services and returns an
# array of Result structs describing what happened.
def run_once
@services.map { |name| check(name) }
end
private
def check(name)
initial = @adapter.status(name)
log("#{name}: status=#{initial}")
if initial == :running
return Result.new(name, initial, :none, initial, 0)
end
attempts = 0
final = initial
while attempts < @max_attempts
attempts += 1
backoff = @backoff_base_seconds * (2**(attempts - 1))
log("#{name}: attempt #{attempts}/#{@max_attempts} to restart (backoff #{backoff}s before check)")
@adapter.start(name)
final = @adapter.status(name)
if final == :running
log("#{name}: restarted successfully after #{attempts} attempt(s)")
return Result.new(name, initial, :restarted, final, attempts)
end
@sleeper.call(backoff) if attempts < @max_attempts
end
log("#{name}: FAILED to restart after #{attempts} attempt(s), status=#{final} -- manual intervention required")
Result.new(name, initial, :restart_failed, final, attempts)
end
def log(msg)
@logger.puts("[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{msg}")
end
end
# ---------------------------------------------------------------------------
# CLI entry point (Windows only -- uses WindowsServiceAdapter)
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { max_attempts: 3, backoff: 2, log: nil }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: service_monitor.rb --services NAME1,NAME2 [options]'
opts.on('--services LIST', 'Comma-separated Windows service names') { |v| options[:services] = v.split(',') }
opts.on('--max-attempts N', Integer, 'Max restart attempts per service (default 3)') { |v| options[:max_attempts] = v }
opts.on('--backoff SECONDS', Integer, 'Base backoff in seconds (default 2)') { |v| options[:backoff] = v }
opts.on('--log PATH', 'Log file path (default: stdout)') { |v| options[:log] = v }
end
parser.parse!
if options[:services].nil? || options[:services].empty?
warn parser.banner
exit 1
end
logger = options[:log] ? File.open(options[:log], 'a') : $stdout
logger.sync = true
adapter = WindowsServiceAdapter.new
monitor = ServiceMonitor.new(
adapter: adapter,
services: options[:services],
max_attempts: options[:max_attempts],
backoff_base_seconds: options[:backoff],
logger: logger
)
results = monitor.run_once
failed = results.select { |r| r.action == :restart_failed }
logger.close if options[:log]
exit(failed.empty? ? 0 : 2)
end
Testing without a Windows host
Because ServiceMonitor only depends on the small ServiceAdapter interface, its entire retry-and-backoff policy can be exercised with MockServiceAdapter — no live Windows services required. This is the same test suite that was used to verify the logic in this tutorial before publishing. Save it as service_monitor_test.rb in the same directory as service_monitor.rb:/p>
#!/usr/bin/env ruby
# service_monitor_test.rb
#
# Exercises ServiceMonitor's policy logic (detect stopped -> restart with
# backoff -> give up after max attempts) using MockServiceAdapter, so the
# core behavior can be verified on any OS, including this Linux sandbox,
# without needing a live Windows machine.
#
# Run with: ruby service_monitor_test.rb
require 'stringio'
require_relative 'service_monitor'
failures = 0
def check(condition, description, failures)
if condition
puts " PASS: #{description}"
else
puts " FAIL: #{description}"
failures[0] += 1
end
end
fail_counter = [0]
puts 'Test 1: a running service is left alone'
adapter = MockServiceAdapter.new('Spooler' => :running)
monitor = ServiceMonitor.new(adapter: adapter, services: ['Spooler'], logger: StringIO.new, sleeper: ->(_s) {})
results = monitor.run_once
check(results.first.action == :none, 'action should be :none', fail_counter)
check(adapter.start_calls_for('Spooler').zero?, 'start should not have been called', fail_counter)
puts 'Test 2: a stopped service is restarted on first attempt'
adapter = MockServiceAdapter.new('MyAppService' => :stopped)
monitor = ServiceMonitor.new(adapter: adapter, services: ['MyAppService'], logger: StringIO.new, sleeper: ->(_s) {})
results = monitor.run_once
r = results.first
check(r.action == :restarted, 'action should be :restarted', fail_counter)
check(r.attempts == 1, 'should take exactly 1 attempt', fail_counter)
check(adapter.status('MyAppService') == :running, 'service should now be running', fail_counter)
puts 'Test 3: a service that fails to start exhausts max_attempts and reports failure'
class FlakyAdapter < MockServiceAdapter
def start(name)
# Simulate a service that accepts the start request but crashes
# again immediately -- it never actually reaches :running.
@states[name] = :stopped
true
end
end
adapter = FlakyAdapter.new('BrokenService' => :stopped)
sleep_calls = []
monitor = ServiceMonitor.new(
adapter: adapter, services: ['BrokenService'], max_attempts: 3,
backoff_base_seconds: 1, logger: StringIO.new, sleeper: ->(s) { sleep_calls << s }
)
results = monitor.run_once
r = results.first
check(r.action == :restart_failed, 'action should be :restart_failed', fail_counter)
check(r.attempts == 3, 'should have tried 3 times', fail_counter)
check(sleep_calls == [1, 2], 'backoff should be exponential: 1s then 2s (no sleep after last attempt)', fail_counter)
puts 'Test 4: multiple services are each evaluated independently'
adapter = MockServiceAdapter.new('A' => :running, 'B' => :stopped)
monitor = ServiceMonitor.new(adapter: adapter, services: %w[A B], logger: StringIO.new, sleeper: ->(_s) {})
results = monitor.run_once
check(results.size == 2, 'should return one result per service', fail_counter)
check(results.find { |x| x.name == 'A' }.action == :none, 'A should be untouched', fail_counter)
check(results.find { |x| x.name == 'B' }.action == :restarted, 'B should be restarted', fail_counter)
puts ''
if fail_counter[0].zero?
puts 'ALL TESTS PASSED'
exit 0
else
puts "#{fail_counter[0]} TEST(S) FAILED"
exit 1
end
Step-by-step walkthrough
Why an adapter interface instead of calling win32/service directly? If ServiceMonitor called Windows Service Control Manager APIs directly, the only way to test its retry and backoff logic would be to actually crash a Windows service repeatedly — slow, disruptive, and hard to automate in CI. By hiding the Windows-specific calls behind ServiceAdapter#status and #start, the policy logic becomes plain Ruby that any test runner, on any OS, can exercise in milliseconds.
The backoff is exponential, not fixed. Look at check in ServiceMonitor: each retry waits backoff_base_seconds * (2 ** (attempts - 1)) — so with the default base of 2 seconds, attempts are spaced 2s, 4s, 8s apart. This gives a flapping service a real chance to stabilize instead of hammering it with restart attempts one second apart.
The sleeper is injectable. Notice ServiceMonitor.new takes a sleeper: argument that defaults to a real sleep call. The test suite passes in a fake sleeper that just records the delays it was asked for, which is how service_monitor_test.rb can verify the exact backoff sequence (1s, then 2s) without a test run that actually takes three seconds — and without ever needing sleep to behave differently on Windows versus the CI runner.
WindowsServiceAdapter#start polls before returning success. The Windows SCM’s start call returns as soon as the start request is accepted, not once the service is actually running. The adapter polls status for up to 10 seconds after issuing the start command, so ServiceMonitor never mistakes “Windows accepted the request” for “the service is actually back up.”
Example output
Running the test suite (using the in-memory mock, so this works on any machine including this Linux tutorial-writing sandbox) confirms all four policy scenarios behave correctly:

Usage
# Check and auto-restart the print spooler and a custom app service,
# retrying up to 3 times with exponential backoff (the defaults)
ruby service_monitor.rb --services Spooler,MyAppService
# Be more patient with a slow-starting service: 5 attempts, longer base backoff
ruby service_monitor.rb --services MyAppService --max-attempts 5 --backoff 5
# Log to a file instead of stdout, so Task Scheduler runs leave a persistent trail
ruby service_monitor.rb --services Spooler,MyAppService --log C:\logs\service_monitor.log
# Exit code is 0 if every service ended up running, 2 if any service
# could not be restarted after exhausting all attempts -- wire this
# into a Task Scheduler action that only alerts on non-zero exit.
Setting up the Task Scheduler trigger
Create a new Task Scheduler task with a trigger of “every 5 minutes, repeat indefinitely.” Set the action to start a program: point it at ruby.exe, with the arguments set to the full path of service_monitor.rb plus your --services and --log flags. Run the task whether or not a user is logged on, and use an account with permission to start the target services.
Troubleshooting
LoadError: cannot load such file -- win32/service. The win32-service gem isn’t installed, or you’re running the script on a non-Windows platform where it can’t be. Run gem install win32-service on the target Windows machine. This error will never occur in the test suite since it uses MockServiceAdapter instead.
A service restarts successfully but crashes again within a few minutes. ServiceMonitor only handles one monitoring pass at a time (as triggered by Task Scheduler); it doesn’t track history across runs. If a service is flapping, that will show up as repeated “restarted successfully” log lines over consecutive Task Scheduler runs — treat three or more restarts within an hour as a signal to investigate the service itself rather than relying on the monitor indefinitely.
“Access is denied” when starting a service. The account running the script (or the Task Scheduler task) doesn’t have the “start service” permission for that specific service. Grant it via the service’s security descriptor (sc sdset) or run the task under an account that’s a local administrator.
The script reports a service as :unknown instead of running or stopped. This means Win32::Service.status raised an error — almost always because the service name doesn’t exactly match what’s registered with the SCM. Use sc query state= all at a command prompt to find the exact internal service name (which often differs from its display name).
Extending this script
- Alert on final failure. When a service exhausts its restart attempts, POST to a Slack webhook, PagerDuty, or send an email in addition to logging — don’t rely on someone reading the log file.
- Track flapping across runs. Persist a small JSON or SQLite file recording restart counts per service per day, and escalate differently if a service has already been restarted several times recently.
- Monitor dependent services together. If service B depends on service A, check and restart A first, and only attempt B if A is confirmed running — avoids a wasted restart cycle on B while its dependency is still down.
- Add a WMI-based adapter. As an alternative to
win32-service, aWmiServiceAdapterusing thewin32olestandard library andWin32_ServiceWMI class can query and control services remotely across the network, not just on the local machine. - Health-check beyond “is it running.” For a web app service, extend the adapter to also make an HTTP request to a health endpoint — a service can be “running” in the SCM’s eyes while the application inside it is completely unresponsive.
Sign in to take Cornell notes on this lesson — they save automatically and stay with your account.