feat: execute durable client commands through supervisor

This commit is contained in:
2026-09-06 12:18:15 +00:00
parent 3f84d3b2f1
commit 56b15c7f4f
41 changed files with 4272 additions and 66 deletions
+124
View File
@@ -0,0 +1,124 @@
// Package windowsservice owns the machine-wide Windows service contract. The
// policy and transition model are platform-neutral so they can be exercised
// on Linux; service manager calls live in build-tagged adapters.
package windowsservice
import (
"context"
"errors"
"fmt"
"strings"
winlaunch "github.com/rvbox/rvbox/internal/client/supervisor/windows"
)
const (
Name = "RVBoxClient"
DisplayName = "RVBox Client"
Description = "RVBox Windows client daemon and command supervisor"
)
var (
ErrInvalidInstallSpec = errors.New("invalid Windows service install specification")
ErrUnsupported = errors.New("Windows service management is unavailable on this platform")
ErrInvalidTransition = errors.New("invalid Windows service lifecycle transition")
)
type StartupMode string
const (
StartupAutomatic StartupMode = "automatic"
StartupManual StartupMode = "manual"
)
// InstallSpec is the complete immutable service image contract. The service
// is always one machine-wide LocalSystem process; config selection is explicit
// and never delegated to PATH, the current directory, or Task Scheduler.
type InstallSpec struct {
ExecutablePath string
ConfigPath string
Startup StartupMode
}
func (spec InstallSpec) Validate() error {
if !winlaunch.ValidAbsoluteWindowsPath(spec.ExecutablePath) || !winlaunch.ValidAbsoluteWindowsPath(spec.ConfigPath) {
return ErrInvalidInstallSpec
}
if strings.ContainsRune(spec.ExecutablePath, 0) || strings.ContainsRune(spec.ConfigPath, 0) {
return ErrInvalidInstallSpec
}
if spec.Startup != StartupAutomatic && spec.Startup != StartupManual {
return ErrInvalidInstallSpec
}
return nil
}
func (spec InstallSpec) String() string {
return fmt.Sprintf("%s config=%s startup=%s", spec.ExecutablePath, spec.ConfigPath, spec.Startup)
}
type State uint8
const (
StateUnknown State = iota
StateStopped
StateStartPending
StateRunning
StateStopPending
StatePaused
)
type Command uint8
const (
CommandStart Command = iota + 1
CommandStop
CommandRestart
)
// Transition validates only state changes that the service control adapter is
// allowed to request. Pending states are intentionally not collapsed into a
// successful result: callers must poll SCM and observe the final state.
func Transition(state State, command Command) (State, error) {
switch command {
case CommandStart:
switch state {
case StateStopped:
return StateStartPending, nil
case StateRunning, StateStartPending:
return state, nil
default:
return StateUnknown, ErrInvalidTransition
}
case CommandStop:
switch state {
case StateRunning, StatePaused:
return StateStopPending, nil
case StateStopped, StateStopPending:
return state, nil
default:
return StateUnknown, ErrInvalidTransition
}
case CommandRestart:
switch state {
case StateStopped:
return StateStartPending, nil
case StateRunning, StatePaused:
return StateStopPending, nil
case StateStartPending, StateStopPending:
return state, nil
default:
return StateUnknown, ErrInvalidTransition
}
default:
return StateUnknown, ErrInvalidTransition
}
}
// Install, Uninstall, Start, and Stop are intentionally narrow. Their
// platform implementations return ErrUnsupported on non-Windows builds.
func Install(spec InstallSpec) error { return installNative(spec) }
func Uninstall() error { return uninstallNative() }
func Start() error { return startNative() }
func Stop(timeoutSeconds uint32) error { return stopNative(timeoutSeconds) }
func Run(run func(context.Context) error) error { return runNative(run) }
@@ -0,0 +1,11 @@
//go:build !windows
package windowsservice
import "context"
func installNative(InstallSpec) error { return ErrUnsupported }
func uninstallNative() error { return ErrUnsupported }
func startNative() error { return ErrUnsupported }
func stopNative(uint32) error { return ErrUnsupported }
func runNative(func(context.Context) error) error { return ErrUnsupported }
@@ -0,0 +1,59 @@
package windowsservice
import (
"errors"
"testing"
)
func TestInstallSpecValidation_HP_WINSVC_01(t *testing.T) {
t.Parallel()
valid := InstallSpec{ExecutablePath: `C:\Program Files\RVBox\rvbox.exe`, ConfigPath: `C:\ProgramData\RVBox\client.toml`, Startup: StartupAutomatic}
if err := valid.Validate(); err != nil {
t.Fatalf("valid install spec rejected: %v", err)
}
for _, invalid := range []InstallSpec{
{ExecutablePath: `rvbox.exe`, ConfigPath: valid.ConfigPath, Startup: StartupAutomatic},
{ExecutablePath: valid.ExecutablePath, ConfigPath: `client.toml`, Startup: StartupAutomatic},
{ExecutablePath: valid.ExecutablePath, ConfigPath: valid.ConfigPath, Startup: StartupMode("disabled")},
} {
if !errors.Is(invalid.Validate(), ErrInvalidInstallSpec) {
t.Fatalf("invalid install spec accepted: %#v", invalid)
}
}
}
func TestServiceTransitionsAreIdempotentAndExplicit_HP_WINSVC_02(t *testing.T) {
t.Parallel()
tests := []struct {
state State
command Command
want State
}{
{StateStopped, CommandStart, StateStartPending},
{StateStartPending, CommandStart, StateStartPending},
{StateRunning, CommandStop, StateStopPending},
{StateStopPending, CommandStop, StateStopPending},
{StateStopped, CommandRestart, StateStartPending},
{StateRunning, CommandRestart, StateStopPending},
}
for _, test := range tests {
got, err := Transition(test.state, test.command)
if err != nil || got != test.want {
t.Errorf("Transition(%v,%v) = %v,%v; want %v,nil", test.state, test.command, got, err, test.want)
}
}
for _, test := range []struct {
state State
command Command
}{{StateUnknown, CommandStart}, {StateStartPending, CommandStop}} {
if test.state == StateStopped && test.command == CommandStop {
// Stopping an already stopped service is deliberately idempotent.
continue
}
if _, err := Transition(test.state, test.command); test.state == StateStopped && test.command == CommandStop {
t.Fatalf("unreachable idempotent case returned error: %v", err)
} else if err == nil {
t.Errorf("Transition(%v,%v) unexpectedly accepted", test.state, test.command)
}
}
}
@@ -0,0 +1,207 @@
//go:build windows
package windowsservice
import (
"context"
"errors"
"fmt"
"syscall"
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
func connect() (*mgr.Mgr, error) {
return mgr.Connect()
}
func installNative(spec InstallSpec) error {
if err := spec.Validate(); err != nil {
return err
}
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
}
defer manager.Disconnect()
startup := uint32(mgr.StartAutomatic)
if spec.Startup == StartupManual {
startup = mgr.StartManual
}
configuration := mgr.Config{
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
StartType: startup,
ErrorControl: mgr.ErrorNormal,
DisplayName: DisplayName,
Description: Description,
ServiceStartName: "LocalSystem",
}
service, openErr := manager.OpenService(Name)
if errors.Is(openErr, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
service, err = manager.CreateService(Name, spec.ExecutablePath, configuration, "--service", "--config", spec.ConfigPath)
if err != nil {
return fmt.Errorf("create %s service: %w", Name, err)
}
} else if openErr != nil {
return fmt.Errorf("open %s service: %w", Name, openErr)
} else {
configuration, err = service.Config()
if err != nil {
return fmt.Errorf("query %s service configuration: %w", Name, err)
}
configuration.ServiceType = windows.SERVICE_WIN32_OWN_PROCESS
configuration.StartType = startup
configuration.ErrorControl = mgr.ErrorNormal
configuration.BinaryPathName = serviceImage(spec)
configuration.DisplayName = DisplayName
configuration.Description = Description
configuration.ServiceStartName = "LocalSystem"
if err := service.UpdateConfig(configuration); err != nil {
return fmt.Errorf("update %s service configuration: %w", Name, err)
}
}
defer service.Close()
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
return fmt.Errorf("start %s service: %w", Name, err)
}
return nil
}
func serviceImage(spec InstallSpec) string {
return syscall.EscapeArg(spec.ExecutablePath) + " --service --config " + syscall.EscapeArg(spec.ConfigPath)
}
func uninstallNative() error {
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
}
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return nil
}
if err != nil {
return fmt.Errorf("open %s service: %w", Name, err)
}
defer service.Close()
if err := stopService(service, 30*time.Second); err != nil {
return err
}
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
return fmt.Errorf("delete %s service: %w", Name, err)
}
return nil
}
func startNative() error {
manager, err := connect()
if err != nil {
return err
}
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return fmt.Errorf("%s service is not installed", Name)
}
if err != nil {
return err
}
defer service.Close()
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
return err
}
return nil
}
func stopNative(timeoutSeconds uint32) error {
manager, err := connect()
if err != nil {
return err
}
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return nil
}
if err != nil {
return err
}
defer service.Close()
timeout := 30 * time.Second
if timeoutSeconds > 0 {
timeout = time.Duration(timeoutSeconds) * time.Second
}
return stopService(service, timeout)
}
func stopService(service *mgr.Service, timeout time.Duration) error {
status, err := service.Query()
if err != nil {
return fmt.Errorf("query %s service: %w", Name, err)
}
if status.State == svc.Stopped {
return nil
}
if _, err := service.Control(svc.Stop); err != nil && !errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) {
return fmt.Errorf("stop %s service: %w", Name, err)
}
deadline := time.Now().Add(timeout)
for {
status, err = service.Query()
if err != nil {
return fmt.Errorf("query %s service while stopping: %w", Name, err)
}
if status.State == svc.Stopped {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out stopping %s service", Name)
}
time.Sleep(100 * time.Millisecond)
}
}
type handler struct {
run func(context.Context) error
}
func (serviceHandler handler) Execute(_ []string, changes <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
if serviceHandler.run == nil {
return false, 1
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
status <- svc.Status{State: svc.StartPending, WaitHint: 10_000}
done := make(chan error, 1)
go func() { done <- serviceHandler.run(ctx) }()
status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
for {
select {
case request := <-changes:
if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown {
status <- svc.Status{State: svc.StopPending, WaitHint: 30_000}
cancel()
err := <-done
if err != nil {
return true, 1
}
status <- svc.Status{State: svc.Stopped}
return false, 0
}
case err := <-done:
if err != nil {
return true, 1
}
status <- svc.Status{State: svc.Stopped}
return false, 0
}
}
}
func runNative(run func(context.Context) error) error {
return svc.Run(Name, handler{run: run})
}