check / check (push) Waiting to run
The slowest test packages spent their time waiting rather than testing. db.Params gains an optional PasswordCost (zero means bcrypt.DefaultCost), and the db, ircserver and handlers tests hash at bcrypt.MinCost. A test IRC server's relay checks its queue on broker notification instead of polling. The service and handlers tests build config.Config directly and run in parallel; config exports DefaultTrustedProxies and ParseTrustedProxies for them. Fixed sleeps give way to waits for the event. Production defaults are unchanged. What still takes time is CPU work under -race or a timeout a test is about. Model: opus-5-5
366 lines
7.9 KiB
Go
366 lines
7.9 KiB
Go
package ircserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// relayMessages polls the client output queue and delivers
|
|
// IRC-formatted messages to the TCP connection. It runs
|
|
// in a goroutine for the lifetime of the connection.
|
|
func (c *Conn) relayMessages(ctx context.Context) {
|
|
// Use a ticker as a fallback; primary wakeup is via
|
|
// broker notification.
|
|
ticker := time.NewTicker(c.pollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Register with the broker before draining, so that a
|
|
// message queued after the drain has read the queue
|
|
// still signals waitCh.
|
|
waitCh := c.brk.Wait(c.sessionID)
|
|
|
|
// Drain any available messages. The drain ignores the
|
|
// cancel that comes when the connection ends, because a
|
|
// query cancelled while it runs leaves the SQLite
|
|
// connection marked broken, and the pool closes it. The
|
|
// loop still stops on ctx.
|
|
delivered := c.drainQueue(context.WithoutCancel(ctx))
|
|
|
|
if delivered {
|
|
c.brk.Remove(c.sessionID, waitCh)
|
|
|
|
// Tight loop while there are messages.
|
|
continue
|
|
}
|
|
|
|
// Wait for notification or timeout.
|
|
select {
|
|
case <-waitCh:
|
|
// New message notification — loop back.
|
|
case <-ticker.C:
|
|
// Periodic check. Nothing used waitCh, so take it
|
|
// out of the broker before the next pass adds
|
|
// another.
|
|
c.brk.Remove(c.sessionID, waitCh)
|
|
case <-ctx.Done():
|
|
c.brk.Remove(c.sessionID, waitCh)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const relayPollLimit = 100
|
|
|
|
// drainQueue polls the output queue and delivers all
|
|
// pending messages. Returns true if at least one message
|
|
// was delivered.
|
|
func (c *Conn) drainQueue(ctx context.Context) bool {
|
|
msgs, lastID, err := c.database.PollMessages(
|
|
ctx, c.clientID, c.lastQueueID, relayPollLimit,
|
|
)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if len(msgs) == 0 {
|
|
return false
|
|
}
|
|
|
|
for i := range msgs {
|
|
c.deliverIRCMessage(ctx, &msgs[i])
|
|
}
|
|
|
|
if lastID > c.lastQueueID {
|
|
c.lastQueueID = lastID
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// deliverIRCMessage converts a db.IRCMessage to wire
|
|
// protocol and sends it.
|
|
//
|
|
//nolint:cyclop // dispatch table
|
|
func (c *Conn) deliverIRCMessage(
|
|
ctx context.Context,
|
|
msg *db.IRCMessage,
|
|
) {
|
|
command := msg.Command
|
|
|
|
// Decode body as []string for the trailing text.
|
|
var bodyLines []string
|
|
|
|
if msg.Body != nil {
|
|
_ = json.Unmarshal(msg.Body, &bodyLines)
|
|
}
|
|
|
|
text := ""
|
|
if len(bodyLines) > 0 {
|
|
text = bodyLines[0]
|
|
}
|
|
|
|
// Route by command type.
|
|
switch {
|
|
case isNumeric(command):
|
|
c.deliverNumeric(msg, text)
|
|
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
|
c.deliverTextMessage(msg, command, bodyLines)
|
|
case command == irc.CmdJoin:
|
|
c.deliverJoin(msg)
|
|
case command == irc.CmdPart:
|
|
c.deliverPart(msg, text)
|
|
case command == irc.CmdNick:
|
|
c.deliverNickChange(ctx, msg, text)
|
|
case command == irc.CmdQuit:
|
|
c.deliverQuitMsg(msg, text)
|
|
case command == irc.CmdTopic:
|
|
c.deliverTopicChange(msg, text)
|
|
case command == irc.CmdKick:
|
|
c.deliverKickMsg(msg, text)
|
|
case command == "INVITE":
|
|
c.deliverInviteMsg(msg, text)
|
|
case command == irc.CmdWallops:
|
|
c.deliverWallops(msg, text)
|
|
case command == irc.CmdMode:
|
|
c.deliverMode(msg, bodyLines)
|
|
case command == irc.CmdPing:
|
|
// Server-originated PING — reply with PONG.
|
|
c.sendFromServer("PING", c.serverSfx)
|
|
default:
|
|
// Unknown command — deliver as server notice.
|
|
if text != "" {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
c.sendFromServer("NOTICE", nick, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isNumeric returns true if the command is a 3-digit
|
|
// numeric code.
|
|
func isNumeric(cmd string) bool {
|
|
return len(cmd) == 3 &&
|
|
cmd[0] >= '0' && cmd[0] <= '9' &&
|
|
cmd[1] >= '0' && cmd[1] <= '9' &&
|
|
cmd[2] >= '0' && cmd[2] <= '9'
|
|
}
|
|
|
|
// deliverNumeric sends a numeric reply.
|
|
func (c *Conn) deliverNumeric(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
from := msg.From
|
|
if from == "" {
|
|
from = c.serverSfx
|
|
}
|
|
|
|
var params []string
|
|
|
|
if msg.Params != nil {
|
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
allParams := make([]string, 0, 1+len(params)+1)
|
|
allParams = append(allParams, nick)
|
|
allParams = append(allParams, params...)
|
|
|
|
if text != "" {
|
|
allParams = append(allParams, text)
|
|
}
|
|
|
|
c.send(FormatMessage(from, msg.Command, allParams...))
|
|
}
|
|
|
|
// deliverTextMessage sends PRIVMSG or NOTICE, one line for
|
|
// each line of the body.
|
|
func (c *Conn) deliverTextMessage(
|
|
msg *db.IRCMessage,
|
|
command string,
|
|
bodyLines []string,
|
|
) {
|
|
from := msg.From
|
|
target := msg.To
|
|
|
|
prefix := from
|
|
if !strings.Contains(prefix, "!") {
|
|
prefix = from + "!" + from + "@*"
|
|
}
|
|
|
|
for _, line := range bodyLines {
|
|
c.send(FormatMessage(prefix, command, target, line))
|
|
}
|
|
}
|
|
|
|
// deliverJoin sends a JOIN notification.
|
|
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
c.send(FormatMessage(prefix, "JOIN", channel))
|
|
}
|
|
|
|
// deliverPart sends a PART notification.
|
|
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
if text != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "PART", channel, text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(prefix, "PART", channel))
|
|
}
|
|
}
|
|
|
|
// deliverNickChange sends a NICK change notification. The
|
|
// change may be its own session's, made from another client,
|
|
// so the connection first takes the nick its session holds
|
|
// now. It does not go by the line's old nick: the connection
|
|
// may have taken that nick after another user gave it up.
|
|
func (c *Conn) deliverNickChange(
|
|
ctx context.Context,
|
|
msg *db.IRCMessage,
|
|
newNick string,
|
|
) {
|
|
c.loadSessionNick(ctx)
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
c.send(FormatMessage(prefix, "NICK", newNick))
|
|
}
|
|
|
|
// deliverQuitMsg sends a QUIT notification.
|
|
func (c *Conn) deliverQuitMsg(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
if text != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "QUIT", "Quit: "+text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(prefix, "QUIT", "Quit"))
|
|
}
|
|
}
|
|
|
|
// deliverTopicChange sends a TOPIC change notification.
|
|
func (c *Conn) deliverTopicChange(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
c.send(FormatMessage(prefix, "TOPIC", channel, text))
|
|
}
|
|
|
|
// deliverKickMsg sends a KICK notification.
|
|
func (c *Conn) deliverKickMsg(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
var params []string
|
|
|
|
if msg.Params != nil {
|
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
|
}
|
|
|
|
kickTarget := ""
|
|
if len(params) > 0 {
|
|
kickTarget = params[0]
|
|
}
|
|
|
|
if kickTarget != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "KICK", channel, kickTarget, text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(
|
|
prefix, "KICK", channel, "?", text,
|
|
))
|
|
}
|
|
}
|
|
|
|
// deliverInviteMsg sends an INVITE line from the inviter
|
|
// for the channel named in text, or nothing when text names
|
|
// no channel.
|
|
func (c *Conn) deliverInviteMsg(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
if text == "" {
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
c.send(FormatMessage(prefix, "INVITE", nick, text))
|
|
}
|
|
|
|
// deliverWallops sends a WALLOPS notification.
|
|
func (c *Conn) deliverWallops(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
c.send(FormatMessage(
|
|
prefix, irc.CmdWallops, text,
|
|
))
|
|
}
|
|
|
|
// deliverMode sends a MODE change notification. The first
|
|
// body line is the change, possibly followed by its values
|
|
// separated by spaces ("+o bob"); any further lines are
|
|
// values ("+k", "secret"). Each goes out as its own
|
|
// parameter, as IRC sends them.
|
|
func (c *Conn) deliverMode(
|
|
msg *db.IRCMessage,
|
|
bodyLines []string,
|
|
) {
|
|
if len(bodyLines) == 0 || bodyLines[0] == "" {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
params := slices.Concat(
|
|
[]string{msg.To},
|
|
strings.Fields(bodyLines[0]),
|
|
bodyLines[1:],
|
|
)
|
|
|
|
c.send(FormatMessage(prefix, "MODE", params...))
|
|
}
|