1 Commits
Author SHA1 Message Date
clawbot aa56b8b63c PING a quiet IRC listener client before closing it (closes #155)
check / check (push) Canceled after 0s
serve closed a connection after five minutes with nothing read and never
sent a PING, so a quiet registered client lost its session. It now sends
PING :<server> after pingInterval with nothing read, and closes the
connection when nothing is read within pongDeadline after that. Every
line read starts both waits again. readTimeout is removed.

Unregistered connections are sent the PING too, so a PONG before
registration is now ignored instead of answered with ERR_NOTREGISTERED.

Model: opus-5-5
2026-10-20 16:12:36 +00:00
4 changed files with 59 additions and 70 deletions

No files matched your search

+15 -10
View File
@@ -45,6 +45,11 @@ type Conn struct {
serverSfx string serverSfx string
commands map[string]cmdHandler commands map[string]cmdHandler
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
// writeMu serializes writes to conn, which come from // writeMu serializes writes to conn, which come from
// serve(), from the relay goroutine and, for KILL, from // serve(), from the relay goroutine and, for KILL, from
// Disconnect. // Disconnect.
@@ -95,6 +100,9 @@ func newConn(
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host), hostname: resolveHost(ctx, host),
pingInterval: pingInterval,
pongDeadline: pongDeadline,
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -213,18 +221,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.
// 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 pingInterval the client is
// a PING, and when nothing is read within deadline after that // sent a PING, and when nothing is read within pongDeadline
// the connection is closed. // after that the connection is closed.
func (c *Conn) serve( func (c *Conn) serve(ctx context.Context) {
ctx context.Context,
interval, deadline time.Duration,
) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
defer c.cleanup(ctx) defer c.cleanup(ctx)
ping := time.AfterFunc(interval, func() { ping := time.AfterFunc(c.pingInterval, func() {
c.send("PING :" + c.serverSfx) c.send("PING :" + c.serverSfx)
}) })
defer ping.Stop() defer ping.Stop()
@@ -234,10 +239,10 @@ func (c *Conn) serve(
for { for {
// Every line read starts both waits again. // Every line read starts both waits again.
ping.Reset(interval) ping.Reset(c.pingInterval)
_ = c.conn.SetReadDeadline( _ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline), time.Now().Add(c.pingInterval + c.pongDeadline),
) )
if !scanner.Scan() { if !scanner.Scan() {
+16 -13
View File
@@ -13,16 +13,13 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing.
// connections use interval and deadline in place of
// pingInterval and pongDeadline.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
Appname: "neoirc", Appname: "neoirc",
@@ -41,9 +38,6 @@ func NewTestServer(
brk: brk, brk: brk,
svc: svc, svc: svc,
conns: make(map[*Conn]struct{}), conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
} }
} }
@@ -70,13 +64,22 @@ const KillWriteWindow = killWriteWindow
// output queue when no broker notification arrives. // output queue when no broker notification arrives.
const PollInterval = pollInterval const PollInterval = pollInterval
// PingInterval is how long the server waits with nothing read // ServeConn serves tcpConn as s serves each connection it
// from a connection before it sends a PING. // accepts, except that interval and deadline replace
const PingInterval = pingInterval // pingInterval and pongDeadline.
func (s *Server) ServeConn(
tcpConn net.Conn,
interval, deadline time.Duration,
) {
client := newConn(
context.Background(), tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc,
)
client.pingInterval = interval
client.pongDeadline = deadline
// PongDeadline is how long the server waits after the PING go client.serve(context.Background())
// for a line before it closes the connection. }
const PongDeadline = pongDeadline
// 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.
+1 -10
View File
@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"sync" "sync"
"time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
@@ -39,11 +38,6 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc 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 // New creates a new IRC Server and registers its lifecycle
@@ -63,9 +57,6 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -162,7 +153,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve(ctx, s.pingInterval, s.pongDeadline) client.serve(ctx)
}() }()
} }
} }
+27 -37
View File
@@ -71,22 +71,6 @@ func newTestEnvWithConfig(
) *testEnv { ) *testEnv {
t.Helper() 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 // 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(
@@ -143,9 +127,7 @@ func newTestEnvWithPingTimes(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(log, cfg, database, brk)
log, cfg, database, brk, interval, deadline,
)
err = srv.Start(addr) err = srv.Start(addr)
if err != nil { if err != nil {
@@ -193,26 +175,34 @@ func (env *testEnv) dial(t *testing.T) *testClient {
} }
} }
// The ping interval and PONG deadline for // The ping interval and PONG deadline for dialWithPingTimes.
// newTestEnvWithShortPings. They are equal, so that three // They are equal, so that three answered PINGs take longer
// answered PINGs take longer than a client that answers none // than a client that answers none stays connected.
// stays connected.
const ( const (
testPingInterval = 300 * time.Millisecond testPingInterval = 300 * time.Millisecond
testPongDeadline = 300 * time.Millisecond testPongDeadline = 300 * time.Millisecond
) )
// newTestEnvWithShortPings starts an IRC server that uses // dialWithPingTimes connects a client to the test server
// testPingInterval and testPongDeadline in place of // whose connection is sent a PING after testPingInterval with
// pingInterval and pongDeadline. // nothing read, and closed when nothing is read within
func newTestEnvWithShortPings(t *testing.T) *testEnv { // testPongDeadline after that.
func (env *testEnv) dialWithPingTimes(t *testing.T) *testClient {
t.Helper() t.Helper()
return newTestEnvWithPingTimes( serverSide, clientSide := net.Pipe()
t,
&config.Config{ServerName: testServerName}, t.Cleanup(func() { _ = clientSide.Close() })
testPingInterval, testPongDeadline,
env.srv.ServeConn(
serverSide, testPingInterval, testPongDeadline,
) )
return &testClient{
t: t,
conn: clientSide,
scanner: bufio.NewScanner(clientSide),
}
} }
// isServerPing reports whether line is the PING the test // isServerPing reports whether line is the PING the test
@@ -442,8 +432,8 @@ func TestPingPong(t *testing.T) {
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) { func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithShortPings(t) env := newTestEnv(t)
client := env.dial(t) client := env.dialWithPingTimes(t)
client.register("answers") client.register("answers")
for range 3 { for range 3 {
@@ -460,8 +450,8 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) { func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithShortPings(t) env := newTestEnv(t)
client := env.dial(t) client := env.dialWithPingTimes(t)
client.register("silent") client.register("silent")
lines := client.readUntilClosed() lines := client.readUntilClosed()
@@ -476,8 +466,8 @@ func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
func TestUnregisteredClientAnswersPing(t *testing.T) { func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithShortPings(t) env := newTestEnv(t)
client := env.dial(t) client := env.dialWithPingTimes(t)
client.readUntil(isServerPing) client.readUntil(isServerPing)
client.send("PONG :" + testServerName) client.send("PONG :" + testServerName)