Send peers the IRC listener user's QUIT reason (closes #216)
check / check (push) Canceled after 0s

handleQuit now records the reason on the connection, and cleanup
passes it to BroadcastQuit, the same service call the HTTP API's
QUIT uses. A QUIT without a reason still gives Client quit. A
connection that ends without a QUIT still gives Connection closed.

The relay test that expected Connection closed for a bare QUIT now
expects Client quit.

Model: opus-5-5
This commit is contained in:
2026-11-02 03:20:44 +00:00
parent cc18397ee1
commit 039e2187cb
4 changed files with 126 additions and 12 deletions
+1
View File
@@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock()
c.closed = true
c.quitReason = reason
c.mu.Unlock()
}
+16 -11
View File
@@ -69,6 +69,11 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
// quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT
// replaces it.
quitReason string
}
func newConn(
@@ -87,14 +92,15 @@ func newConn(
}
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
conn: tcpConn,
log: log,
database: database,
brk: brk,
cfg: cfg,
svc: svc,
serverSfx: srvName,
remoteIP: host,
conn: tcpConn,
log: log,
database: database,
brk: brk,
cfg: cfg,
svc: svc,
serverSfx: srvName,
remoteIP: host,
quitReason: "Connection closed",
}
conn.commands = conn.buildCommandMap()
@@ -278,6 +284,7 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
quitReason := c.quitReason
c.closed = true
c.mu.Unlock()
@@ -287,9 +294,7 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the
// session.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
c.svc.BroadcastQuit(ctx, sessID, nick, quitReason)
}
}
+1 -1
View File
@@ -316,7 +316,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
{"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"},
} {
capitalAlice.send(step.line)
+108
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -23,6 +24,7 @@ import (
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite"
)
@@ -872,6 +874,112 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason")
}
// TestQuitReasonReachesPeers checks the reason that an IRC
// listener user's channel peers see in its QUIT, on both the
// IRC listener and the HTTP API. It is the reason the user
// gave, Client quit for a QUIT without one, or Connection
// closed when the connection ends without a QUIT.
func TestQuitReasonReachesPeers(t *testing.T) {
t.Parallel()
for _, step := range []struct {
name, quit, want string
}{
{"QUIT with a reason", "QUIT :gone home", "gone home"},
{"QUIT without a reason", irc.CmdQuit, "Client quit"},
{"connection closed without QUIT", "", "Connection closed"},
} {
t.Run(step.name, func(t *testing.T) {
t.Parallel()
ircLine, apiBody := quitSeenByPeers(t, step.quit)
want := ":quitter!quitter@* QUIT :Quit: " + step.want
if ircLine != want {
t.Errorf("IRC peer got %q, want %q", ircLine, want)
}
if len(apiBody) != 1 || apiBody[0] != step.want {
t.Errorf("HTTP API peer got %q, want [%q]", apiBody, step.want)
}
})
}
}
// quitSeenByPeers starts a server on which quitter, ircpeer on
// the IRC listener and apipeer on the HTTP API share a channel.
// quitter sends the line quit, or closes its connection when
// quit is empty. It returns the QUIT line ircpeer is sent and
// the body of the QUIT apipeer is sent.
func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
ircPeer := env.dial(t)
ircPeer.register("ircpeer")
ircPeer.joinAndDrain("#bye")
apiPeerID, apiPeerClientID, _, err := env.database.CreateSession(
ctx, "apipeer", "apipeer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#bye")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, apiPeerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
quitter := env.dial(t)
quitter.register("quitter")
quitter.joinAndDrain("#bye")
if quit == "" {
err = quitter.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
} else {
quitter.send(quit)
}
lines := ircPeer.readUntil(func(l string) bool {
return strings.Contains(l, " QUIT ")
})
// The QUIT is queued to both peers at once, so apipeer has
// it by now.
msgs, _, err := env.database.PollMessages(ctx, apiPeerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == irc.CmdQuit && msg.From == "quitter" {
var body []string
err = json.Unmarshal(msg.Body, &body)
if err != nil {
t.Fatalf("decode QUIT body %s: %v", msg.Body, err)
}
return lines[len(lines)-1], body
}
}
t.Fatalf("apipeer was not sent the QUIT: %v", msgs)
return "", nil
}
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and