Module: Winsvc

Defined in:
lib/winsvc.rb,
lib/winsvc/version.rb,
ext/winsvc/winsvc.c

Overview

winsvc — host a Ruby process as a Windows service (SERVICE_WIN32_OWN_PROCESS) with correct SCM integration, plus a minimal installer.

require "winsvc"   # require winsvc EARLY (30 s dispatcher window from start);
                 # heavy app requires belong INSIDE the block.

Winsvc.run("myapp", log: "C:/ProgramData/myapp/service.log") do |svc|
require_relative "app"        # heavy boot AFTER START_PENDING is reported
app = MyApp.new(svc.args)
app.start
svc.wait                      # parks; Ctrl-C in a console injects :stop
app.stop
end                            # block returned ⇒ STOPPED reported, run returns

The same block runs unchanged as a console program (ruby service.rb): the dispatcher fails with 1063 when not launched by the SCM, which winsvc detects as console mode. No Ruby ever runs on an SCM thread; controls arrive on a Thread::Queue, so the body cooperates with a Fiber scheduler (winloop) when one is active and works standalone otherwise.

Defined Under Namespace

Classes: AccessDenied, Control, Error, Exists, MarkedForDelete, NotFound, OSError, Service, StateError, TimeoutError

Constant Summary collapse

SERVICE_CONTROL_STOP =

---- Win32 service-control codes (verified) ----------------------------

0x00000001
SERVICE_CONTROL_PAUSE =
0x00000002
SERVICE_CONTROL_CONTINUE =
0x00000003
SERVICE_CONTROL_SHUTDOWN =
0x00000005
SERVICE_CONTROL_POWEREVENT =
0x0000000D
SERVICE_CONTROL_SESSIONCHANGE =
0x0000000E
SERVICE_CONTROL_PRESHUTDOWN =
0x0000000F
SERVICE_ACCEPT_STOP =

---- Win32 accept-mask bits (verified) ---------------------------------

0x00000001
SERVICE_ACCEPT_PAUSE_CONTINUE =
0x00000002
SERVICE_ACCEPT_SHUTDOWN =
0x00000004
SERVICE_ACCEPT_POWEREVENT =
0x00000040
SERVICE_ACCEPT_SESSIONCHANGE =
0x00000080
SERVICE_ACCEPT_PRESHUTDOWN =
0x00000100
SERVICE_STOPPED =

---- Win32 service states (verified) -----------------------------------

0x00000001
SERVICE_START_PENDING =
0x00000002
SERVICE_STOP_PENDING =
0x00000003
SERVICE_RUNNING =
0x00000004
SERVICE_CONTINUE_PENDING =
0x00000005
SERVICE_PAUSE_PENDING =
0x00000006
SERVICE_PAUSED =
0x00000007
SERVICE_AUTO_START =

---- Win32 start types (verified) --------------------------------------

0x00000002
SERVICE_DEMAND_START =
0x00000003
PBT_APMSUSPEND =

---- Win32 event-type values (verified) --------------------------------

0x0004
PBT_APMRESUMESUSPEND =
0x0007
PBT_APMPOWERSTATUSCHANGE =
0x000A
PBT_APMRESUMEAUTOMATIC =
0x0012
PBT_POWERSETTINGCHANGE =
0x8013
WTS_CONSOLE_CONNECT =
1
WTS_CONSOLE_DISCONNECT =
2
WTS_REMOTE_CONNECT =
3
WTS_REMOTE_DISCONNECT =
4
WTS_SESSION_LOGON =
5
WTS_SESSION_LOGOFF =
6
WTS_SESSION_LOCK =
7
WTS_SESSION_UNLOCK =
8
WTS_SESSION_REMOTE_CONTROL =
9
WTS_SESSION_CREATE =
0xA
WTS_SESSION_TERMINATE =
0xB
ACCEPT_BITS =

Map accept: symbols -> the accept-mask bit.

{
  stop:           SERVICE_ACCEPT_STOP,
  shutdown:       SERVICE_ACCEPT_SHUTDOWN,
  preshutdown:    SERVICE_ACCEPT_PRESHUTDOWN,
  pause_continue: SERVICE_ACCEPT_PAUSE_CONTINUE,
  power:          SERVICE_ACCEPT_POWEREVENT,
  session_change: SERVICE_ACCEPT_SESSIONCHANGE
}.freeze
CONTROL_SYMBOLS =

Map a SERVICE_CONTROL_* code -> the Control#control symbol.

{
  SERVICE_CONTROL_STOP          => :stop,
  SERVICE_CONTROL_SHUTDOWN      => :shutdown,
  SERVICE_CONTROL_PRESHUTDOWN   => :preshutdown,
  SERVICE_CONTROL_PAUSE         => :pause,
  SERVICE_CONTROL_CONTINUE      => :continue,
  SERVICE_CONTROL_POWEREVENT    => :power,
  SERVICE_CONTROL_SESSIONCHANGE => :session_change
}.freeze
STOP_CLASS =
%i[stop shutdown preshutdown].freeze
VERSION =
"0.1.0"

Class Method Summary collapse

Class Method Details

._checkpoint(vhint) ⇒ Object

Winsvc.checkpoint(hint_or-1) -> true. Re-reports the CURRENT pending state with ++checkpoint. No-op when no transition is pending (a checkpoint in RUNNING/PAUSED/STOPPED is invalid). Keeps working after a stop is latched — that is exactly what a draining stop needs.



739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
# File 'ext/winsvc/winsvc.c', line 739

static VALUE
checkpoint(VALUE mod, VALUE vhint)
{
    long hint_in = NUM2LONG(vhint);
    (void)mod;
    EnterCriticalSection(&g_host.lock);
    if (!g_host.stopped_reported && g_host.status) {
        DWORD st = g_host.current_state;
        if (st == SERVICE_START_PENDING || st == SERVICE_STOP_PENDING ||
            st == SERVICE_PAUSE_PENDING  || st == SERVICE_CONTINUE_PENDING) {
            DWORD hint = (hint_in < 0) ? g_host.stop_hint : (DWORD)hint_in;
            report_status_locked(st, 0, g_host.checkpoint + 1, hint, NO_ERROR, 0);
        }
    }
    LeaveCriticalSection(&g_host.lock);
    return Qnil;
}

._control_stop(name) ⇒ Object

Winsvc._control_stop(name) -> :sent | :stopped | :retry. Raises on real errors.



1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
# File 'ext/winsvc/winsvc.c', line 1073

static VALUE
svc_control_stop(VALUE mod, VALUE name)
{
    ctlstop_t a;
    (void)mod;
    memset(&a, 0, sizeof(a));
    a.name = to_wide(name);
    rb_thread_call_without_gvl(ctlstop_fn, &a, NULL, NULL);
    xfree(a.name);
    if (a.failed_api) raise_gle(a.failed_api, a.gle);
    if (a.result == 1) return ID2SYM(rb_intern("stopped"));
    if (a.result == 2) return ID2SYM(rb_intern("retry"));
    return ID2SYM(rb_intern("sent"));
}

._delete_service(name) ⇒ Object

Winsvc._delete_service(name) -> true. Raises NotFound / MarkedForDelete / etc.



1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
# File 'ext/winsvc/winsvc.c', line 1115

static VALUE
svc_delete_service(VALUE mod, VALUE name)
{
    del_t a;
    (void)mod;
    memset(&a, 0, sizeof(a));
    a.name = to_wide(name);
    rb_thread_call_without_gvl(del_fn, &a, NULL, NULL);
    xfree(a.name);
    if (a.failed_api) raise_gle(a.failed_api, a.gle);
    return Qtrue;
}

._dropped_countObject

Winsvc._dropped_count -> Integer (ring-overflow counter; diagnostics).



766
767
768
769
770
771
772
773
774
775
# File 'ext/winsvc/winsvc.c', line 766

static VALUE
dropped_count(VALUE mod)
{
    unsigned d;
    (void)mod;
    EnterCriticalSection(&g_host.lock);
    d = g_host.dropped;
    LeaveCriticalSection(&g_host.lock);
    return UINT2NUM(d);
}

._host_argsObject

Winsvc._host_args -> Array (frozen UTF-8), the ServiceMain argv.



542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# File 'ext/winsvc/winsvc.c', line 542

static VALUE
host_args(VALUE mod)
{
    VALUE ary;
    int i;
    (void)mod;
    ary = rb_ary_new_capa(g_host.argc > 0 ? g_host.argc : 0);
    for (i = 0; i < g_host.argc; i++) {
        const char *s = g_host.argv_utf8 ? g_host.argv_utf8[i] : NULL;
        VALUE str = rb_utf8_str_new_cstr(s ? s : "");
        rb_str_freeze(str);
        rb_ary_push(ary, str);
    }
    return ary;
}

._host_done(vspecific) ⇒ Object

Winsvc._host_done(specific_exit) -> true. Record the exit code, SetEvent hRubyDone (ServiceMain reports STOPPED), then JOIN the dispatcher thread.

NULL ubf, DELIBERATELY: hRubyDone is already set, so ServiceMain reports STOPPED and the dispatcher returns within milliseconds; this join MUST land so the process never outlives/abandons a live SCM connection (research trap 13). Bounded-in-practice, documented like phylax's PBKDF2.

specific_exit: 0 ⇒ clean (NO_ERROR); nonzero ⇒ 1066 ERROR_SERVICE_SPECIFIC_ERROR with that dwServiceSpecificExitCode. In console mode the dispatcher already exited (1063), so the join returns immediately and no STOPPED is reported.



800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'ext/winsvc/winsvc.c', line 800

static VALUE
host_done(VALUE mod, VALUE vspecific)
{
    DWORD specific = NUM2ULONG(vspecific);
    join_t j;
    (void)mod;

    EnterCriticalSection(&g_host.lock);
    if (specific == 0) {
        g_host.win32_exit    = NO_ERROR;
        g_host.specific_exit = 0;
    } else {
        g_host.win32_exit    = ERROR_SERVICE_SPECIFIC_ERROR; /* 1066 */
        g_host.specific_exit = specific;
    }
    LeaveCriticalSection(&g_host.lock);

    SetEvent(g_host.hRubyDone);

    if (g_host.hDispatcher) {
        j.h = g_host.hDispatcher;
        rb_thread_call_without_gvl(join_fn, &j, NULL, NULL);
        CloseHandle(g_host.hDispatcher);
        g_host.hDispatcher = NULL;
    }
    return Qtrue;
}

._host_start(name, accept_mask, start_hint, stop_hint) ⇒ Object

Winsvc._host_start(name, accept_mask, start_hint, stop_hint) -> true Create events/CS/ring, spawn the dispatcher thread. One-shot interlocked latch: a second call raises StateError before touching the OS.



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'ext/winsvc/winsvc.c', line 445

static VALUE
host_start(VALUE mod, VALUE name, VALUE accept_mask, VALUE start_hint, VALUE stop_hint)
{
    DWORD mask  = NUM2ULONG(accept_mask);
    DWORD shint = NUM2ULONG(start_hint);
    DWORD phint = NUM2ULONG(stop_hint);
    WCHAR *nw;
    uintptr_t th;
    (void)mod;

    if (InterlockedCompareExchange(&g_host.used, 1, 0) != 0)
        rb_raise(eStateError, "winsvc: Winsvc.run may be called only once per process");

    nw = to_wide_malloc(name);
    if (!nw) {
        g_host.used = 1; /* stays latched; this process can never host again */
        rb_raise(eError, "winsvc: invalid UTF-8 in service name");
    }
    g_host.name_w     = nw;
    g_host.accept_mask = mask;
    g_host.start_hint  = shint;
    g_host.stop_hint   = phint;

    InitializeCriticalSection(&g_host.lock);

    /* manual-reset: hStarted, hConsoleMode, hDispatchFail, hRubyDone.
     * auto-reset:   hControl, hPumpWake, hMainWake. (7 total) */
    g_host.hStarted      = CreateEventW(NULL, TRUE,  FALSE, NULL);
    g_host.hConsoleMode  = CreateEventW(NULL, TRUE,  FALSE, NULL);
    g_host.hDispatchFail = CreateEventW(NULL, TRUE,  FALSE, NULL);
    g_host.hRubyDone     = CreateEventW(NULL, TRUE,  FALSE, NULL);
    g_host.hControl      = CreateEventW(NULL, FALSE, FALSE, NULL);
    g_host.hPumpWake     = CreateEventW(NULL, FALSE, FALSE, NULL);
    g_host.hMainWake     = CreateEventW(NULL, FALSE, FALSE, NULL);
    if (!g_host.hStarted || !g_host.hConsoleMode || !g_host.hDispatchFail ||
        !g_host.hRubyDone || !g_host.hControl || !g_host.hPumpWake || !g_host.hMainWake)
        raise_gle("CreateEvent", GetLastError());

    th = _beginthreadex(NULL, 0, dispatcher_thread, NULL, 0, NULL);
    if (th == 0)
        rb_raise(eError, "winsvc: failed to start the dispatcher thread");
    g_host.hDispatcher = (HANDLE)th;

    return Qtrue;
}

._host_wait_modeObject

Winsvc._host_wait_mode -> :service | :console (raises OSError on dispatch failure). GVL released; ubf wakes via hMainWake so Thread#kill / Ctrl-C break it, then we re-wait.



518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'ext/winsvc/winsvc.c', line 518

static VALUE
host_wait_mode(VALUE mod)
{
    (void)mod;
    for (;;) {
        mode_wait_t w;
        w.which = WAIT_FAILED;
        rb_thread_call_without_gvl(mode_wait_fn, &w, mode_wait_ubf, &w);

        if (w.which == WAIT_OBJECT_0 + 0)
            return ID2SYM(rb_intern("service"));
        if (w.which == WAIT_OBJECT_0 + 1)
            return ID2SYM(rb_intern("console"));
        if (w.which == WAIT_OBJECT_0 + 2)
            raise_gle("StartServiceCtrlDispatcher", g_host.dispatch_error);

        /* hMainWake (index 3) or WAIT_FAILED: service interrupts, then re-wait. */
        rb_thread_check_ints();
    }
}

._inject(vcontrol, vevent, vsession, vdata) ⇒ Object

Winsvc._inject(control, event_type, session_id, data_or_nil) -> true Enqueue a record from Ruby (console Ctrl-C; tests) — the SAME enqueue path HandlerEx uses, under the same CS. Reports the pending transition for stop/pause/continue exactly like the handler, so console mode and tests see the real state machine.



663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
# File 'ext/winsvc/winsvc.c', line 663

static VALUE
host_inject(VALUE mod, VALUE vcontrol, VALUE vevent, VALUE vsession, VALUE vdata)
{
    ctl_rec_t rec;
    DWORD control = NUM2ULONG(vcontrol);
    (void)mod;

    memset(&rec, 0, sizeof(rec));
    rec.control    = control;
    rec.event_type = NIL_P(vevent) ? 0 : NUM2ULONG(vevent);
    rec.session_id = NIL_P(vsession) ? 0xFFFFFFFF : NUM2ULONG(vsession);
    rec.tick       = GetTickCount64();
    if (!NIL_P(vdata)) {
        long len;
        StringValue(vdata);
        len = RSTRING_LEN(vdata);
        if (len > WINSVC_DATA_MAX) len = WINSVC_DATA_MAX;
        memcpy(rec.data, RSTRING_PTR(vdata), (size_t)len);
        rec.data_len = (DWORD)len;
    }

    EnterCriticalSection(&g_host.lock);
    if (!g_host.stopped_reported) {
        if (control == SERVICE_CONTROL_STOP ||
            control == SERVICE_CONTROL_SHUTDOWN ||
            control == SERVICE_CONTROL_PRESHUTDOWN) {
            if (g_host.status)
                report_status_locked(SERVICE_STOP_PENDING, 0, 1, g_host.stop_hint, NO_ERROR, 0);
            InterlockedExchange(&g_host.stop_flag, 1);
        } else if (control == SERVICE_CONTROL_PAUSE) {
            if (g_host.status)
                report_status_locked(SERVICE_PAUSE_PENDING, 0, 1, g_host.stop_hint, NO_ERROR, 0);
        } else if (control == SERVICE_CONTROL_CONTINUE) {
            if (g_host.status)
                report_status_locked(SERVICE_CONTINUE_PENDING, 0, 1, g_host.stop_hint, NO_ERROR, 0);
        }
        enqueue_rec(&rec); /* recursive CS */
    }
    LeaveCriticalSection(&g_host.lock);
    return Qtrue;
}

._install(*args) ⇒ Object

Winsvc._install(name, display, binpath, description|nil, account|nil, password|nil, start_type, delayed, preshutdown_ms|nil, restart, restart_delay_ms, reset_secs, on_non_crash) -> true



937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
# File 'ext/winsvc/winsvc.c', line 937

static VALUE
svc_install(int argc, VALUE *argv, VALUE mod)
{
    install_t a;
    VALUE name, display, binpath, description, account, password;
    VALUE start_type, delayed, preshutdown, restart, rdelay, reset, noncrash;
    (void)mod;

    if (argc != 13) rb_raise(rb_eArgError, "winsvc: _install expects 13 args, got %d", argc);
    name        = argv[0];
    display     = argv[1];
    binpath     = argv[2];
    description = argv[3];
    account     = argv[4];
    password    = argv[5];
    start_type  = argv[6];
    delayed     = argv[7];
    preshutdown = argv[8];
    restart     = argv[9];
    rdelay      = argv[10];
    reset       = argv[11];
    noncrash    = argv[12];

    memset(&a, 0, sizeof(a));
    /* Convert all strings up front (TypeError here owns no handle). */
    a.name        = to_wide(name);
    a.display     = to_wide(display);
    a.binpath     = to_wide(binpath);
    a.description = NIL_P(description) ? NULL : to_wide(description);
    a.account     = NIL_P(account)     ? NULL : to_wide(account);
    a.password    = NIL_P(password)    ? NULL : to_wide(password);
    a.start_type  = NUM2ULONG(start_type);
    a.delayed     = RTEST(delayed) ? 1 : 0;
    a.has_preshutdown = NIL_P(preshutdown) ? 0 : 1;
    a.preshutdown_ms  = NIL_P(preshutdown) ? 0 : NUM2ULONG(preshutdown);
    a.restart            = RTEST(restart) ? 1 : 0;
    a.restart_delay_ms   = NIL_P(rdelay) ? 0 : NUM2ULONG(rdelay);
    a.reset_secs         = NIL_P(reset)  ? 0 : NUM2ULONG(reset);
    a.restart_on_non_crash = RTEST(noncrash) ? 1 : 0;

    rb_thread_call_without_gvl(install_fn, &a, NULL, NULL);

    /* Free every wide buffer before any raise. */
    xfree(a.name); xfree(a.display); xfree(a.binpath);
    if (a.description) xfree(a.description);
    if (a.account)     xfree(a.account);
    if (a.password)    xfree(a.password);

    if (a.failed_api) raise_gle(a.failed_api, a.gle);
    return Qtrue;
}

._service_state(name) ⇒ Object

Winsvc.service_state(name) -> Integer (SERVICE* state). Raises NotFound etc.



1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
# File 'ext/winsvc/winsvc.c', line 1025

static VALUE
svc_service_state(VALUE mod, VALUE name)
{
    state_t a;
    (void)mod;
    memset(&a, 0, sizeof(a));
    a.name = to_wide(name);
    rb_thread_call_without_gvl(state_fn, &a, NULL, NULL);
    xfree(a.name);
    if (a.failed_api) raise_gle(a.failed_api, a.gle);
    return ULONG2NUM(a.state);
}

._set_pausedObject



724
725
726
727
728
729
730
731
732
733
# File 'ext/winsvc/winsvc.c', line 724

static VALUE
set_paused(VALUE mod)
{
    (void)mod;
    EnterCriticalSection(&g_host.lock);
    if (!g_host.stopped_reported && !g_host.stop_flag && g_host.status)
        report_status_locked(SERVICE_PAUSED, g_host.accept_mask, 0, 0, NO_ERROR, 0);
    LeaveCriticalSection(&g_host.lock);
    return Qnil;
}

._set_runningObject

All status bridges: guard check + SetServiceStatus atomic under the host CS. In console mode g_host.status is NULL, so report_status_locked is a no-op — but the Ruby layer already no-ops these in console mode; the NULL guard is belt-and-suspenders.



712
713
714
715
716
717
718
719
720
721
722
# File 'ext/winsvc/winsvc.c', line 712

static VALUE
set_running(VALUE mod)
{
    (void)mod;
    EnterCriticalSection(&g_host.lock);
    /* running!/ready! no-op once a stop-class control is latched or after STOPPED. */
    if (!g_host.stopped_reported && !g_host.stop_flag && g_host.status)
        report_status_locked(SERVICE_RUNNING, g_host.accept_mask, 0, 0, NO_ERROR, 0);
    LeaveCriticalSection(&g_host.lock);
    return Qnil;
}

._stop_requestedObject

Winsvc._stop_requested -> true|false (lock-free interlocked read).



758
759
760
761
762
763
# File 'ext/winsvc/winsvc.c', line 758

static VALUE
stop_requested(VALUE mod)
{
    (void)mod;
    return InterlockedCompareExchange(&g_host.stop_flag, 0, 0) ? Qtrue : Qfalse;
}

._wait_control(vms) ⇒ Object

Winsvc._wait_control(ms) -> Array of raw records, or nil on timeout. (-1 ms == INFINITE.) Drains up to WINSVC_DRAIN_MAX records per CS hold, building Ruby objects OUTSIDE the lock (no Ruby allocation while the CS is held — an OOM longjmp must not orphan the lock), looping until the ring is empty.



614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
# File 'ext/winsvc/winsvc.c', line 614

static VALUE
wait_control(VALUE mod, VALUE vms)
{
    long ms_in = NUM2LONG(vms);
    ctl_wait_t w;
    VALUE all = Qnil;
    (void)mod;

    w.ms = (ms_in < 0) ? INFINITE : (DWORD)ms_in;
    w.which = WAIT_FAILED;
    rb_thread_call_without_gvl(ctl_wait_fn, &w, ctl_wait_ubf, &w);

    if (w.which == WAIT_TIMEOUT) {
        rb_thread_check_ints();
        return Qnil;
    }
    /* hPumpWake (ubf) or signaled hControl: service interrupts (delivers the
     * teardown Thread#kill), then drain whatever is present. */
    rb_thread_check_ints();

    for (;;) {
        ctl_rec_t chunk[WINSVC_DRAIN_MAX];
        int got = 0;
        VALUE part;

        EnterCriticalSection(&g_host.lock);
        while (got < WINSVC_DRAIN_MAX && g_host.count > 0) {
            chunk[got++] = g_host.ring[g_host.head];
            g_host.head = (g_host.head + 1) % WINSVC_RING_CAP;
            g_host.count--;
        }
        LeaveCriticalSection(&g_host.lock);

        if (got == 0) break;
        part = build_records(chunk, got);     /* Ruby alloc, lock released */
        if (NIL_P(all)) all = part;
        else rb_ary_concat(all, part);
        if (got < WINSVC_DRAIN_MAX) break;    /* ring drained this pass */
    }
    return all; /* may be nil if a spurious wake found nothing */
}

.install(name, script:, display_name: name, description: nil, args: [], start: :demand, account: nil, password: nil, ruby: RbConfig.ruby, preshutdown_timeout: nil, restart_on_failure: nil) ⇒ Object

Install the service. REQUIRED kwarg: script: (path to the entry .rb). Requires elevation. Returns true. See README for the full contract.

Raises:

  • (ArgumentError)


496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
# File 'lib/winsvc.rb', line 496

def install(name,
            script:,
            display_name: name,
            description: nil,
            args: [],
            start: :demand,
            account: nil,
            password: nil,
            ruby: RbConfig.ruby,
            preshutdown_timeout: nil,
            restart_on_failure: nil)
  validate_name!(name)
  raise ArgumentError, "winsvc: display_name must be a String" unless display_name.is_a?(String)
  unless description.nil? || description.is_a?(String)
    raise ArgumentError, "winsvc: description must be a String or nil"
  end

  binpath = compose_binpath(ruby, script, args)
  start_type, delayed = start_flags(start)

  if password && .nil?
    raise ArgumentError, "winsvc: password: requires account:"
  end

  pre_ms = validate_preshutdown(preshutdown_timeout)
  restart, rdelay, reset, noncrash = restart_actions(restart_on_failure)

  _install(name, display_name, binpath, description, , password,
           start_type, delayed, pre_ms,
           restart, rdelay, reset, noncrash)
  true
end

.install_command(name, script:, display_name: name, description: nil, args: [], start: :demand, account: nil, password: nil, ruby: RbConfig.ruby, preshutdown_timeout: nil, restart_on_failure: nil) ⇒ Object

Return the equivalent, correctly quoted sc.exe incantation(s), one command per line. Pure function: touches no OS API, needs no elevation. Same validation and binPath composer as install, so the two can never drift.



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
# File 'lib/winsvc.rb', line 574

def install_command(name,
                    script:,
                    display_name: name,
                    description: nil,
                    args: [],
                    start: :demand,
                    account: nil,
                    password: nil,
                    ruby: RbConfig.ruby,
                    preshutdown_timeout: nil,
                    restart_on_failure: nil)
  validate_name!(name)
  binpath = compose_binpath(ruby, script, args)
  start_type, delayed = start_flags(start)
  pre_ms = validate_preshutdown(preshutdown_timeout)
  restart, rdelay, reset, _noncrash = restart_actions(restart_on_failure)

  # The service name must be quoted exactly like every other value: a valid
  # Windows name may contain spaces (or, if the caller's validate_name! is ever
  # loosened, other metacharacters), and a bare interpolation would let sc.exe
  # mis-parse `my svc` as name `my` + stray token `svc`, or let `foo & calc`
  # inject a shell command. sc_quote keeps whitespace-free names bare, so the
  # common case is unchanged while the install/install_command pair stay aligned.
  qname = sc_quote(name)

  lines = []
  create = +"sc create #{qname} binpath= #{sc_quote(binpath)}"
  # sc create takes start= demand|auto; delayed-auto is set by a follow-up
  # `sc config` line (the standard sc.exe idiom), so the create line uses auto.
  create << " start= #{sc_start_word(start_type)}"
  create << " obj= #{sc_quote()}" if 
  create << " password= #{sc_quote(password)}" if password
  create << " displayname= #{sc_quote(display_name)}" if display_name != name
  lines << create

  lines << "sc config #{qname} start= delayed-auto" if delayed && start_type == SERVICE_AUTO_START
  lines << "sc description #{qname} #{sc_quote(description)}" if description
  lines << "sc preshutdown #{qname} #{pre_ms}" if pre_ms
  if restart
    actions = "restart/#{rdelay}/restart/#{rdelay}/restart/#{rdelay}"
    lines << "sc failure #{qname} reset= #{reset} actions= #{actions}"
  end
  lines.join("\n")
end

.run(name, accept: %i[stop shutdown],, start_wait_hint: 30_000, stop_wait_hint: 30_000, manual_ready: false, log: nil) ⇒ Object

Run your Ruby program as a Windows service. See the module docs and README for the full contract. Returns the block's value (re-raises a block exception after reporting STOPPED).

Raises:

  • (ArgumentError)


321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/winsvc.rb', line 321

def run(name,
        accept: %i[stop shutdown],
        start_wait_hint: 30_000,
        stop_wait_hint: 30_000,
        manual_ready: false,
        log: nil)
  validate_name!(name)
  mask = accept_mask!(accept)
  positive_hint!(start_wait_hint, "start_wait_hint")
  positive_hint!(stop_wait_hint, "stop_wait_hint")
  validate_log!(log)
  raise ArgumentError, "winsvc: Winsvc.run requires a block" unless block_given?

  # Callable once per process (Win32: one StartServiceCtrlDispatcherW per
  # process). A second call raises StateError, forever.
  if @ran
    raise StateError, "winsvc: Winsvc.run may be called only once per process"
  end

  @ran = true
  frozen_name = name.dup.freeze

  _host_start(frozen_name, mask, start_wait_hint, stop_wait_hint)

  # EVERYTHING after a successful _host_start runs inside one begin/ensure so
  # ServiceMain can never park on hRubyDone forever.
  pump = nil
  queue = nil
  saved_int = saved_break = nil
  exit_specific = 0
  mode = nil

  begin
    mode = _host_wait_mode # :service or :console (raises OSError on dispatch fail)

    redirect_stdio(log) if mode == :service

    queue = Thread::Queue.new
    svc = Service.new(frozen_name, mode, queue)

    pump = start_pump(queue)

    if mode == :console
      saved_int, saved_break = install_console_traps(queue)
    end

    _set_running unless manual_ready || mode == :console

    begin
      return yield svc
    rescue Exception => e # rubocop:disable Lint/RescueException
      # Service mode: write the diagnostic to the redirected stderr and FLUSH
      # it BEFORE STOPPED is reported (post-STOPPED Ruby is best-effort).
      if mode == :service
        begin
          $stderr.puts(e.full_message(highlight: false))
          $stderr.flush
        rescue StandardError
          # diagnostics are best-effort; never mask the original exception
        end
        exit_specific = 1
      end
      raise
    end
  rescue Exception => e # rubocop:disable Lint/RescueException
    # Any failure between a successful _host_start and the block's return — a
    # log: open error, an interrupt during mode-wait — reports STOPPED with
    # 1066/specific 1 (service mode), then propagates.
    exit_specific = 1 if mode == :service && exit_specific.zero?
    raise
  ensure
    restore_console_traps(saved_int, saved_break) if saved_int || saved_break
    if queue
      queue.close
    end
    if pump
      pump.kill
      pump.join
    end
    _host_done(exit_specific)
  end
end

.uninstall(name, timeout: 30) ⇒ Object

Stop (bounded) then delete the service. Returns true. Requires elevation.

Raises:

  • (ArgumentError)


530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'lib/winsvc.rb', line 530

def uninstall(name, timeout: 30)
  validate_name!(name)
  t = Float(timeout)
  raise ArgumentError, "winsvc: timeout must be non-negative, got #{timeout.inspect}" if t.negative?

  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + t

  # Stop the service first (a delete of a running service would mark-for-delete
  # and zombify). 1062 ERROR_SERVICE_NOT_ACTIVE (already stopped — proceed) and
  # 1061 ERROR_SERVICE_CANNOT_ACCEPT_CTRL (START_PENDING/STOP_PENDING — keep
  # polling and re-send) are retriable, not errors. Polls every 250 ms with a
  # plain Ruby sleep, so it stays interruptible and scheduler-friendly.
  unless _service_state(name) == SERVICE_STOPPED # raises NotFound if absent
    until current_state_or_stopped(name) == SERVICE_STOPPED
      case _control_stop(name)
      when :stopped
        break # 1062 — it stopped under us
      # :sent / :retry — keep polling within the budget
      end

      if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
        raise TimeoutError,
              "winsvc: service '#{name}' did not stop within #{timeout}s (not deleted)"
      end

      sleep 0.25 # interruptible + scheduler-friendly for free
    end
  end

  _delete_service(name)
  true
end

.validate_name!(name) ⇒ Object

Validate a service name: String, 1..256 chars, no "/" or "". Raises ArgumentError otherwise. Returns the frozen name. (Exposed for unit tests.)

Raises:

  • (ArgumentError)


253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/winsvc.rb', line 253

def validate_name!(name)
  raise ArgumentError, "winsvc: name must be a String, got #{name.class}" unless name.is_a?(String)

  n = name.length
  if n < 1 || n > 256
    raise ArgumentError, "winsvc: name must be 1..256 chars, got #{n}"
  end
  if name.include?("/") || name.include?("\\")
    raise ArgumentError, "winsvc: name must not contain '/' or '\\' (got #{name.inspect})"
  end

  name
end