fix(web): stop clean shutdowns logging false errors, and let the panel stop its children (#6884)

A systemd stop or restart logged "Server shutdown failed: use of closed
network connection | xray is not running" on every clean exit. httpServer.Shutdown
already closes the listener, so the second Close's net.ErrClosed is now ignored,
and StopXray's not-running case is a sentinel (ErrXrayNotRunning) that Stop
treats as done.

The shipped units now use KillMode=mixed, so SIGTERM reaches only the panel,
which stops Xray and the sidecars itself; systemd still SIGKILLs whatever is left
after the stop timeout.
This commit is contained in:
Rouzbeh†
2026-10-24 06:45:34 +03:30
committed by GitHub
parent 40cea0c2fe
commit ad470681ef
9 changed files with 67 additions and 11 deletions
+7 -5
View File
@@ -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.
+5 -2
View File
@@ -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)
}
+38
View File
@@ -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)
}
}
+1 -2
View File
@@ -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)
}
+4 -2
View File
@@ -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
+9
View File
@@ -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)
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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