fix: send QUIT notifications for background idle cleanup
All checks were successful
check / check (push) Successful in 2m2s

The background idle cleanup (DeleteStaleUsers) was removing stale
clients/sessions directly via SQL without sending QUIT notifications
to channel members. This caused timed-out users to silently disappear
from channels.

Now runCleanup identifies sessions that will be orphaned by the stale
client deletion and calls cleanupUser for each one first, ensuring
QUIT messages are sent to all channel members — matching the explicit
logout behavior.

Also refactored cleanupUser to accept context.Context instead of
*http.Request so it can be called from both HTTP handlers and the
background cleanup goroutine.
This commit is contained in:
user
2026-03-01 06:33:15 -08:00
parent 910a5c2606
commit 4d7b7618b2
3 changed files with 70 additions and 4 deletions

View File

@@ -869,6 +869,60 @@ func (database *Database) DeleteStaleUsers(
return deleted, nil return deleted, nil
} }
// StaleSession holds the id and nick of a session
// whose clients are all stale.
type StaleSession struct {
ID int64
Nick string
}
// GetStaleOrphanSessions returns sessions where every
// client has a last_seen before cutoff.
func (database *Database) GetStaleOrphanSessions(
ctx context.Context,
cutoff time.Time,
) ([]StaleSession, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick
FROM sessions s
WHERE s.id IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen < ?
)
AND s.id NOT IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen >= ?
)`, cutoff, cutoff)
if err != nil {
return nil, fmt.Errorf(
"get stale orphan sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var result []StaleSession
for rows.Next() {
var stale StaleSession
if err := rows.Scan(&stale.ID, &stale.Nick); err != nil {
return nil, fmt.Errorf(
"scan stale session: %w", err,
)
}
result = append(result, stale)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate stale sessions: %w", err,
)
}
return result, nil
}
// GetSessionChannels returns channels a session // GetSessionChannels returns channels a session
// belongs to. // belongs to.
func (database *Database) GetSessionChannels( func (database *Database) GetSessionChannels(

View File

@@ -1405,7 +1405,7 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
if remaining == 0 { if remaining == 0 {
hdlr.cleanupUser( hdlr.cleanupUser(
request, sessionID, nick, ctx, sessionID, nick,
) )
} }
@@ -1418,12 +1418,10 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
// cleanupUser parts the user from all channels (notifying // cleanupUser parts the user from all channels (notifying
// members) and deletes the session. // members) and deletes the session.
func (hdlr *Handlers) cleanupUser( func (hdlr *Handlers) cleanupUser(
request *http.Request, ctx context.Context,
sessionID int64, sessionID int64,
nick string, nick string,
) { ) {
ctx := request.Context()
channels, _ := hdlr.params.Database. channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, sessionID) GetSessionChannels(ctx, sessionID)

View File

@@ -169,6 +169,20 @@ func (hdlr *Handlers) runCleanup(
) { ) {
cutoff := time.Now().Add(-timeout) cutoff := time.Now().Add(-timeout)
// Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers( deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff, ctx, cutoff,
) )