Broadcast a session's QUIT once when its clients leave together (closes #154)
check / check (push) Waiting to run

Logout deleted the client, counted the session's clients and ran
BroadcastQuit as separate statements, and BroadcastQuit read the peers,
parted, deleted empty channels and deleted the session separately too.
Two logouts or QUITs of one session at once both sent its QUIT, and a
JOIN landing between a part and the channel's deletion got into a +k
channel without the key.

db.QuitSession now does all of that in one transaction, parting each
channel as PartChannelAndDeleteIfEmpty does, and stores and queues the
QUIT in it only when it deleted the session, so the QUIT is queued
before the nick is free. db.LogoutClient deletes the client and counts
the rest in the same transaction.

Model: opus-5-5
This commit is contained in:
2026-10-20 23:24:51 +00:00
committed by sneak
parent bbdf3a70af
commit 0ca6ae6c2f
7 changed files with 1110 additions and 189 deletions
+34 -40
View File
@@ -826,61 +826,55 @@ func (s *Service) ChangeNick(
return nil
}
// BroadcastQuit broadcasts a QUIT to all channel peers,
// parts all channels, and deletes the session. Uses the
// FanOut pattern: one message row fanned out to all unique
// peer sessions.
// BroadcastQuit parts the session from all its channels,
// deletes it, and queues a QUIT to the users who shared a
// channel with it, in one transaction, so that the QUIT is
// queued before anyone else can take the nick, and then
// wakes those users' long polls. Of several calls for one
// session at once, only the one that deletes it sends the
// QUIT.
func (s *Service) BroadcastQuit(
ctx context.Context,
sessionID int64,
nick, reason string,
) {
channels, err := s.db.GetSessionChannels(
ctx, sessionID,
)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body)
if err != nil {
s.log.Error("quit failed", "error", err)
return
}
notified := make(map[int64]bool)
for _, peerID := range peerIDs {
s.broker.Notify(peerID)
}
}
for _, ch := range channels {
memberIDs, memErr := s.db.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
continue
}
// Logout deletes a client of the session and, if that was
// the session's last client, does what BroadcastQuit does.
// If that fails, nothing changes and Logout returns the
// error.
func (s *Service) Logout(
ctx context.Context,
sessionID, clientID int64,
nick string,
) error {
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson
for _, mid := range memberIDs {
if mid == sessionID || notified[mid] {
continue
}
notified[mid] = true
}
peerIDs, err := s.db.LogoutClient(
ctx, sessionID, clientID, nick, body,
)
if err != nil {
return fmt.Errorf("logout: %w", err)
}
if len(notified) > 0 {
recipients := make([]int64, 0, len(notified))
for sid := range notified {
recipients = append(recipients, sid)
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil, recipients,
)
for _, peerID := range peerIDs {
s.broker.Notify(peerID)
}
for _, ch := range channels {
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
return nil
}
// SetAway sets or clears the away message. Returns true