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:
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: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#
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#
$ beacon --interval 5s # plain console run — any OS
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.