sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.3.0). Switch to the current version →

Windows service applet

A complete SCMApplet: beacon, an applet that logs a heartbeat on an interval until told to stop. It runs four ways from one codebase — a plain console tool on any OS, a Windows service under the SCM, the service code path in a console via --scm-debug, and cross-compiles everywhere because the Windows parts live in a _windows.go file.

The portable part#

beacon.go — config, registration, and the console-mode Run:

go
go
package beacon

import (
    "log/slog"
    "os"
    "os/signal"
    "time"

    fw "sxcli.dev/fw"
)

const ID = "example.com/beacon/beacon"

type Config struct {
    Version  uint32        `json:"version"`
    Interval time.Duration `json:"interval" conf:"interval,i" usage:"heartbeat interval"`
}

type Beacon struct{ cfg Config }

func (b *Beacon) Configured() error { return nil }

// Run — console mode: beat until interrupted.
func (b *Beacon) Run() int {
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt)
    t := time.NewTicker(b.cfg.Interval)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            slog.Info("beacon", "at", time.Now())
        case <-stop:
            slog.Info("beacon stopping")
            return 0
        }
    }
}

func init() {
    fw.NewRegistration(ID,
        func() *Beacon { return &Beacon{cfg: Config{Version: 1, Interval: 30 * time.Second}} },
        func(b *Beacon) *Config { return &b.cfg },
    ).Alias("beacon").Register()
}

The Windows part#

beacon_windows.go — the same type gains Execute, and with it the SCMApplet interface. By the time Execute runs, the framework has already reported start-pending to the SCM and driven the whole pipeline (config merged, services started):

go
go
//go:build windows

package beacon

import (
    "log/slog"
    "time"

    "golang.org/x/sys/windows/svc"
)

// Execute — service mode: go Running, beat, obey the SCM.
func (b *Beacon) Execute(args []string, req <-chan svc.ChangeRequest,
    status chan<- svc.Status) (bool, uint32) {

    const accepted = svc.AcceptStop | svc.AcceptShutdown
    status <- svc.Status{State: svc.Running, Accepts: accepted}

    t := time.NewTicker(b.cfg.Interval)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            slog.Info("beacon", "at", time.Now())
        case c := <-req:
            switch c.Cmd {
            case svc.Interrogate:
                status <- c.CurrentStatus
            case svc.Stop, svc.Shutdown:
                status <- svc.Status{State: svc.StopPending}
                return false, 0 // framework: reverse-order Stop, final status
            }
        }
    }
}

The contract in three lines: report Running when serving, echo Interrogate, return on Stop/Shutdown. Everything before and after — start-pending, the pipeline, reverse-order shutdown, the final SCM status — is the framework's job.

main.go#

go
go
package main

import (
    "sxcli.dev/fw"
    "sxcli.dev/fw/sink/file"

    "example.com/beacon/beacon"
)

func main() {
    fw.Enable(fw.FeatureSCMDebug) // opt-in: --scm-debug console testing
    fw.Builder().
        Accept(beacon.ID, file.ID).
        Main()
}

The file sink is accepted deliberately: under the SCM there is no console, so --enable logfile is what makes the heartbeats land somewhere durable. A service that logs only to a console nobody can see is a service you cannot debug.

Running it, four ways#

sh
sh
$ beacon --interval 5s              # plain console run — any OS
console
console
C> beacon.exe --scm-debug -i 5s     # service code path in a console:
                                    # Ctrl+C sends a real Stop request
C> sc.exe create beacon binPath= "C:\svc\beacon.exe"
C> sc.exe start beacon              # the real thing
C> sc.exe stop beacon               # Execute returns, reverse-order Stop

Configuration is unchanged in every mode: --interval/-i, BEACON__INTERVAL, or %ProgramData%\beacon\config.json with {"beacon": {"version": 1, "interval": "5s"}} — the SCM launch passes through the same pipeline as a console run.

The concepts behind all of this are on the Windows services page; the Windows-only API is in the fw (windows) reference.