diff --git a/config.json b/config.json index 8beadcd..0a4645e 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "endpoint": "https://app.pangolin.dev.fosrl.io", - "id": "uglmdfj33iuxfab", + "id": "4o2yr11p4kg5k5q", "loglevel": "debug", "nativeMain": true, - "secret": "mus3dletfjw2pss7iu3drle7m3vyxv4r4dzqgunx" + "secret": "6h5p3h2aaip47xqgusoqigvibm4vvla74gxhc4wg" } \ No newline at end of file diff --git a/reexec_windows.go b/reexec_windows.go index 770f544..fb3700d 100644 --- a/reexec_windows.go +++ b/reexec_windows.go @@ -8,11 +8,21 @@ import ( "os/exec" ) -// reexec spawns a new copy of the current process with the same arguments and -// environment, then exits the current process. On Windows, execve is not -// available, so we start a child process and exit. On success it never returns -// (os.Exit terminates the current process). On failure it returns an error. +// reexec restarts newt. On Windows, execve is not available, so outside of +// service mode we start a child process and exit (on success this never +// returns since os.Exit terminates the current process). +// +// When running as a Windows service, spawning a detached child would orphan +// it from the Service Control Manager (the SCM only tracks processes it +// started itself), and os.Exit would end the process without ever reporting +// a clean status transition, making the service look like it crashed. So in +// that case we instead delegate to requestServiceRestart, which asks the SCM +// itself to relaunch the service. func reexec() error { + if isWindowsService() { + return requestServiceRestart() + } + exe, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) diff --git a/service_windows.go b/service_windows.go index 3206d80..e03ec5b 100644 --- a/service_windows.go +++ b/service_windows.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync/atomic" "syscall" "time" @@ -85,10 +86,44 @@ type newtService struct { args []string } +// activeService points at the newtService currently running under the SCM, +// so that requestServiceRestart (called from deep inside the app via +// cfg.OnRestart) can signal it without threading a reference through the +// newt package. restartRequested tells Execute's completion path whether the +// tunnel shut down because of a real Stop/Shutdown control request (report +// success) or because the app asked to be restarted (report a non-zero exit +// code so the SCM's configured recovery action relaunches the service as a +// fresh, SCM-tracked process). +var ( + activeService *newtService + restartRequested atomic.Bool +) + +// requestServiceRestart asks the Windows Service Control Manager to relaunch +// the service. Unlike reexec on other platforms, a Windows service cannot +// replace its own process image or spawn a detached child that the SCM would +// recognize - the SCM only respawns processes it started itself. So instead +// we gracefully stop the current run (same as a real Stop control request) +// and report a failure exit code on the way out, which - combined with the +// recovery actions configured in configureRecoveryActions - causes the SCM +// to start a brand new, properly tracked instance of the service. +func requestServiceRestart() error { + if activeService == nil || activeService.stop == nil { + return fmt.Errorf("newt service is not running") + } + restartRequested.Store(true) + activeService.elog.Info(1, "Restart requested; stopping tunnel and asking the service control manager to relaunch") + activeService.stop() + return nil +} + func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown changes <- svc.Status{State: svc.StartPending} + activeService = s + restartRequested.Store(false) + s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args)) // Load saved service arguments @@ -169,8 +204,12 @@ func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c)) } case <-newtDone: - s.elog.Info(1, "Main newt logic completed, stopping service") changes <- svc.Status{State: svc.StopPending} + if restartRequested.Load() { + s.elog.Info(1, "Main newt logic stopped for restart, reporting failure exit code so the SCM relaunches the service") + return false, 1 + } + s.elog.Info(1, "Main newt logic completed, stopping service") return false, 0 } } @@ -207,6 +246,52 @@ func (s *newtService) runNewt() { } } +// configureRecoveryActions tells the Service Control Manager to relaunch the +// service automatically when it stops with a non-zero exit code. This is +// what makes requestServiceRestart's approach (report failure, let the SCM +// respawn us) actually result in a restart. +func configureRecoveryActions(s *mgr.Service) error { + actions := []mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 30 * time.Second}, + } + if err := s.SetRecoveryActions(actions, 24*60*60); err != nil { + return fmt.Errorf("failed to set recovery actions: %v", err) + } + // By default the SCM only runs recovery actions when a service crashes. + // A restart-via-OnRestart is a controlled stop that merely reports a + // non-zero exit code, so this flag must be enabled for it to count. + if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil { + return fmt.Errorf("failed to enable recovery actions on non-crash failures: %v", err) + } + return nil +} + +// ensureRecoveryActionsConfigured is a best-effort check run at service +// startup so that services installed by older versions of newt (before +// recovery actions existed) get them configured on their next start, without +// requiring a reinstall. +func ensureRecoveryActionsConfigured(name string, elog debug.Log) { + m, err := mgr.Connect() + if err != nil { + elog.Warning(1, fmt.Sprintf("Could not connect to service manager to verify recovery actions: %v", err)) + return + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + elog.Warning(1, fmt.Sprintf("Could not open service to verify recovery actions: %v", err)) + return + } + defer s.Close() + + if err := configureRecoveryActions(s); err != nil { + elog.Warning(1, fmt.Sprintf("Could not configure recovery actions: %v", err)) + } +} + func runService(name string, isDebug bool, args []string) { var err error var elog debug.Log @@ -224,6 +309,7 @@ func runService(name string, isDebug bool, args []string) { defer elog.Close() elog.Info(1, fmt.Sprintf("Starting %s service", name)) + ensureRecoveryActionsConfigured(name, elog) run := svc.Run if isDebug { run = debug.Run @@ -283,6 +369,13 @@ func installService() error { return fmt.Errorf("failed to install event log: %v", err) } + if err := configureRecoveryActions(s); err != nil { + // Non-fatal: the service is installed and usable, it just won't + // auto-relaunch on an app-initiated restart until this is retried + // (ensureRecoveryActionsConfigured does so on every service start). + fmt.Printf("Warning: failed to configure service recovery actions: %v\n", err) + } + return nil }