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:
@@ -3395,15 +3395,11 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := request.Context()
|
||||
|
||||
err := hdlr.params.Database.DeleteClient(
|
||||
ctx, clientID,
|
||||
err := hdlr.svc.Logout(
|
||||
request.Context(), sessionID, clientID, nick,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"delete client failed", "error", err,
|
||||
)
|
||||
hdlr.log.Error("logout failed", "error", err)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
@@ -3413,23 +3409,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// If no clients remain, clean up the user fully:
|
||||
// part all channels (notifying members) and
|
||||
// delete the session.
|
||||
remaining, err := hdlr.params.Database.
|
||||
ClientCountForSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"client count check failed", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
if remaining == 0 {
|
||||
hdlr.cleanupUser(
|
||||
ctx, sessionID, nick,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.clearAuthCookie(writer)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
|
||||
@@ -1393,6 +1393,267 @@ func TestQuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// quitRaceTrials is how many times checkQuitRace repeats
|
||||
// its race.
|
||||
const quitRaceTrials = 5
|
||||
|
||||
// TestConcurrentLogoutsSendOneQuit checks that when both
|
||||
// clients of a session log out at once, the session's QUIT
|
||||
// is sent once.
|
||||
func TestConcurrentLogoutsSendOneQuit(t *testing.T) {
|
||||
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
||||
tserver.postConcurrently("/api/v1/logout", nil, tokens...)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConcurrentQuitsSendOneQuit checks that when both
|
||||
// clients of a session send QUIT at once, the session's
|
||||
// QUIT is sent once.
|
||||
func TestConcurrentQuitsSendOneQuit(t *testing.T) {
|
||||
quit, err := json.Marshal(map[string]any{commandKey: irc.CmdQuit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
||||
tserver.postConcurrently(apiMessages, quit, tokens...)
|
||||
})
|
||||
}
|
||||
|
||||
// checkQuitRace gives a session a password and two
|
||||
// clients, puts it on a channel with a second session, and
|
||||
// calls leave with the tokens of the two clients. It checks
|
||||
// that the second session then holds one QUIT from the
|
||||
// first, and that the first session is deleted: its tokens
|
||||
// are refused and its nick is free. It does all this
|
||||
// quitRaceTrials times, each on a new server, because each
|
||||
// trial logs in and logins are rate limited.
|
||||
func checkQuitRace(
|
||||
t *testing.T,
|
||||
leave func(tserver *testServer, tokens []string),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
const nick = "racingquitter"
|
||||
|
||||
for trial := range quitRaceTrials {
|
||||
tserver := newTestServer(t)
|
||||
firstToken := tserver.createSession(nick)
|
||||
observerToken := tserver.createSession("raceobserver")
|
||||
|
||||
tserver.sendCommand(firstToken, map[string]any{
|
||||
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
|
||||
})
|
||||
|
||||
tokens := []string{firstToken, tserver.login(nick)}
|
||||
|
||||
for _, token := range []string{firstToken, observerToken} {
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#quitrace",
|
||||
})
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(observerToken, 0)
|
||||
|
||||
leave(tserver, tokens)
|
||||
|
||||
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
||||
|
||||
quits := countQuits(msgs, nick)
|
||||
if quits != 1 {
|
||||
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
|
||||
}
|
||||
|
||||
for _, token := range tokens {
|
||||
status, _ := tserver.getState(token)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("trial %d: state got %d, want 401", trial, status)
|
||||
}
|
||||
}
|
||||
|
||||
// createSession fails the test if the nick is taken.
|
||||
tserver.createSession(nick)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutOfOneClientKeepsSession checks that when one of
|
||||
// a session's two clients logs out, the other client's
|
||||
// token still works, the session stays on its channel and
|
||||
// its channel peer gets no QUIT, and that the other
|
||||
// client's logout then sends one QUIT.
|
||||
func TestLogoutOfOneClientKeepsSession(t *testing.T) {
|
||||
const (
|
||||
nick = "twoclients"
|
||||
channel = "#logouts"
|
||||
)
|
||||
|
||||
tserver := newTestServer(t)
|
||||
firstToken := tserver.createSession(nick)
|
||||
observerToken := tserver.createSession("logoutobserver")
|
||||
|
||||
tserver.sendCommand(firstToken, map[string]any{
|
||||
commandKey: irc.CmdPass, bodyKey: []string{testPassword},
|
||||
})
|
||||
|
||||
secondToken := tserver.login(nick)
|
||||
|
||||
for _, token := range []string{firstToken, observerToken} {
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: channel,
|
||||
})
|
||||
}
|
||||
|
||||
_, lastID := tserver.pollMessages(observerToken, 0)
|
||||
|
||||
tserver.logout(firstToken)
|
||||
|
||||
status, _ := tserver.getState(firstToken)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("logged-out client's state: got %d, want 401", status)
|
||||
}
|
||||
|
||||
status, state := tserver.getState(secondToken)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("other client's state: got %d, want 200", status)
|
||||
}
|
||||
|
||||
channels, _ := state["channels"].([]any)
|
||||
names := make([]string, 0, len(channels))
|
||||
|
||||
for _, entry := range channels {
|
||||
info, _ := entry.(map[string]any)
|
||||
name, _ := info["name"].(string)
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
if !slices.Equal(names, []string{channel}) {
|
||||
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
||||
}
|
||||
|
||||
msgs, lastID := tserver.pollMessages(observerToken, lastID)
|
||||
|
||||
quits := countQuits(msgs, nick)
|
||||
if quits != 0 {
|
||||
t.Errorf("after the first logout: %d QUITs, want 0: %v", quits, msgs)
|
||||
}
|
||||
|
||||
tserver.logout(secondToken)
|
||||
|
||||
msgs, _ = tserver.pollMessages(observerToken, lastID)
|
||||
|
||||
quits = countQuits(msgs, nick)
|
||||
if quits != 1 {
|
||||
t.Errorf("after the second logout: %d QUITs, want 1: %v", quits, msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// countQuits returns how many of msgs are a QUIT from nick.
|
||||
func countQuits(msgs []map[string]any, nick string) int {
|
||||
quits := 0
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg[commandKey] == irc.CmdQuit && msg["from"] == nick {
|
||||
quits++
|
||||
}
|
||||
}
|
||||
|
||||
return quits
|
||||
}
|
||||
|
||||
// logout logs out the client token and fails the test
|
||||
// unless the server answers 200.
|
||||
func (tserver *testServer) logout(token string) {
|
||||
tserver.t.Helper()
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
tserver.t, http.MethodPost,
|
||||
tserver.url("/api/v1/logout"), token, nil,
|
||||
)
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("logout: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tserver.t.Fatalf("logout: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// login logs in to the session nick with testPassword and
|
||||
// returns the new client's token.
|
||||
func (tserver *testServer) login(nick string) string {
|
||||
tserver.t.Helper()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
nickKey: nick, passwordKey: testPassword,
|
||||
})
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("marshal login: %v", err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
tserver.t, http.MethodPost,
|
||||
tserver.url("/api/v1/login"), bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tserver.t.Fatalf("login: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == authCookieName {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
tserver.t.Fatal("no auth cookie in login response")
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// postConcurrently POSTs body to path once with each of
|
||||
// tokens, all at once.
|
||||
func (tserver *testServer) postConcurrently(
|
||||
path string, body []byte, tokens ...string,
|
||||
) {
|
||||
tserver.t.Helper()
|
||||
|
||||
start := make(chan struct{})
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for _, token := range tokens {
|
||||
waitGroup.Add(1)
|
||||
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
|
||||
<-start
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
tserver.t, http.MethodPost, tserver.url(path),
|
||||
token, bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
tserver.t.Error(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
waitGroup.Wait()
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("cmdtest")
|
||||
|
||||
Reference in New Issue
Block a user