perf: prevent cron job overlap, auto-set GOMEMLIMIT, fix tgbot userStates race

cron: SkipIfStillRunning stops a slow 5s/10s job from overlapping itself and racing the shared xrayAPI (grpc conn leak) and the StatsLastValues map (fatal concurrent map write). memlimit: auto-detect a Go soft memory limit from XUI_MEMORY_LIMIT, the cgroup limit, or system RAM (about 90 percent); opt-in pprof via XUI_PPROF. tgbot: userStates now goes through a mutex-guarded store with TTL pruning (was raced by worker-pool and delayed-delete goroutines). check_client_ip: prefilter inbounds by settings LIKE limitIp instead of loading and JSON-parsing all of them every scan. minor: prune StatsLastValues, RateLimiter.lastSent, reportedRemoteTagConflict. docker-compose: document the memory knobs.
This commit is contained in:
MHSanaei
2026-06-22 02:48:58 +02:00
parent 679d2e1cca
commit 7d23a2c15b
11 changed files with 234 additions and 27 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Find(&inbounds).Error
err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error
if err != nil {
return false
}
+1
View File
@@ -407,6 +407,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
continue
}
reportedRemoteTagConflict.Delete(fmt.Sprintf("%d:%s", nodeID, snapIb.Tag))
newIb := model.Inbound{
UserId: defaultUserId,
NodeID: &nodeID,
+61 -1
View File
@@ -83,7 +83,65 @@ var (
client_Reset int
)
var userStates = make(map[int64]string)
// userStateStore guards the per-chat conversation states. The Telegram command
// and callback handlers run on a worker-pool goroutine while the message handler
// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
// crash. It also expires abandoned conversations so a user who starts a flow and
// goes silent doesn't leave an entry forever.
type userStateStore struct {
mu sync.Mutex
states map[int64]userStateEntry
lastPrune time.Time
}
type userStateEntry struct {
state string
at time.Time
}
var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)}
func (s *userStateStore) set(chatID int64, state string) {
s.mu.Lock()
s.states[chatID] = userStateEntry{state: state, at: time.Now()}
s.mu.Unlock()
}
func (s *userStateStore) get(chatID int64) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.states[chatID]
return e.state, ok
}
func (s *userStateStore) clear(chatID int64) {
s.mu.Lock()
delete(s.states, chatID)
s.mu.Unlock()
}
func (s *userStateStore) reset() {
s.mu.Lock()
s.states = make(map[int64]userStateEntry)
s.mu.Unlock()
}
// maybePrune drops conversations older than maxAge, at most once per maxAge so a
// busy bot doesn't sweep the whole map on every message.
func (s *userStateStore) maybePrune(maxAge time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if now.Sub(s.lastPrune) < maxAge {
return
}
s.lastPrune = now
for id, e := range s.states {
if now.Sub(e.at) > maxAge {
delete(s.states, id)
}
}
}
// LoginStatus represents the result of a login attempt.
type LoginStatus byte
@@ -411,6 +469,8 @@ func StopBot() {
isRunning = false
tgBotMutex.Unlock()
userStateMgr.reset()
if handler != nil {
handler.Stop()
}
+17 -16
View File
@@ -47,7 +47,7 @@ func (t *Tgbot) OnReceive() {
tgBotMutex.Unlock()
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
return nil
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
@@ -62,7 +62,7 @@ func (t *Tgbot) OnReceive() {
messageWorkerPool <- struct{}{} // Acquire worker
defer func() { <-messageWorkerPool }() // Release worker
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
}()
return nil
@@ -74,25 +74,26 @@ func (t *Tgbot) OnReceive() {
messageWorkerPool <- struct{}{} // Acquire worker
defer func() { <-messageWorkerPool }() // Release worker
delete(userStates, query.Message.GetChat().ID)
userStateMgr.clear(query.Message.GetChat().ID)
t.answerCallback(&query, checkAdmin(query.From.ID))
}()
return nil
}, th.AnyCallbackQueryWithMessage())
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
if userState, exists := userStates[message.Chat.ID]; exists {
userStateMgr.maybePrune(time.Hour)
if userState, exists := userStateMgr.get(message.Chat.ID); exists {
switch userState {
case "awaiting_email":
if client_Email == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
return nil
}
client_Email = strings.TrimSpace(message.Text)
if t.isSingleWord(client_Email) {
userStates[message.Chat.ID] = "awaiting_email"
userStateMgr.set(message.Chat.ID, "awaiting_email")
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
@@ -103,26 +104,26 @@ func (t *Tgbot) OnReceive() {
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
}
case "awaiting_comment":
if client_Comment == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
return nil
}
client_Comment = strings.TrimSpace(message.Text)
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
case "awaiting_tg_id":
input := strings.TrimSpace(message.Text)
if input == "" || input == "-" || strings.EqualFold(input, "none") {
client_TgID = ""
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
return nil
}
@@ -137,7 +138,7 @@ func (t *Tgbot) OnReceive() {
}
client_TgID = input
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
userStateMgr.clear(message.Chat.ID)
t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
}
@@ -1236,7 +1237,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
case "add_client_ch_default_email":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
userStates[chatId] = "awaiting_email"
userStateMgr.set(chatId, "awaiting_email")
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1246,7 +1247,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
case "add_client_ch_default_comment":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
userStates[chatId] = "awaiting_comment"
userStateMgr.set(chatId, "awaiting_comment")
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1256,7 +1257,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
case "add_client_ch_default_tg_id":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
userStates[chatId] = "awaiting_tg_id"
userStateMgr.set(chatId, "awaiting_tg_id")
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1357,10 +1358,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
case "add_client_default_info":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, chatId)
userStateMgr.clear(chatId)
t.addClient(chatId, t.BuildClientDraftMessage())
case "add_client_cancel":
delete(userStates, chatId)
userStateMgr.clear(chatId)
receiver_inbound_ID = 0
receiver_inbound_IDs = nil
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
+1 -1
View File
@@ -232,7 +232,7 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
go func() {
time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
t.deleteMessageTgBot(chatId, sentMsg.MessageID) // Delete the message
delete(userStates, chatId)
userStateMgr.clear(chatId)
}()
}
+13 -3
View File
@@ -476,9 +476,19 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
}
service.StartTrafficWriter()
// cron.Recover wraps every job so a panic is logged and the scheduler keeps
// running, instead of the panic taking down the whole panel process.
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds(), cron.WithChain(cron.Recover(cron.PrintfLogger(cronPanicLogger{}))))
// SkipIfStillRunning stops a slow job (e.g. the 5s traffic poll on a large
// install) from overlapping itself: two concurrent runs of the same job race
// the shared xrayAPI — leaking a grpc connection — and the StatsLastValues
// map, whose concurrent write is a fatal runtime throw cron.Recover can't
// catch. cron.Recover then logs any panic and keeps the scheduler alive.
s.cron = cron.New(
cron.WithLocation(loc),
cron.WithSeconds(),
cron.WithChain(
cron.SkipIfStillRunning(cron.DiscardLogger),
cron.Recover(cron.PrintfLogger(cronPanicLogger{})),
),
)
s.cron.Start()
// Wire the inbound-runtime manager once so InboundService can route