Resolve IRC client hostnames off the accept loop (closes #156) #172

Merged
clawbot merged 1 commits from issue-156-resolve-off-accept into next 2026-10-21 03:31:28 +02:00
4 changed files with 90 additions and 10 deletions
+8 -2
View File
@@ -70,7 +70,6 @@ type Conn struct {
} }
func newConn( func newConn(
ctx context.Context,
tcpConn net.Conn, tcpConn net.Conn,
log *slog.Logger, log *slog.Logger,
database *db.Database, database *db.Database,
@@ -94,7 +93,6 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -212,12 +210,15 @@ func resolveHost(ctx context.Context, addr string) string {
} }
// serve is the main loop for a single IRC client connection. // serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine. // Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent // When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that // a PING, and when nothing is read within deadline after that
// the connection is closed. // the connection is closed.
func (c *Conn) serve( func (c *Conn) serve(
ctx context.Context, ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration, interval, deadline time.Duration,
) { ) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -226,6 +227,11 @@ func (c *Conn) serve(
// still end the session then. // still end the session then.
defer c.cleanup(context.WithoutCancel(ctx)) defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() { ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx) c.send("PING :" + c.serverSfx)
}) })
+11 -5
View File
@@ -15,7 +15,8 @@ import (
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of // connections use interval and deadline in place of
// pingInterval and pongDeadline. // pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
@@ -23,6 +24,7 @@ func NewTestServer(
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration, interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
Appname: "neoirc", Appname: "neoirc",
@@ -44,9 +46,16 @@ func NewTestServer(
pingInterval: interval, pingInterval: interval,
pongDeadline: deadline, pongDeadline: deadline,
resolveHost: resolve,
} }
} }
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
return resolveHost(ctx, addr)
}
// Start exposes the unexported start method for tests. // Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr) return s.start(context.Background(), addr)
@@ -97,10 +106,7 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn( conn := newConn(tcpConn, log, nil, nil, cfg, nil)
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick conn.nick = nick
return conn return conn
+10 -2
View File
@@ -52,6 +52,10 @@ type Server struct {
// same names, except in tests, which shorten them. // same names, except in tests, which shorten them.
pingInterval time.Duration pingInterval time.Duration
pongDeadline time.Duration pongDeadline time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
} }
// New creates a new IRC Server and registers its lifecycle // New creates a new IRC Server and registers its lifecycle
@@ -75,6 +79,7 @@ func New(
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -175,7 +180,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
ctx, tcpConn, s.log, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
@@ -204,7 +209,10 @@ func (s *Server) acceptLoop(ctx context.Context) {
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve(ctx, s.pingInterval, s.pongDeadline) client.serve(
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+61 -1
View File
@@ -2,6 +2,7 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"errors" "errors"
@@ -11,6 +12,7 @@ import (
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -90,6 +92,21 @@ func newTestEnvWithPingTimes(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not // A random name, so that no other test environment, not
// even an earlier run of the same test, can share it. // even an earlier run of the same test, can share it.
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
@@ -147,7 +164,7 @@ func newTestEnvWithPingTimes(
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, log, cfg, database, brk, interval, deadline, resolve,
) )
err = srv.Start(addr) err = srv.Start(addr)
@@ -415,6 +432,49 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") assertContains(t, lines, " 001 ", "RPL_WELCOME")
} }
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test // TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one // environment starts on an empty database while another one
// is still open. // is still open.