Files
3x-ui/internal/xray/log_writer.go
T
MHSanaei 760f428c41 fix(xray): capture whole core crash reports instead of the first pipe chunk
LogWriter inspected each pipe chunk on its own. The Go runtime writes
"panic: " with a separate write before the message and traceback, so the
crash report was written from that first chunk alone and the panel left
7-byte core_crash_*.log files holding only "panic: ". Any ordinary log
line mentioning panic/exception/stack trace was also treated as a crash
and dropped from the panel log.

The writer now splits output into lines, starts a crash only on a line
beginning with the Go runtime's "panic: " / "fatal error: " header, keeps
everything after it, and writes one report from Flush, which
waitForCommand calls after cmd.Wait has delivered all output. The header
line becomes LastLine so GetResult shows the cause.

The report filename format "_000000000" is not a Go fractional-seconds
layout and always produced literal zeros, so reports from the same second
overwrote each other; os.CreateTemp now gives every report a unique name.
2026-10-14 15:31:09 +02:00

161 lines
4.1 KiB
Go

package xray
import (
"regexp"
"runtime"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Compiled once at package load: Write runs on every line Xray emits, so
// recompiling these per write is wasted work.
var (
crashStartRegex = regexp.MustCompile(`^(panic|fatal error): `)
logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
)
// NewLogWriter returns a new LogWriter for processing Xray log output.
func NewLogWriter() *LogWriter {
return &LogWriter{}
}
// LogWriter splits Xray's stdout/stderr into lines and forwards them to the
// panel log; once a Go panic or fatal error starts, all output is kept for one crash report.
type LogWriter struct {
mu sync.RWMutex
pending string
crash strings.Builder
lastLine string
}
// LastLine returns the most recently processed Xray log line. It is safe for
// concurrent use: Process.GetResult reads it from a different goroutine than the
// one Xray drives Write from.
func (lw *LogWriter) LastLine() string {
lw.mu.RLock()
defer lw.mu.RUnlock()
return lw.lastLine
}
// Write buffers chunks into whole lines: the pipe splits output at arbitrary
// points, e.g. the Go runtime writes "panic: " on its own before the message.
func (lw *LogWriter) Write(m []byte) (n int, err error) {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.crash.Len() > 0 {
lw.crash.Write(m)
return len(m), nil
}
lw.pending += string(m)
for {
i := strings.IndexByte(lw.pending, '\n')
if i < 0 {
break
}
line := lw.pending[:i]
lw.pending = lw.pending[i+1:]
if lw.startCrashLocked(line + "\n" + lw.pending) {
lw.pending = ""
break
}
lw.processLineLocked(line)
}
return len(m), nil
}
// Flush handles an unterminated final line and writes the crash report, if any;
// it must run after cmd.Wait, which guarantees every output chunk was delivered.
func (lw *LogWriter) Flush() {
lw.mu.Lock()
defer lw.mu.Unlock()
if rest := lw.pending; rest != "" {
lw.pending = ""
if !lw.startCrashLocked(rest) {
lw.processLineLocked(rest)
}
}
if lw.crash.Len() == 0 {
return
}
report := lw.crash.String()
lw.crash.Reset()
logger.Debug("Core crash detected:\n", report)
if err := writeCrashReport([]byte(report)); err != nil {
logger.Error("Unable to write crash report:", err)
}
}
// startCrashLocked begins capturing a crash when output opens with a Go runtime
// crash header; the header line becomes LastLine so GetResult shows the cause.
func (lw *LogWriter) startCrashLocked(output string) bool {
if lw.crash.Len() > 0 || !crashStartRegex.MatchString(output) {
return false
}
header, _, _ := strings.Cut(output, "\n")
lw.lastLine = strings.TrimSpace(header)
lw.crash.WriteString(output)
return true
}
func (lw *LogWriter) processLineLocked(line string) {
msg := strings.TrimSpace(line)
if msg == "" {
return
}
msgLower := strings.ToLower(msg)
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
if runtime.GOOS == "windows" && strings.Contains(msgLower, "exit status 1") {
return
}
matches := logLineRegex.FindStringSubmatch(msg)
if len(matches) > 3 {
level := matches[2]
msgBody := matches[3]
msgBodyLower := strings.ToLower(msgBody)
if strings.Contains(msgBodyLower, "tls handshake error") ||
strings.Contains(msgBodyLower, "connection ends") {
logger.Debug("XRAY: " + msgBody)
lw.lastLine = ""
return
}
if strings.Contains(msgBodyLower, "failed") {
logger.Error("XRAY: " + msgBody)
} else {
switch level {
case "Debug":
logger.Debug("XRAY: " + msgBody)
case "Info":
logger.Info("XRAY: " + msgBody)
case "Warning":
logger.Warning("XRAY: " + msgBody)
case "Error":
logger.Error("XRAY: " + msgBody)
default:
logger.Debug("XRAY: " + msg)
}
}
lw.lastLine = ""
return
}
if strings.Contains(msgLower, "tls handshake error") ||
strings.Contains(msgLower, "connection ends") {
logger.Debug("XRAY: " + msg)
lw.lastLine = msg
return
}
if strings.Contains(msgLower, "failed") {
logger.Error("XRAY: " + msg)
} else {
logger.Debug("XRAY: " + msg)
}
lw.lastLine = msg
}