Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Waiting to run

JoinChannel made the channel, counted its members, ran the join
checks and inserted the member as separate statements, so two
requests could each decide on a count the other was about to change:
+l was exceeded, two first joiners both became operator, and a JOIN
racing the last PART failed its foreign key and answered 500.

db.CreateOrJoinChannel now does all of that, and uses up the invite,
in one transaction; the join checks moved into it, and the service
turns its refusals into the same IRC errors as before. PART and KICK
remove the member and delete an empty channel in one transaction
(db.PartChannelAndDeleteIfEmpty). The db functions this leaves with
no caller are removed.

Model: opus-5-5
This commit was merged in pull request #164.
This commit is contained in:
2026-10-20 22:48:25 +02:00
parent 292f1eb7a9
commit 108e381428
4 changed files with 645 additions and 126 deletions
+19 -73
View File
@@ -536,41 +536,13 @@ func (s *Service) JoinChannel(
}
}
chID, err := s.db.GetOrCreateChannel(ctx, channel)
if err != nil {
return nil, fmt.Errorf("get/create channel: %w", err)
}
memberCount, countErr := s.db.CountChannelMembers(
ctx, chID,
chID, isCreator, err := s.db.CreateOrJoinChannel(
ctx, channel, sessionID, suppliedKey,
)
isCreator := countErr == nil && memberCount == 0
if !isCreator {
joinErr := checkJoinRestrictions(
ctx, s.db, chID, sessionID,
channel, suppliedKey, memberCount,
)
if joinErr != nil {
return nil, joinErr
}
}
if isCreator {
err = s.db.JoinChannelAsOperator(
ctx, chID, sessionID,
)
} else {
err = s.db.JoinChannel(ctx, chID, sessionID)
}
if err != nil {
return nil, fmt.Errorf("join channel: %w", err)
return nil, joinError(channel, err)
}
// Clear invite after successful join.
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
@@ -622,8 +594,7 @@ func (s *Service) PartChannel(
nil, body, nil, recipients,
)
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
return nil
}
@@ -748,8 +719,7 @@ func (s *Service) KickUser(
params, body, nil, memberIDs,
)
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
return nil
}
@@ -1392,54 +1362,30 @@ func (s *Service) broadcastNickChange(
}
}
// checkJoinRestrictions validates Tier 2 join conditions:
// bans, invite-only, channel key, and user limit.
func checkJoinRestrictions(
ctx context.Context,
database *db.Database,
chID, sessionID int64,
channel, suppliedKey string,
memberCount int64,
) error {
isBanned, banErr := database.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
// joinError returns the IRC error for a JOIN of channel
// that db.CreateOrJoinChannel refused with err, or err
// wrapped if it is not a refusal.
func joinError(channel string, err error) error {
switch {
case errors.Is(err, db.ErrBannedFromChannel):
return &IRCError{
Code: irc.ErrBannedFromChan,
Params: []string{channel},
Message: "Cannot join channel (+b)",
}
}
isInviteOnly, ioErr := database.IsChannelInviteOnly(
ctx, chID,
)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := database.HasChannelInvite(
ctx, chID, sessionID,
)
if invErr != nil || !hasInvite {
return &IRCError{
Code: irc.ErrInviteOnlyChan,
Params: []string{channel},
Message: "Cannot join channel (+i)",
}
case errors.Is(err, db.ErrInviteOnlyChannel):
return &IRCError{
Code: irc.ErrInviteOnlyChan,
Params: []string{channel},
Message: "Cannot join channel (+i)",
}
}
key, keyErr := database.GetChannelKey(ctx, chID)
if keyErr == nil && key != "" && suppliedKey != key {
case errors.Is(err, db.ErrBadChannelKey):
return &IRCError{
Code: irc.ErrBadChannelKey,
Params: []string{channel},
Message: "Cannot join channel (+k)",
}
}
limit, limErr := database.GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 &&
memberCount >= int64(limit) {
case errors.Is(err, db.ErrChannelIsFull):
return &IRCError{
Code: irc.ErrChannelIsFull,
Params: []string{channel},
@@ -1447,7 +1393,7 @@ func checkJoinRestrictions(
}
}
return nil
return fmt.Errorf("join channel: %w", err)
}
// extractHashcashFromMeta parses the meta JSON and
+397
View File
@@ -13,6 +13,7 @@ import (
"os"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -255,6 +256,402 @@ func TestPartChannel(t *testing.T) {
}
}
// concurrentJoinTrials is how many times each test of
// concurrent JOINs repeats its race. Before a JOIN ran as
// one transaction, ten trials lost each race every time.
const concurrentJoinTrials = 10
// runConcurrently runs each of funcs in its own goroutine,
// releasing them all at once, and returns their errors in
// the order of funcs.
func runConcurrently(funcs ...func() error) []error {
start := make(chan struct{})
errs := make([]error, len(funcs))
var wg sync.WaitGroup
for i, run := range funcs {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs[i] = run()
}()
}
close(start)
wg.Wait()
return errs
}
// joinConcurrently creates a session for each nick and has
// them all JOIN channel at once. It returns the result and
// error of each JOIN, in the order of nicks.
func joinConcurrently(
ctx context.Context,
t *testing.T,
env *testEnv,
channel string,
nicks ...string,
) ([]*service.JoinResult, []error) {
t.Helper()
results := make([]*service.JoinResult, len(nicks))
joins := make([]func() error, len(nicks))
for i, nick := range nicks {
sessionID := createSession(ctx, t, env.db, nick)
joins[i] = func() error {
result, err := env.svc.JoinChannel(
ctx, sessionID, nick, channel, "",
)
results[i] = result
return err
}
}
return results, runConcurrently(joins...)
}
// TestConcurrentJoinsRespectUserLimit checks that of two
// concurrent JOINs of a +l 2 channel with one member, one
// gets in and the other gets ERR_CHANNELISFULL.
func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
const userLimit = 2
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
channel := fmt.Sprintf("#full%d", trial)
opNick := fmt.Sprintf("op%d", trial)
opSID := createSession(ctx, t, env.db, opNick)
created, err := env.svc.JoinChannel(
ctx, opSID, opNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", opNick, err)
}
err = env.db.SetChannelUserLimit(
ctx, created.ChannelID, userLimit,
)
if err != nil {
t.Fatalf("set +l: %v", err)
}
_, errs := joinConcurrently(
ctx, t, env, channel,
fmt.Sprintf("a%d", trial), fmt.Sprintf("b%d", trial),
)
refused := 0
for _, joinErr := range errs {
var ircErr *service.IRCError
switch {
case joinErr == nil:
case errors.As(joinErr, &ircErr) &&
ircErr.Code == irc.ErrChannelIsFull:
refused++
default:
t.Errorf("trial %d: JOIN: %v", trial, joinErr)
}
}
members, err := env.db.ChannelMembers(ctx, created.ChannelID)
if err != nil {
t.Fatalf("channel members: %v", err)
}
if len(members) != userLimit || refused != 1 {
t.Errorf(
"trial %d: %d members and %d refused, want %d and 1",
trial, len(members), refused, userLimit,
)
}
}
}
// TestConcurrentFirstJoinsMakeOneOperator checks that of
// two concurrent JOINs that create a channel, exactly one
// makes its session the operator.
func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
results, errs := joinConcurrently(
ctx, t, env, fmt.Sprintf("#new%d", trial),
fmt.Sprintf("first%d", trial),
fmt.Sprintf("second%d", trial),
)
creators := 0
for i, err := range errs {
if err != nil {
t.Fatalf("trial %d: JOIN: %v", trial, err)
}
if results[i].IsCreator {
creators++
}
}
members, err := env.db.ChannelMembers(
ctx, results[0].ChannelID,
)
if err != nil {
t.Fatalf("channel members: %v", err)
}
operators := 0
for _, member := range members {
if member.IsOperator {
operators++
}
}
if creators != 1 || operators != 1 {
t.Errorf(
"trial %d: %d creators and %d operators, want 1 each",
trial, creators, operators,
)
}
}
}
// TestJoinConcurrentWithLastPart checks that a JOIN
// concurrent with the PART of a channel's only member
// either joins a channel that still exists or gets
// ERR_NOSUCHCHANNEL, and never fails otherwise.
func TestJoinConcurrentWithLastPart(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
channel := fmt.Sprintf("#last%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)
_, err := env.svc.JoinChannel(
ctx, leaverSID, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
}
errs := runConcurrently(
func() error {
return env.svc.PartChannel(
ctx, leaverSID, leaverNick, channel, "",
)
},
func() error {
_, joinErr := env.svc.JoinChannel(
ctx, joinerSID, joinerNick, channel, "",
)
return joinErr
},
)
if errs[0] != nil {
t.Fatalf("trial %d: PART: %v", trial, errs[0])
}
var ircErr *service.IRCError
switch {
case errs[1] == nil:
chID, lookupErr := env.db.GetChannelByName(ctx, channel)
isMember, _ := env.db.IsChannelMember(ctx, chID, joinerSID)
if lookupErr != nil || !isMember {
t.Errorf(
"trial %d: JOIN succeeded, but %s is not on %s",
trial, joinerNick, channel,
)
}
case errors.As(errs[1], &ircErr) &&
ircErr.Code == irc.ErrNoSuchChannel:
default:
t.Errorf(
"trial %d: JOIN got %v, want success or 403",
trial, errs[1],
)
}
}
}
// 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.
func TestJoinUsesUpInvite(t *testing.T) {
const channel = "#club"
env := newTestEnv(t)
ctx := t.Context()
opSID := createSession(ctx, t, env.db, "op")
guestSID := createSession(ctx, t, env.db, "guest")
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil {
t.Fatalf("join op: %v", err)
}
err = env.db.SetChannelInviteOnly(ctx, created.ChannelID, true)
if err != nil {
t.Fatalf("set +i: %v", err)
}
_, err = env.svc.InviteUser(ctx, opSID, "op", "guest", channel)
if err != nil {
t.Fatalf("invite guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
if err != nil {
t.Fatalf("invited join: %v", err)
}
err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "")
if err != nil {
t.Fatalf("part guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
t.Fatalf("second join: want ERR_INVITEONLYCHAN, got %v", err)
}
}
// TestJoinRefusedByHostmaskBan checks that a ban naming a
// session's nick, username and hostname refuses its JOIN
// with ERR_BANNEDFROMCHAN.
func TestJoinRefusedByHostmaskBan(t *testing.T) {
const channel = "#club"
env := newTestEnv(t)
ctx := t.Context()
opSID := createSession(ctx, t, env.db, "op")
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil {
t.Fatalf("join op: %v", err)
}
err = env.db.AddChannelBan(
ctx, created.ChannelID, "guest!user@host.example", "op",
)
if err != nil {
t.Fatalf("add ban: %v", err)
}
guestSID, _, _, err := env.db.CreateSession(
ctx, "guest", "user", "host.example", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
t.Fatalf("want ERR_BANNEDFROMCHAN, got %v", err)
}
}
// TestPartDeletesChannelOnlyWhenEmpty checks that a PART
// keeps a channel other members are still on and deletes
// the channel when its last member parts.
func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
const channel = "#room"
env := newTestEnv(t)
ctx := t.Context()
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "")
if err != nil {
t.Fatalf("part alice: %v", err)
}
chID, err := env.db.GetChannelByName(ctx, channel)
if err != nil {
t.Fatalf("channel gone while bob is on it: %v", err)
}
isMember, _ := env.db.IsChannelMember(ctx, chID, bobSID)
if !isMember {
t.Fatal("bob is not on the channel after alice parted")
}
err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
if err != nil {
t.Fatalf("part bob: %v", err)
}
_, err = env.db.GetChannelByName(ctx, channel)
if err == nil {
t.Fatal("channel still exists after its last member parted")
}
}
// TestKickOfLastMemberDeletesChannel checks that a KICK
// that leaves a channel with no members deletes it.
func TestKickOfLastMemberDeletesChannel(t *testing.T) {
const channel = "#room"
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "")
if err != nil {
t.Fatalf("kick alice: %v", err)
}
_, err = env.db.GetChannelByName(ctx, channel)
if err == nil {
t.Fatal("channel still exists after its last member was kicked")
}
}
func TestSendChannelMessage(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()