diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index bb1f2a711..0ce0ca6d3 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -2,7 +2,6 @@ package service import ( "encoding/json" - "errors" "fmt" "path" "path/filepath" @@ -78,6 +77,9 @@ func currentXrayProcess() *xray.Process { return process } +// ErrXrayNotRunning reports an operation that requires a running core when xray is stopped. +var ErrXrayNotRunning = xray.ErrXrayNotRunning + // XrayService provides business logic for Xray process management. // It handles starting, stopping, restarting Xray, and managing its configuration. type XrayService struct { @@ -1245,7 +1247,7 @@ func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage { func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) { process := currentXrayProcess() if process == nil || !process.IsRunning() { - err := errors.New("xray is not running") + err := ErrXrayNotRunning logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err) return nil, nil, err } @@ -1355,7 +1357,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error { defer lock.Unlock() process := currentXrayProcess() if process == nil || !process.IsRunning() { - return errors.New("xray is not running") + return ErrXrayNotRunning } if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil { return err @@ -1369,7 +1371,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error { func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) { process := currentXrayProcess() if process == nil || !process.IsRunning() { - return nil, errors.New("xray is not running") + return nil, ErrXrayNotRunning } if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil { return nil, err @@ -1611,7 +1613,7 @@ func (s *XrayService) StopXray() error { if process != nil && process.IsRunning() { return process.Stop() } - return errors.New("xray is not running") + return ErrXrayNotRunning } // SetToNeedRestart marks that Xray needs to be restarted. diff --git a/internal/web/web.go b/internal/web/web.go index 328843a07..ecc41c26a 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -787,7 +787,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { amneziawgnet.GetOutboundManager().StopAll() } if stopXray { - if err := s.xrayService.StopXray(); err != nil { + if err := s.xrayService.StopXray(); err != nil && !errors.Is(err, service.ErrXrayNotRunning) { err2 = common.Combine(err2, err) } } @@ -811,7 +811,10 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { s.wsHub.Stop() } if s.listener != nil { - err1 = common.Combine(err1, s.listener.Close()) + // httpServer.Shutdown already closes the listener; ignore closed errors. + if err := s.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + err1 = common.Combine(err1, err) + } } return common.Combine(err1, err2) } diff --git a/internal/web/web_shutdown_test.go b/internal/web/web_shutdown_test.go new file mode 100644 index 000000000..49e1ba68c --- /dev/null +++ b/internal/web/web_shutdown_test.go @@ -0,0 +1,38 @@ +package web + +import ( + "context" + "net" + "net/http" + "testing" + "time" +) + +func TestServerStopWhenXrayNotRunningSucceeds(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + s := NewServer() + if err := s.Stop(); err != nil { + t.Fatalf("expected nil error when stopping server with unstarted xray, got: %v", err) + } +} + +func TestServerStopPanelOnlyWithPreclosedListenerSucceeds(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + s := NewServer() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen failed: %v", err) + } + s.listener = ln + s.httpServer = &http.Server{} + go func() { _ = s.httpServer.Serve(ln) }() + time.Sleep(10 * time.Millisecond) + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.httpServer.Shutdown(shutdownCtx) + + if err := s.StopPanelOnly(); err != nil { + t.Fatalf("expected nil error when listener is already closed, got: %v", err) + } +} diff --git a/internal/web/web_tuic_shutdown_test.go b/internal/web/web_tuic_shutdown_test.go index 9c0c05712..a861d2419 100644 --- a/internal/web/web_tuic_shutdown_test.go +++ b/internal/web/web_tuic_shutdown_test.go @@ -11,7 +11,6 @@ import ( "math/big" "net" "path/filepath" - "strings" "testing" "time" @@ -75,7 +74,7 @@ func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) { t.Fatal("TUIC listener did not start") } - if err := NewServer().Stop(); err != nil && !strings.Contains(err.Error(), "xray is not running") { + if err := NewServer().Stop(); err != nil { t.Fatalf("graceful server shutdown: %v", err) } diff --git a/internal/xray/process.go b/internal/xray/process.go index 9821d486d..9ea2baff5 100644 --- a/internal/xray/process.go +++ b/internal/xray/process.go @@ -227,6 +227,8 @@ var ( xrayVersionTimeout = 5 * time.Second // OnCrash is called when xray crashes unexpectedly. Set from web layer. OnCrash func(err error) + // ErrXrayNotRunning reports an operation that requires a running core when xray is stopped. + ErrXrayNotRunning = errors.New("xray is not running") ) // newProcess creates a new internal process struct for Xray. @@ -758,7 +760,7 @@ func (p *process) waitForCommand(cmd *exec.Cmd, done chan struct{}) { // Stop terminates the running Xray process. func (p *process) Stop() error { if !p.IsRunning() { - return errors.New("xray is not running") + return ErrXrayNotRunning } p.intentionalStop.Store(true) @@ -768,7 +770,7 @@ func (p *process) Stop() error { cmd := p.cmd p.mu.RUnlock() if cmd == nil || cmd.Process == nil { - return errors.New("xray is not running") + return ErrXrayNotRunning } // Remove temporary config file used for test runs so main config is never touched diff --git a/internal/xray/process_test.go b/internal/xray/process_test.go index cf034a0cb..066f9fa1b 100644 --- a/internal/xray/process_test.go +++ b/internal/xray/process_test.go @@ -96,6 +96,15 @@ func TestIntentionalStopDoesNotRecordExitError(t *testing.T) { } } +func TestStopWhenNotRunningReturnsErrXrayNotRunning(t *testing.T) { + initProcessTestLogger(t) + + p := NewProcess(&Config{}) + if err := p.Stop(); !errors.Is(err, ErrXrayNotRunning) { + t.Fatalf("Stop() on unstarted process err = %v, want ErrXrayNotRunning", err) + } +} + func TestStopKillsProcessThatIgnoresSIGTERM(t *testing.T) { initProcessTestLogger(t) diff --git a/x-ui.service.arch b/x-ui.service.arch index c36335d8f..949328709 100644 --- a/x-ui.service.arch +++ b/x-ui.service.arch @@ -14,6 +14,7 @@ ExecStart=/usr/local/x-ui/x-ui ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +KillMode=mixed # The panel intentionally stays root: it supervises the Xray child processes, # edits netfilter state and reads TLS private keys. These settings only bound diff --git a/x-ui.service.debian b/x-ui.service.debian index 3a6085d7d..87863fe08 100644 --- a/x-ui.service.debian +++ b/x-ui.service.debian @@ -14,6 +14,7 @@ ExecStart=/usr/local/x-ui/x-ui ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +KillMode=mixed # The panel intentionally stays root: it supervises the Xray child processes, # edits netfilter state and reads TLS private keys. These settings only bound diff --git a/x-ui.service.rhel b/x-ui.service.rhel index 18b115dcd..b44f6b004 100644 --- a/x-ui.service.rhel +++ b/x-ui.service.rhel @@ -14,6 +14,7 @@ ExecStart=/usr/local/x-ui/x-ui ExecReload=/bin/kill -USR1 $MAINPID Restart=on-failure RestartSec=5s +KillMode=mixed # The panel intentionally stays root: it supervises the Xray child processes, # edits netfilter state and reads TLS private keys. These settings only bound