Broadcast a session's QUIT once when its clients leave together (closes #154)
check / check (push) Waiting to run
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:
+31
-40
@@ -826,61 +826,52 @@ 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. 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.
|
||||
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
|
||||
|
||||
@@ -495,6 +495,208 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJoinConcurrentWithLastQuit checks that a user who
|
||||
// sends JOIN without the key to a +k channel, again after
|
||||
// each ERR_BADCHANNELKEY, while the channel's only member
|
||||
// quits, gets in only once the channel is deleted, into a
|
||||
// new channel of that name with no key.
|
||||
func TestJoinConcurrentWithLastQuit(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
for trial := range concurrentJoinTrials {
|
||||
channel := fmt.Sprintf("#keyed%d", trial)
|
||||
leaverNick := fmt.Sprintf("leaver%d", trial)
|
||||
leaverSID := createSession(ctx, t, env.db, leaverNick)
|
||||
joinerNick := fmt.Sprintf("joiner%d", trial)
|
||||
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
||||
|
||||
created, err := env.svc.JoinChannel(
|
||||
ctx, leaverSID, leaverNick, channel, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join %s: %v", leaverNick, err)
|
||||
}
|
||||
|
||||
err = env.db.SetChannelKey(ctx, created.ChannelID, "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("set +k: %v", err)
|
||||
}
|
||||
|
||||
var joined *service.JoinResult
|
||||
|
||||
errs := runConcurrently(
|
||||
func() error {
|
||||
env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "")
|
||||
|
||||
return nil
|
||||
},
|
||||
// The database has one connection, so retried
|
||||
// JOINs take turns with the QUIT's statements
|
||||
// and land between any two that are not in one
|
||||
// transaction. A JOIN after the QUIT gets in, so
|
||||
// the loop ends.
|
||||
func() error {
|
||||
for {
|
||||
var (
|
||||
joinErr error
|
||||
ircErr *service.IRCError
|
||||
)
|
||||
|
||||
joined, joinErr = env.svc.JoinChannel(
|
||||
ctx, joinerSID, joinerNick, channel, "",
|
||||
)
|
||||
if !errors.As(joinErr, &ircErr) ||
|
||||
ircErr.Code != irc.ErrBadChannelKey {
|
||||
return joinErr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if errs[1] != nil {
|
||||
t.Fatalf("trial %d: JOIN: %v", trial, errs[1])
|
||||
}
|
||||
|
||||
key, _ := env.db.GetChannelKey(ctx, joined.ChannelID)
|
||||
if key != "" {
|
||||
t.Errorf(
|
||||
"trial %d: %s got into +k %s without the key",
|
||||
trial, joinerNick, channel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quitCrowdSize is how many sessions share a second channel
|
||||
// with the quitting session in
|
||||
// TestQuitQueuedAheadOfNewNickHoldersJoin.
|
||||
const quitCrowdSize = 10
|
||||
|
||||
// TestQuitQueuedAheadOfNewNickHoldersJoin checks that when
|
||||
// a new session takes a quitting session's nick and joins a
|
||||
// channel the quitting session shared with a watcher, while
|
||||
// the QUIT is going out, the watcher has the QUIT queued
|
||||
// ahead of that JOIN. The quitting session is also on a
|
||||
// channel with quitCrowdSize other sessions, made before the
|
||||
// watcher's channel, so that its QUIT reaches the watcher
|
||||
// last.
|
||||
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
for trial := range concurrentJoinTrials {
|
||||
nick := fmt.Sprintf("reused%d", trial)
|
||||
crowd := fmt.Sprintf("#crowd%d", trial)
|
||||
shared := fmt.Sprintf("#shared%d", trial)
|
||||
quitterSID := createSession(ctx, t, env.db, nick)
|
||||
|
||||
for _, channel := range []string{crowd, shared} {
|
||||
_, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
|
||||
if err != nil {
|
||||
t.Fatalf("join %s: %v", channel, err)
|
||||
}
|
||||
}
|
||||
|
||||
for member := range quitCrowdSize {
|
||||
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
|
||||
memberSID := createSession(ctx, t, env.db, memberNick)
|
||||
|
||||
_, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
|
||||
if err != nil {
|
||||
t.Fatalf("join %s: %v", memberNick, err)
|
||||
}
|
||||
}
|
||||
|
||||
watcherNick := fmt.Sprintf("watcher%d", trial)
|
||||
|
||||
watcherSID, watcherClientID, _, err := env.db.CreateSession(
|
||||
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create session %s: %v", watcherNick, err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
|
||||
if err != nil {
|
||||
t.Fatalf("join %s: %v", watcherNick, err)
|
||||
}
|
||||
|
||||
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("poll: %v", err)
|
||||
}
|
||||
|
||||
errs := runConcurrently(
|
||||
func() error {
|
||||
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
|
||||
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
return takeNickAndJoin(ctx, env, nick, shared)
|
||||
},
|
||||
)
|
||||
if errs[1] != nil {
|
||||
t.Fatalf("trial %d: new session: %v", trial, errs[1])
|
||||
}
|
||||
|
||||
msgs, _, err := env.db.PollMessages(
|
||||
ctx, watcherClientID, lastQueueID, 0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("poll: %v", err)
|
||||
}
|
||||
|
||||
commands := commandsFrom(msgs, nick)
|
||||
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
|
||||
t.Errorf(
|
||||
"trial %d: watcher got %v from %s, want QUIT then JOIN",
|
||||
trial, commands, nick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// takeNickAndJoin creates a session with nick, trying again
|
||||
// while another session holds the nick, and joins it to
|
||||
// channel.
|
||||
func takeNickAndJoin(
|
||||
ctx context.Context,
|
||||
env *testEnv,
|
||||
nick, channel string,
|
||||
) error {
|
||||
for {
|
||||
sessionID, _, _, err := env.db.CreateSession(
|
||||
ctx, nick, nick, "localhost", "127.0.0.1",
|
||||
)
|
||||
if db.IsUniqueConstraintError(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// commandsFrom returns the commands of the messages in msgs
|
||||
// that nick sent, in order.
|
||||
func commandsFrom(msgs []db.IRCMessage, nick string) []string {
|
||||
commands := make([]string, 0, len(msgs))
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg.From == nick {
|
||||
commands = append(commands, msg.Command)
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
// TestJoinUsesUpInvite checks that the JOIN an invite let
|
||||
// into a +i channel uses the invite up, so that a JOIN
|
||||
// after leaving gets ERR_INVITEONLYCHAN.
|
||||
|
||||
Reference in New Issue
Block a user