1 Commits
Author SHA1 Message Date
clawbot 6ed3fee559 Broadcast a session's QUIT once when its clients leave together (closes #154)
check / check (push) Canceled after 0s
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 reports whether it
deleted the session; only that caller sends the QUIT. db.LogoutClient
deletes the client and counts the rest in the same transaction.

Model: opus-5-5
2026-10-20 21:09:39 +00:00
12 changed files with 219 additions and 952 deletions

No files matched your search

+8 -9
View File
@@ -2569,15 +2569,14 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Sessions**: Both anonymous and password-protected sessions are deleted on - **Sessions**: Both anonymous and password-protected sessions are deleted on
`QUIT` or when the last client logs out (`POST /api/v1/logout` with no `QUIT` or when the last client logs out (`POST /api/v1/logout` with no
remaining clients triggers session cleanup). There is no distinction between remaining clients triggers session cleanup). There is no distinction between
session types in the cleanup path: the session is parted from its channels, session types in the cleanup path: the session is parted from its channels and
deleted, and its QUIT queued to its channel peers in one database transaction, deleted in one database transaction, and when several QUITs or logouts end one
so when several QUITs or logouts end one session at once only the one that session at once, only the one that deletes it broadcasts QUIT. Idle clients
deletes it broadcasts QUIT, and whatever a new holder of the nick sends are automatically removed after `SESSION_IDLE_TIMEOUT` (default 30 days)
reaches those peers after the QUIT. Idle clients are automatically removed without an authenticated HTTP API request or, on the IRC listener, a command —
after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP the server runs a background cleanup loop that, for a session left with no
API request or, on the IRC listener, a command — the server runs a background clients, parts the user from all channels, broadcasts QUIT, and releases the
cleanup loop that, for a session left with no clients, parts the user from all nick.
channels, broadcasts QUIT, and releases the nick.
- **Clients**: Individual client auth cookies are invalidated on - **Clients**: Individual client auth cookies are invalidated on
`POST /api/v1/logout`. A session can have multiple clients; removing one `POST /api/v1/logout`. A session can have multiple clients; removing one
doesn't affect others. However, when the last client is removed (via logout), doesn't affect others. However, when the last client is removed (via logout),
+95 -128
View File
@@ -854,6 +854,42 @@ func (database *Database) SetChannelTopicLocked(
return nil return nil
} }
// PartChannel removes a session from a channel.
func (database *Database) PartChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("part channel: %w", err)
}
return nil
}
// DeleteChannelIfEmpty removes a channel with no members.
func (database *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
if err != nil {
return fmt.Errorf(
"delete channel if empty: %w", err,
)
}
return nil
}
// PartChannelAndDeleteIfEmpty removes a session from a // PartChannelAndDeleteIfEmpty removes a session from a
// channel and deletes the channel if that left it with no // channel and deletes the channel if that left it with no
// members, in one transaction, so that no JOIN lands // members, in one transaction, so that no JOIN lands
@@ -1137,40 +1173,6 @@ func (database *Database) InsertMessage(
params json.RawMessage, params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("begin insert message: %w", err)
}
defer func() { _ = transaction.Rollback() }()
dbID, msgUUID, err := insertMessageInTx(
ctx, transaction, command, from, target,
toSessionID, toChannelID, params, body, meta,
)
if err != nil {
return 0, "", err
}
err = transaction.Commit()
if err != nil {
return 0, "", fmt.Errorf("commit insert message: %w", err)
}
return dbID, msgUUID, nil
}
// insertMessageInTx is InsertMessage, writing through
// transaction.
func insertMessageInTx(
ctx context.Context,
transaction *sql.Tx,
command, from, target string,
toSessionID, toChannelID int64,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
@@ -1187,7 +1189,7 @@ func insertMessageInTx(
meta = json.RawMessage("{}") meta = json.RawMessage("{}")
} }
res, err := transaction.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
to_session_id, to_channel_id, to_session_id, to_channel_id,
@@ -1214,34 +1216,7 @@ func (database *Database) EnqueueToSession(
ctx context.Context, ctx context.Context,
sessionID, messageID int64, sessionID, messageID int64,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) _, err := database.conn.ExecContext(ctx,
if err != nil {
return fmt.Errorf("begin enqueue: %w", err)
}
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit enqueue: %w", err)
}
return nil
}
// enqueueToSessionInTx is EnqueueToSession, writing through
// transaction.
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID int64,
) error {
_, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues `INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at) (client_id, message_id, created_at)
SELECT c.id, ?, ? SELECT c.id, ?, ?
@@ -1512,56 +1487,68 @@ func (database *Database) SetTopic(
return nil return nil
} }
// QuitSession ends a session in one transaction: it parts // DeleteSession removes a session and all its data.
// the session from its channels, deleting each channel this func (database *Database) DeleteSession(
// leaves with no members, deletes the session with its ctx context.Context,
// clients, and stores a QUIT from nick with body and queues sessionID int64,
// it to the other sessions that were on those channels. It ) error {
// returns those sessions, for the caller to wake once the _, err := database.conn.ExecContext(
// QUIT is committed. Of several calls for one session at ctx,
// once, only the first finds the session; the others change "DELETE FROM sessions WHERE id = ?",
// nothing and return none. sessionID,
)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// QuitSession parts a session from its channels, deleting
// each channel that this leaves with no members, and
// deletes the session with its clients, in one
// transaction. It returns the other sessions that were on
// those channels, and whether this call deleted the
// session: of several calls for one session at once, only
// one finds it still there, and the others change nothing.
func (database *Database) QuitSession( func (database *Database) QuitSession(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick string, ) ([]int64, bool, error) {
body json.RawMessage,
) ([]int64, error) {
transaction, err := database.conn.BeginTx(ctx, nil) transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("begin quit: %w", err) return nil, false, fmt.Errorf("begin quit: %w", err)
} }
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
peerIDs, err := quitSessionInTx( peerIDs, deleted, err := quitSessionInTx(
ctx, transaction, sessionID, nick, body, ctx, transaction, sessionID,
) )
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
err = transaction.Commit() err = transaction.Commit()
if err != nil { if err != nil {
return nil, fmt.Errorf("commit quit: %w", err) return nil, false, fmt.Errorf("commit quit: %w", err)
} }
return peerIDs, nil return peerIDs, deleted, nil
} }
// LogoutClient deletes a client of a session and, if that // LogoutClient deletes a client of a session and, if that
// leaves the session with no clients, does what QuitSession // leaves the session with no clients, does what
// does, in one transaction. It returns what QuitSession // QuitSession does, in one transaction. It returns what
// returns, or none if the session keeps a client. // QuitSession returns, or nothing and false if the session
// keeps a client.
func (database *Database) LogoutClient( func (database *Database) LogoutClient(
ctx context.Context, ctx context.Context,
sessionID, clientID int64, sessionID, clientID int64,
nick string, ) ([]int64, bool, error) {
body json.RawMessage,
) ([]int64, error) {
transaction, err := database.conn.BeginTx(ctx, nil) transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("begin logout: %w", err) return nil, false, fmt.Errorf("begin logout: %w", err)
} }
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
@@ -1569,7 +1556,7 @@ func (database *Database) LogoutClient(
_, err = transaction.ExecContext(ctx, _, err = transaction.ExecContext(ctx,
"DELETE FROM clients WHERE id = ?", clientID) "DELETE FROM clients WHERE id = ?", clientID)
if err != nil { if err != nil {
return nil, fmt.Errorf("delete client: %w", err) return nil, false, fmt.Errorf("delete client: %w", err)
} }
var remaining int64 var remaining int64
@@ -1579,26 +1566,29 @@ func (database *Database) LogoutClient(
sessionID, sessionID,
).Scan(&remaining) ).Scan(&remaining)
if err != nil { if err != nil {
return nil, fmt.Errorf("count clients: %w", err) return nil, false, fmt.Errorf("count clients: %w", err)
} }
var peerIDs []int64 var (
peerIDs []int64
deleted bool
)
if remaining == 0 { if remaining == 0 {
peerIDs, err = quitSessionInTx( peerIDs, deleted, err = quitSessionInTx(
ctx, transaction, sessionID, nick, body, ctx, transaction, sessionID,
) )
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
} }
err = transaction.Commit() err = transaction.Commit()
if err != nil { if err != nil {
return nil, fmt.Errorf("commit logout: %w", err) return nil, false, fmt.Errorf("commit logout: %w", err)
} }
return peerIDs, nil return peerIDs, deleted, nil
} }
// quitSessionInTx is QuitSession, writing through // quitSessionInTx is QuitSession, writing through
@@ -1607,9 +1597,7 @@ func quitSessionInTx(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, transaction *sql.Tx,
sessionID int64, sessionID int64,
nick string, ) ([]int64, bool, error) {
body json.RawMessage,
) ([]int64, error) {
rows, err := transaction.QueryContext(ctx, rows, err := transaction.QueryContext(ctx,
`SELECT DISTINCT session_id FROM channel_members `SELECT DISTINCT session_id FROM channel_members
WHERE session_id != ? AND channel_id IN WHERE session_id != ? AND channel_id IN
@@ -1617,26 +1605,26 @@ func quitSessionInTx(
WHERE session_id = ?)`, WHERE session_id = ?)`,
sessionID, sessionID) sessionID, sessionID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get channel peers: %w", err) return nil, false, fmt.Errorf("get channel peers: %w", err)
} }
peerIDs, err := scanInt64s(rows) peerIDs, err := scanInt64s(rows)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
rows, err = transaction.QueryContext(ctx, rows, err = transaction.QueryContext(ctx,
`SELECT channel_id FROM channel_members `SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID) WHERE session_id = ?`, sessionID)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, false, fmt.Errorf(
"get session channel ids: %w", err, "get session channel ids: %w", err,
) )
} }
channelIDs, err := scanInt64s(rows) channelIDs, err := scanInt64s(rows)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
for _, channelID := range channelIDs { for _, channelID := range channelIDs {
@@ -1644,43 +1632,22 @@ func quitSessionInTx(
ctx, transaction, channelID, sessionID, ctx, transaction, channelID, sessionID,
) )
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
} }
result, err := transaction.ExecContext(ctx, result, err := transaction.ExecContext(ctx,
"DELETE FROM sessions WHERE id = ?", sessionID) "DELETE FROM sessions WHERE id = ?", sessionID)
if err != nil { if err != nil {
return nil, fmt.Errorf("delete session: %w", err) return nil, false, fmt.Errorf("delete session: %w", err)
} }
deletedRows, err := result.RowsAffected() deletedRows, err := result.RowsAffected()
if err != nil { if err != nil {
return nil, fmt.Errorf("delete session: %w", err) return nil, false, fmt.Errorf("delete session: %w", err)
} }
if deletedRows == 0 || len(peerIDs) == 0 { return peerIDs, deletedRows > 0, nil
return nil, nil
}
messageID, _, err := insertMessageInTx(
ctx, transaction, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil,
)
if err != nil {
return nil, err
}
for _, peerID := range peerIDs {
err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID,
)
if err != nil {
return nil, err
}
}
return peerIDs, nil
} }
// GetUserCount returns the number of active users. // GetUserCount returns the number of active users.
+30 -127
View File
@@ -2,7 +2,6 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -412,7 +411,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid) err = database.PartChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -423,10 +422,7 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
// TestPartChannelAndDeleteIfEmpty checks that a part keeps func TestDeleteChannelIfEmpty(t *testing.T) {
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -439,34 +435,22 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
nicks := []string{"temp1", "temp2"} sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = database.GetChannelByName(ctx, "#empty") err = database.JoinChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatalf("channel deleted while a member is on it: %v", err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1]) err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -793,11 +777,6 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`) body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage( _, _, err = database.InsertMessage(
@@ -820,9 +799,12 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal("expected one DM and one channel message") t.Fatal("expected one DM and one channel message")
} }
// The session is the channel's only member, so quitting err = database.DeleteSession(ctx, oldSID)
// deletes the channel too. if err != nil {
_, err = database.QuitSession(ctx, oldSID, "reused", nil) t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, oldChID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -844,42 +826,34 @@ func TestGetHistoryReusedNames(t *testing.T) {
} }
} }
// TestQuitSession checks that QuitSession deletes the func TestDeleteSession(t *testing.T) {
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid, sharedID, _ := createSessionWithChannels( sid, _, _, err := database.CreateSession(
t, database, "deleteme", "#shared", "#alone", ctx, "deleteme", "", "", "",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, sharedID, peerSID) chID, err := database.GetOrCreateChannel(
ctx, "#delchan",
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
body := json.RawMessage(`["bye"]`) err = database.JoinChannel(ctx, chID, sid)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !slices.Equal(peerIDs, []int64{peerSID}) { err = database.DeleteSession(ctx, sid)
t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID) if err != nil {
t.Fatal(err)
} }
_, err = database.GetSessionByNick(ctx, "deleteme") _, err = database.GetSessionByNick(ctx, "deleteme")
@@ -887,80 +861,9 @@ func TestQuitSession(t *testing.T) {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, sharedID) ids, _ := database.GetChannelMemberIDs(ctx, chID)
if !slices.Equal(ids, []int64{peerSID}) { if len(ids) != 0 {
t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID) t.Fatal("expected no members after deletion")
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
} }
} }
+8 -105
View File
@@ -1459,7 +1459,14 @@ func checkQuitRace(
msgs, _ := tserver.pollMessages(observerToken, lastID) msgs, _ := tserver.pollMessages(observerToken, lastID)
quits := countQuits(msgs, nick) quits := 0
for _, msg := range msgs {
if msg[commandKey] == irc.CmdQuit && msg["from"] == nick {
quits++
}
}
if quits != 1 { if quits != 1 {
t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs) t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs)
} }
@@ -1476,110 +1483,6 @@ func checkQuitRace(
} }
} }
// 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 // login logs in to the session nick with testPassword and
// returns the new client's token. // returns the new client's token.
func (tserver *testServer) login(nick string) string { func (tserver *testServer) login(nick string) string {
+1 -3
View File
@@ -222,9 +222,7 @@ func (c *Conn) serve(
) { ) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
// Stopping the server cancels ctx, and the cleanup must defer c.cleanup(ctx)
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
ping := time.AfterFunc(interval, func() { ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx) c.send("PING :" + c.serverSfx)
-4
View File
@@ -85,10 +85,6 @@ const PingInterval = pingInterval
// for a line before it closes the connection. // for a line before it closes the connection.
const PongDeadline = pongDeadline const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
+1 -43
View File
@@ -16,10 +16,6 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC // Params defines the dependencies for creating an IRC
// Server. // Server.
type Params struct { type Params struct {
@@ -44,10 +40,6 @@ type Server struct {
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the // pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them. // same names, except in tests, which shorten them.
pingInterval time.Duration pingInterval time.Duration
@@ -71,7 +63,6 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
@@ -121,10 +112,7 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil return nil
} }
// stop shuts down the listener and all connections, then // stop shuts down the listener and all connections.
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() { func (s *Server) stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
@@ -139,22 +127,6 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
s.mu.Unlock() s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
} }
// acceptLoop accepts new connections. // acceptLoop accepts new connections.
@@ -180,24 +152,10 @@ func (s *Server) acceptLoop(ctx context.Context) {
) )
s.mu.Lock() s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{} s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer s.serving.Done()
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.conns, client) delete(s.conns, client)
-115
View File
@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -813,120 +812,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) { func TestTopicSetAndGet(t *testing.T) {
t.Parallel() t.Parallel()
+46 -21
View File
@@ -45,8 +45,11 @@ type Params struct {
// It manages routing, middleware, and lifecycle. // It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params Params params Params
@@ -75,16 +78,13 @@ func New(
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes() srv.SetupRoutes()
// A closed stdout or stderr must not end the process. // The start hook's context ends when the hook
signal.Ignore(syscall.SIGPIPE) // returns; serving must outlive it.
go srv.serve() //nolint:contextcheck,gosec // G118
srv.startHTTP()
return nil return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(_ context.Context) error {
srv.cleanShutdown(ctx)
return nil return nil
}, },
}) })
@@ -129,15 +129,44 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true srv.sentryEnabled = true
} }
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() { func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up") srv.log.Info("cleaning up")
} }
// cleanShutdown stops the HTTP server, waiting up to func (srv *Server) cleanShutdown() {
// shutdownTimeout for the requests in progress to finish. srv.exitCode = 0
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout, context.Background(), shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := srv.httpServer.Shutdown(ctxShutdown)
@@ -162,11 +191,7 @@ func (srv *Server) configure() {
// Server configuration placeholder. // Server configuration placeholder.
} }
// startHTTP creates the HTTP server and serves it from a new func (srv *Server) serveUntilShutdown() {
// goroutine. srv.httpServer is set before that goroutine
// starts and never changes, so it and cleanShutdown read it
// without a lock.
func (srv *Server) startHTTP() {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port, ":%d", srv.params.Config.Port,
) )
@@ -179,16 +204,16 @@ func (srv *Server) startHTTP() {
Handler: srv, Handler: srv,
} }
go srv.serveUntilShutdown()
}
func (srv *Server) serveUntilShutdown() {
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", srv.httpServer.Addr, "http begin listen", "listenaddr", listenAddr,
) )
err := srv.httpServer.ListenAndServe() err := srv.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err) srv.log.Error("listen error", "error", err)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
} }
} }
-247
View File
@@ -1,247 +0,0 @@
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
"strconv"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
const (
testTimeout = 5 * time.Second
// checkStep is how often waitUntil checks again.
checkStep = 10 * time.Millisecond
// stopHoldTime is how long a test waits to see that the
// app has not stopped.
stopHoldTime = 200 * time.Millisecond
)
// TestStopWaitsForRequestInProgress checks that stopping the
// app, which runs the stop hook server.New registers, waits
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Parallel()
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
)
if err != nil {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
go func() {
statuses <- getAuthenticated(
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
)
}()
waitUntil(t, "the long poll to wait for a message", func() bool {
return brk.ListenerCount(sessionID) == 1
})
var stopErr error
stopped := make(chan struct{})
go func() {
stopErr = app.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("the app stopped while a request was in progress")
case <-time.After(stopHoldTime):
}
brk.Notify(sessionID)
status := <-statuses
if status != http.StatusOK {
t.Errorf("status %d, want %d", status, http.StatusOK)
}
select {
case <-stopped:
if stopErr != nil {
t.Errorf("stop: %v", stopErr)
}
case <-time.After(testTimeout):
t.Fatal("the app did not stop after the request finished")
}
}
// startApp starts the server with the dependencies
// cmd/neoircd gives it, on a fresh in-memory database, with
// the HTTP server listening on port.
func startApp(
t *testing.T,
port int,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
var (
brk *broker.Broker
database *db.Database
)
app := fxtest.New(t,
fx.Supply(&globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}),
fx.Provide(
broker.New,
config.New,
db.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
server.New,
service.New,
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port
return cfg
}),
fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database),
)
app.RequireStart()
// Stops the app if the test ends before stopping it.
t.Cleanup(app.RequireStop)
return app, brk, database
}
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
return tcpAddr.Port
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(checkStep)
}
}
// getAuthenticated sends a GET request to url as the session
// whose auth token is token, and returns the response status,
// or 0 if there was no response.
func getAuthenticated(t *testing.T, url, token string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, url, nil,
)
if err != nil {
t.Errorf("new request: %v", err)
return 0
}
request.Header.Set("Cookie", "neoirc_auth="+token)
client := http.Client{Timeout: testTimeout}
response, err := client.Do(request)
if err != nil {
t.Errorf("get %s: %v", url, err)
return 0
}
_ = response.Body.Close()
return response.StatusCode
}
+30 -20
View File
@@ -827,28 +827,20 @@ func (s *Service) ChangeNick(
} }
// BroadcastQuit parts the session from all its channels, // BroadcastQuit parts the session from all its channels,
// deletes it, and queues a QUIT to the users who shared a // deletes it, and sends a QUIT to the users who shared a
// channel with it, in one transaction, so that the QUIT is // channel with it. Of several calls for one session at
// queued before anyone else can take the nick. Of several // once, only the one that deletes it sends the QUIT.
// calls for one session at once, only the one that deletes
// it sends the QUIT.
func (s *Service) BroadcastQuit( func (s *Service) BroadcastQuit(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, reason string, nick, reason string,
) { ) {
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson peerIDs, deleted, err := s.db.QuitSession(ctx, sessionID)
if err != nil || !deleted {
peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body)
if err != nil {
s.log.Error("quit failed", "error", err)
return return
} }
for _, peerID := range peerIDs { s.sendQuit(ctx, nick, reason, peerIDs)
s.broker.Notify(peerID)
}
} }
// Logout deletes a client of the session and, if that was // Logout deletes a client of the session and, if that was
@@ -858,17 +850,15 @@ func (s *Service) Logout(
sessionID, clientID int64, sessionID, clientID int64,
nick string, nick string,
) error { ) error {
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson peerIDs, deleted, err := s.db.LogoutClient(
ctx, sessionID, clientID,
peerIDs, err := s.db.LogoutClient(
ctx, sessionID, clientID, nick, body,
) )
if err != nil { if err != nil {
return fmt.Errorf("logout: %w", err) return fmt.Errorf("logout: %w", err)
} }
for _, peerID := range peerIDs { if deleted {
s.broker.Notify(peerID) s.sendQuit(ctx, nick, "Connection closed", peerIDs)
} }
return nil return nil
@@ -1353,6 +1343,26 @@ func (s *Service) broadcastNickChange(
} }
} }
// sendQuit sends the QUIT of nick, with reason, to the
// sessions peerIDs, as one message row fanned out to all of
// them.
func (s *Service) sendQuit(
ctx context.Context,
nick, reason string,
peerIDs []int64,
) {
if len(peerIDs) == 0 {
return
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil, peerIDs,
)
}
// joinError returns the IRC error for a JOIN of channel // joinError returns the IRC error for a JOIN of channel
// that db.CreateOrJoinChannel refused with err, or err // that db.CreateOrJoinChannel refused with err, or err
// wrapped if it is not a refusal. // wrapped if it is not a refusal.
-130
View File
@@ -567,136 +567,6 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
} }
} }
// 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 // TestJoinUsesUpInvite checks that the JOIN an invite let
// into a +i channel uses the invite up, so that a JOIN // into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN. // after leaving gets ERR_INVITEONLYCHAN.