Files
neoirc/internal/ircserver/server.go
T
sneak 6c555958d6
check / check (push) Failing after 3s
Stop the slow test packages waiting (closes #263)
- Tests hash passwords at bcrypt.MinCost, set through the
  PasswordCost field of db.Params. TestPasswordCost checks the
  cost with and without it.
- Test IRC servers check the queue only on broker notification;
  one test keeps the 100 ms fallback poll.
- The stop-timeout test shortens the timeout through a test-only
  setter.
- service and handlers tests build config.Config directly instead
  of through config.New, so they run in parallel. config exports
  DefaultTrustedProxies and ParseTrustedProxies for the handlers
  test config.
- Repeated race trials run as parallel subtests.
- Fixed sleeps become waits on the broker.
- A new test covers the relay's failed queue read, which only the
  100 ms poll had reached.

Model: opus-5-5
2026-11-10 22:12:13 +00:00

226 lines
4.6 KiB
Go

package ircserver
import (
"context"
"fmt"
"log/slog"
"net"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
)
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC
// Server.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
Database *db.Database
Broker *broker.Broker
Service *service.Service
}
// Server is the TCP IRC protocol server.
type Server struct {
log *slog.Logger
cfg *config.Config
database *db.Database
brk *broker.Broker
svc *service.Service
listener net.Listener
mu sync.Mutex
conns map[*Conn]struct{}
cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
// pollInterval and stopTimeout are the constants of the
// same names, except in tests, which change them.
pollInterval time.Duration
stopTimeout 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
// hooks. The listener is only started if IRC_LISTEN_ADDR
// is configured; otherwise the server is inert.
func New(
lifecycle fx.Lifecycle,
params Params,
) *Server {
srv := &Server{
log: params.Logger.Get(),
cfg: params.Config,
database: params.Database,
brk: params.Broker,
svc: params.Service,
conns: make(map[*Conn]struct{}),
listener: nil,
cancel: nil,
mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
pollInterval: pollInterval,
stopTimeout: stopTimeout,
resolveHost: resolveHost,
}
listenAddr := params.Config.IRCListenAddr
if listenAddr == "" {
return srv
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return srv.start(ctx, listenAddr)
},
OnStop: func(_ context.Context) error {
srv.stop()
return nil
},
})
return srv
}
// start begins listening for TCP connections.
//
//nolint:contextcheck // long-lived server ctx, not the short Fx one
func (s *Server) start(ctx context.Context, addr string) error {
var listenConfig net.ListenConfig
ln, err := listenConfig.Listen(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("irc listen: %w", err)
}
s.listener = ln
serverCtx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.log.Info(
"irc server listening", "addr", addr,
)
go s.acceptLoop(serverCtx)
return nil
}
// stop shuts down the listener and all connections, then
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() {
if s.cancel != nil {
s.cancel()
}
if s.listener != nil {
s.listener.Close() //nolint:errcheck,gosec
}
s.mu.Lock()
for c := range s.conns {
c.conn.Close() //nolint:errcheck,gosec
}
s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(s.stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", s.stopTimeout,
)
}
}
// acceptLoop accepts new connections.
func (s *Server) acceptLoop(ctx context.Context) {
for {
tcpConn, err := s.listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
s.log.Error(
"irc accept error", "error", err,
)
continue
}
}
client := newConn(
tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.pollInterval,
)
s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock()
go func() {
defer s.serving.Done()
defer func() {
s.mu.Lock()
delete(s.conns, client)
s.mu.Unlock()
}()
client.serve(
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}()
}
}