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† authored and GitHub committed 2026-10-24 05:15:34 +02:00
1 parent 40cea0c2fe
commit ad470681ef
9 files changed
+67 -11

No files matched your search

+7 -5
View File
@@ -2,7 +2,6 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"path" "path"
"path/filepath" "path/filepath"
@@ -78,6 +77,9 @@ func currentXrayProcess() *xray.Process {
return 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. // XrayService provides business logic for Xray process management.
// It handles starting, stopping, restarting Xray, and managing its configuration. // It handles starting, stopping, restarting Xray, and managing its configuration.
type XrayService struct { 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) { func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
process := currentXrayProcess() process := currentXrayProcess()
if process == nil || !process.IsRunning() { 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) logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
return nil, nil, err return nil, nil, err
} }
@@ -1355,7 +1357,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
defer lock.Unlock() defer lock.Unlock()
process := currentXrayProcess() process := currentXrayProcess()
if process == nil || !process.IsRunning() { if process == nil || !process.IsRunning() {
return errors.New("xray is not running") return ErrXrayNotRunning
} }
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil { if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
return err return err
@@ -1369,7 +1371,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) { func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
process := currentXrayProcess() process := currentXrayProcess()
if process == nil || !process.IsRunning() { 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 { if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
return nil, err return nil, err
@@ -1611,7 +1613,7 @@ func (s *XrayService) StopXray() error {
if process != nil && process.IsRunning() { if process != nil && process.IsRunning() {
return process.Stop() return process.Stop()
} }
return errors.New("xray is not running") return ErrXrayNotRunning
} }
// SetToNeedRestart marks that Xray needs to be restarted. // 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() amneziawgnet.GetOutboundManager().StopAll()
} }
if stopXray { 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) err2 = common.Combine(err2, err)
} }
} }
@@ -811,7 +811,10 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
s.wsHub.Stop() s.wsHub.Stop()
} }
if s.listener != nil { 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) 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" "math/big"
"net" "net"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -75,7 +74,7 @@ func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) {
t.Fatal("TUIC listener did not start") 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) t.Fatalf("graceful server shutdown: %v", err)
} }
+4 -2
View File
@@ -227,6 +227,8 @@ var (
xrayVersionTimeout = 5 * time.Second xrayVersionTimeout = 5 * time.Second
// OnCrash is called when xray crashes unexpectedly. Set from web layer. // OnCrash is called when xray crashes unexpectedly. Set from web layer.
OnCrash func(err error) 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. // 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. // Stop terminates the running Xray process.
func (p *process) Stop() error { func (p *process) Stop() error {
if !p.IsRunning() { if !p.IsRunning() {
return errors.New("xray is not running") return ErrXrayNotRunning
} }
p.intentionalStop.Store(true) p.intentionalStop.Store(true)
@@ -768,7 +770,7 @@ func (p *process) Stop() error {
cmd := p.cmd cmd := p.cmd
p.mu.RUnlock() p.mu.RUnlock()
if cmd == nil || cmd.Process == nil { 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 // 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) { func TestStopKillsProcessThatIgnoresSIGTERM(t *testing.T) {
initProcessTestLogger(t) initProcessTestLogger(t)
+1
View File
@@ -14,6 +14,7 @@ ExecStart=/usr/local/x-ui/x-ui
ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
KillMode=mixed
# The panel intentionally stays root: it supervises the Xray child processes, # The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound # 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 ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
KillMode=mixed
# The panel intentionally stays root: it supervises the Xray child processes, # The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound # 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 ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
KillMode=mixed
# The panel intentionally stays root: it supervises the Xray child processes, # The panel intentionally stays root: it supervises the Xray child processes,
# edits netfilter state and reads TLS private keys. These settings only bound # edits netfilter state and reads TLS private keys. These settings only bound