1 Commits
Author SHA1 Message Date
clawbot ce24b177d6 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:57:36 +00:00
4 changed files with 70 additions and 59 deletions

No files matched your search

+10 -15
View File
@@ -45,11 +45,6 @@ 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.
@@ -100,9 +95,6 @@ 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()
@@ -221,15 +213,18 @@ 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 pingInterval the client is // When nothing has been read for interval the client is sent
// sent a PING, and when nothing is read within pongDeadline // a PING, and when nothing is read within deadline after that
// after that the connection is closed. // the connection is closed.
func (c *Conn) serve(ctx context.Context) { func (c *Conn) serve(
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(c.pingInterval, func() { ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx) c.send("PING :" + c.serverSfx)
}) })
defer ping.Stop() defer ping.Stop()
@@ -239,10 +234,10 @@ func (c *Conn) serve(ctx context.Context) {
for { for {
// Every line read starts both waits again. // Every line read starts both waits again.
ping.Reset(c.pingInterval) ping.Reset(interval)
_ = c.conn.SetReadDeadline( _ = c.conn.SetReadDeadline(
time.Now().Add(c.pingInterval + c.pongDeadline), time.Now().Add(interval + deadline),
) )
if !scanner.Scan() { if !scanner.Scan() {
+13 -16
View File
@@ -13,13 +13,16 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing. // NewTestServer creates a Server suitable for testing, whose
// 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",
@@ -38,6 +41,9 @@ func NewTestServer(
brk: brk, brk: brk,
svc: svc, svc: svc,
conns: make(map[*Conn]struct{}), conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
} }
} }
@@ -64,22 +70,13 @@ const KillWriteWindow = killWriteWindow
// output queue when no broker notification arrives. // output queue when no broker notification arrives.
const PollInterval = pollInterval const PollInterval = pollInterval
// ServeConn serves tcpConn as s serves each connection it // PingInterval is how long the server waits with nothing read
// accepts, except that interval and deadline replace // from a connection before it sends a PING.
// pingInterval and pongDeadline. const PingInterval = pingInterval
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
go client.serve(context.Background()) // 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 // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
+10 -1
View File
@@ -6,6 +6,7 @@ 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"
@@ -38,6 +39,11 @@ 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
@@ -57,6 +63,9 @@ 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
@@ -153,7 +162,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve(ctx) client.serve(ctx, s.pingInterval, s.pongDeadline)
}() }()
} }
} }
+37 -27
View File
@@ -71,6 +71,22 @@ 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(
@@ -127,7 +143,9 @@ func newTestEnvWithConfig(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer(log, cfg, database, brk) srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline,
)
err = srv.Start(addr) err = srv.Start(addr)
if err != nil { if err != nil {
@@ -175,34 +193,26 @@ func (env *testEnv) dial(t *testing.T) *testClient {
} }
} }
// The ping interval and PONG deadline for dialWithPingTimes. // The ping interval and PONG deadline for
// They are equal, so that three answered PINGs take longer // newTestEnvWithShortPings. They are equal, so that three
// than a client that answers none stays connected. // answered PINGs take longer than a client that answers none
// stays connected.
const ( const (
testPingInterval = 300 * time.Millisecond testPingInterval = 300 * time.Millisecond
testPongDeadline = 300 * time.Millisecond testPongDeadline = 300 * time.Millisecond
) )
// dialWithPingTimes connects a client to the test server // newTestEnvWithShortPings starts an IRC server that uses
// whose connection is sent a PING after testPingInterval with // testPingInterval and testPongDeadline in place of
// nothing read, and closed when nothing is read within // pingInterval and pongDeadline.
// testPongDeadline after that. func newTestEnvWithShortPings(t *testing.T) *testEnv {
func (env *testEnv) dialWithPingTimes(t *testing.T) *testClient {
t.Helper() t.Helper()
serverSide, clientSide := net.Pipe() return newTestEnvWithPingTimes(
t,
t.Cleanup(func() { _ = clientSide.Close() }) &config.Config{ServerName: testServerName},
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
@@ -432,8 +442,8 @@ func TestPingPong(t *testing.T) {
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) { func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnvWithShortPings(t)
client := env.dialWithPingTimes(t) client := env.dial(t)
client.register("answers") client.register("answers")
for range 3 { for range 3 {
@@ -450,8 +460,8 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) { func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnvWithShortPings(t)
client := env.dialWithPingTimes(t) client := env.dial(t)
client.register("silent") client.register("silent")
lines := client.readUntilClosed() lines := client.readUntilClosed()
@@ -466,8 +476,8 @@ func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
func TestUnregisteredClientAnswersPing(t *testing.T) { func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnvWithShortPings(t)
client := env.dialWithPingTimes(t) client := env.dial(t)
client.readUntil(isServerPing) client.readUntil(isServerPing)
client.send("PONG :" + testServerName) client.send("PONG :" + testServerName)