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
+15 -10
View File
@@ -45,6 +45,11 @@ type Conn struct {
serverSfx string
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
// serve(), from the relay goroutine and, for KILL, from
// Disconnect.
@@ -95,6 +100,9 @@ func newConn(
serverSfx: srvName,
remoteIP: host,
hostname: resolveHost(ctx, host),
pingInterval: pingInterval,
pongDeadline: pongDeadline,
}
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.
// 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,
) {
// When nothing has been read for pingInterval the client is
// sent a PING, and when nothing is read within pongDeadline
// after that the connection is closed.
func (c *Conn) serve(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer c.cleanup(ctx)
ping := time.AfterFunc(interval, func() {
ping := time.AfterFunc(c.pingInterval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
@@ -234,10 +239,10 @@ func (c *Conn) serve(
for {
// Every line read starts both waits again.
ping.Reset(interval)
ping.Reset(c.pingInterval)
_ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline),
time.Now().Add(c.pingInterval + c.pongDeadline),
)
if !scanner.Scan() {
+16 -13
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,
}
}
@@ -70,13 +64,22 @@ 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
// ServeConn serves tcpConn as s serves each connection it
// accepts, except that interval and deadline replace
// 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
// for a line before it closes the connection.
const PongDeadline = pongDeadline
go client.serve(context.Background())
}
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
+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)
}()
}
}
+27 -37
View File
@@ -71,22 +71,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(
@@ -143,9 +127,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 {
@@ -193,26 +175,34 @@ func (env *testEnv) dial(t *testing.T) *testClient {
}
}
// The ping interval and PONG deadline for
// newTestEnvWithShortPings. They are equal, so that three
// answered PINGs take longer than a client that answers none
// stays connected.
// The ping interval and PONG deadline for dialWithPingTimes.
// They are equal, so that three answered PINGs take longer
// than a client that answers none stays connected.
const (
testPingInterval = 300 * time.Millisecond
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 {
// dialWithPingTimes connects a client to the test server
// whose connection is sent a PING after testPingInterval with
// nothing read, and closed when nothing is read within
// testPongDeadline after that.
func (env *testEnv) dialWithPingTimes(t *testing.T) *testClient {
t.Helper()
return newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
testPingInterval, testPongDeadline,
serverSide, clientSide := net.Pipe()
t.Cleanup(func() { _ = clientSide.Close() })
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
@@ -442,8 +432,8 @@ func TestPingPong(t *testing.T) {
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
env := newTestEnv(t)
client := env.dialWithPingTimes(t)
client.register("answers")
for range 3 {
@@ -460,8 +450,8 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
env := newTestEnv(t)
client := env.dialWithPingTimes(t)
client.register("silent")
lines := client.readUntilClosed()
@@ -476,8 +466,8 @@ func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
env := newTestEnv(t)
client := env.dialWithPingTimes(t)
client.readUntil(isServerPing)
client.send("PONG :" + testServerName)