1 Commits
Author SHA1 Message Date
sneak 14ac339ae5 Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Canceled after 0s
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).

Model: opus-5-5
2026-10-20 18:44:00 +00:00
9 changed files with 8 additions and 461 deletions
-3
View File
@@ -2677,9 +2677,6 @@ IRC_LISTEN_ADDR: ""
An optional `PASS` before registration sets the session password (minimum 8
characters). Registering creates a session without a hashcash stamp; closing
the connection ends the session, as `QUIT` does.
- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and
is closed if it sends nothing in the 30 seconds after that; any line, such as
the client's `PONG`, starts both waits again.
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
ignored, for compatibility with modern clients.
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
+4 -21
View File
@@ -20,6 +20,7 @@ import (
const (
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second
@@ -213,31 +214,17 @@ func resolveHost(ctx context.Context, addr string) string {
// serve is the main loop for a single IRC client connection.
// Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that
// the connection is closed.
func (c *Conn) serve(
ctx context.Context,
interval, deadline time.Duration,
) {
func (c *Conn) serve(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer c.cleanup(ctx)
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline),
time.Now().Add(readTimeout),
)
if !scanner.Scan() {
@@ -379,7 +366,7 @@ func (c *Conn) handleMessage(
msg *Message,
) {
// Before registration, only NICK, USER, PASS, PING,
// PONG, QUIT, and CAP are accepted.
// QUIT, and CAP are accepted.
if !c.registered {
c.handlePreRegistration(ctx, msg)
@@ -465,10 +452,6 @@ func (c *Conn) handlePreRegistration(
case irc.CmdPing:
c.handlePing(msg)
return
case irc.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
return
case irc.CmdQuit:
c.handleQuit(msg)
+1 -22
View File
@@ -13,16 +13,13 @@ import (
"sneak.berlin/go/neoirc/internal/service"
)
// NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of
// pingInterval and pongDeadline.
// NewTestServer creates a Server suitable for testing.
// The caller must call Stop() when finished.
func NewTestServer(
log *slog.Logger,
cfg *config.Config,
database *db.Database,
brk *broker.Broker,
interval, deadline time.Duration,
) *Server {
globs := &globals.Globals{
Appname: "neoirc",
@@ -41,9 +38,6 @@ func NewTestServer(
brk: brk,
svc: svc,
conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
}
}
@@ -62,13 +56,6 @@ func (s *Server) Listener() net.Listener {
return s.listener
}
// PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it
// before they are closed.
func (s *Server) PingTimes() (time.Duration, time.Duration) {
return s.pingInterval, s.pongDeadline
}
// KillWriteWindow is how long Disconnect allows each write
// to a killed victim.
const KillWriteWindow = killWriteWindow
@@ -77,14 +64,6 @@ const KillWriteWindow = killWriteWindow
// output queue when no broker notification arrives.
const PollInterval = pollInterval
// PingInterval is how long the server waits with nothing read
// from a connection before it sends a PING.
const PingInterval = pingInterval
// PongDeadline is how long the server waits after the PING
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn(
+1 -10
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"net"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
@@ -39,11 +38,6 @@ type Server struct {
mu sync.Mutex
conns map[*Conn]struct{}
cancel context.CancelFunc
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
}
// New creates a new IRC Server and registers its lifecycle
@@ -63,9 +57,6 @@ func New(
listener: nil,
cancel: nil,
mu: sync.Mutex{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
}
listenAddr := params.Config.IRCListenAddr
@@ -162,7 +153,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
s.mu.Unlock()
}()
client.serve(ctx, s.pingInterval, s.pongDeadline)
client.serve(ctx)
}()
}
}
+1 -191
View File
@@ -13,13 +13,10 @@ import (
"testing"
"time"
"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/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
_ "modernc.org/sqlite"
)
@@ -73,22 +70,6 @@ func newTestEnvWithConfig(
) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t, cfg, ircserver.PingInterval, ircserver.PongDeadline,
)
}
// newTestEnvWithPingTimes is newTestEnvWithConfig, except
// that the server sends a connection a PING after interval
// with nothing read, and closes it when nothing is read
// within deadline after that.
func newTestEnvWithPingTimes(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
@@ -145,9 +126,7 @@ func newTestEnvWithPingTimes(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline,
)
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
@@ -195,34 +174,6 @@ func (env *testEnv) dial(t *testing.T) *testClient {
}
}
// The ping interval and PONG deadline for
// newTestEnvWithShortPings. They differ, so that a test can
// tell them apart; the PONG deadline is how long a test
// client has to answer a PING.
const (
testPingInterval = time.Second
testPongDeadline = 300 * time.Millisecond
)
// newTestEnvWithShortPings starts an IRC server that uses
// testPingInterval and testPongDeadline in place of
// pingInterval and pongDeadline.
func newTestEnvWithShortPings(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
testPingInterval, testPongDeadline,
)
}
// isServerPing reports whether line is the PING the test
// server sends a quiet client.
func isServerPing(line string) bool {
return line == "PING :"+testServerName
}
// testClient wraps a raw TCP connection with helpers.
type testClient struct {
t *testing.T
@@ -321,24 +272,6 @@ func (tc *testClient) readUntilClosed() []string {
return lines
}
// readServerPing reads until the PING the test server sends a
// quiet client, and fails the test if it came sooner than
// testPingInterval after lastSent, a time just before the
// client sent its last line.
func (tc *testClient) readServerPing(lastSent time.Time) {
tc.t.Helper()
tc.readUntil(isServerPing)
waited := time.Since(lastSent)
if waited < testPingInterval {
tc.t.Errorf(
"PING came %v after the client's last line, want at least %v",
waited, testPingInterval,
)
}
}
// assertContains checks that at least one line matches the
// given substring.
func assertContains(
@@ -455,129 +388,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response")
}
// TestQuietClientAnsweringPingStaysConnected checks that a
// registered client that sends nothing is sent a PING once
// the ping interval has passed since its last line, and stays
// connected as long as it answers each one, whether with a
// PONG or with another line.
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
answer string
}{
{name: "PONG", answer: "PONG :" + testServerName},
{name: "PING of its own", answer: "PING :answer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("answers")
// The second PING comes later than a client that
// answers none stays connected.
for range 2 {
client.readServerPing(lastSent)
lastSent = time.Now()
client.send(tt.answer)
}
client.sendAndExpect("PING :still here", "still here")
})
}
}
// TestQuietClientNotAnsweringPingIsClosed checks that a
// registered client that does not answer the PING is
// disconnected once the PONG deadline has passed after it.
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
// latest leaves room for a loaded host, but is sooner than
// a read deadline of two ping intervals would close it.
const (
earliest = testPingInterval + testPongDeadline
latest = 2 * testPingInterval
)
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("silent")
client.readServerPing(lastSent)
client.readUntilClosed()
waited := time.Since(lastSent)
if waited < earliest || waited >= latest {
t.Errorf(
"closed %v after the client's last line, want %v to %v",
waited, earliest, latest,
)
}
}
// TestUnregisteredClientAnswersPing checks that a PONG before
// registration, the answer to the PING unregistered clients
// are sent too, gets no ERR_NOTREGISTERED.
func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
client.readUntil(isServerPing)
client.send("PONG :" + testServerName)
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED")
}
// TestNewPingTimes checks that a server made by New sends a
// PING after 90 seconds with nothing read, and closes the
// connection 30 seconds after that, as README.md states.
func TestNewPingTimes(t *testing.T) {
t.Parallel()
const (
wantInterval = 90 * time.Second
wantDeadline = 30 * time.Second
)
log, err := logger.New(nil, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
// With no IRC_LISTEN_ADDR, New starts no listener and
// uses nothing else from its params.
srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{
Logger: log,
Config: &config.Config{},
})
interval, deadline := srv.PingTimes()
if interval != wantInterval || deadline != wantDeadline {
t.Errorf(
"PING after %v and close %v later, want %v and %v",
interval, deadline, wantInterval, wantDeadline,
)
}
}
// TestIdleCleanupKeepsActiveUser checks that a user who
// sends commands is kept by the idle cleanup, and a user who
// sends nothing is not.
-12
View File
@@ -1,12 +0,0 @@
package server
import "github.com/go-chi/chi/v5"
// NewTestSPAServer creates a Server whose router serves only
// the embedded web client.
func NewTestSPAServer() *Server {
srv := &Server{router: chi.NewRouter()}
srv.setupSPA()
return srv
}
+1 -4
View File
@@ -3,7 +3,6 @@ package server
import (
"io/fs"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/web"
@@ -138,10 +137,8 @@ func (srv *Server) setupSPA() {
return
}
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile(
strings.TrimPrefix(request.URL.Path, "/"),
request.URL.Path[1:],
)
if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile(
-41
View File
@@ -1,41 +0,0 @@
package server_test
import (
"io/fs"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/web"
)
// An absolute-form request line with no path, such as
// "GET http://example.com HTTP/1.1", leaves URL.Path empty.
func TestSPAServesIndexForEmptyPath(t *testing.T) {
t.Parallel()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "http://example.com", nil,
)
if request.URL.Path != "" {
t.Fatalf("URL.Path is %q, want it empty", request.URL.Path)
}
recorder := httptest.NewRecorder()
server.NewTestSPAServer().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d, want %d", recorder.Code, http.StatusOK)
}
indexHTML, err := fs.ReadFile(web.Dist, "dist/index.html")
if err != nil {
t.Fatal(err)
}
if recorder.Body.String() != string(indexHTML) {
t.Errorf("body is not the index page")
}
}
-157
View File
@@ -495,163 +495,6 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
}
}
// 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()