From 039e2187cb6d38c2d48662a23a0b6c5bfb315107 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 2 Nov 2026 03:14:17 +0000 Subject: [PATCH] Send peers the IRC listener user's QUIT reason (closes #216) 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 --- internal/ircserver/commands.go | 1 + internal/ircserver/conn.go | 27 +++++--- internal/ircserver/relay_test.go | 2 +- internal/ircserver/server_test.go | 108 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 12 deletions(-) diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 43e00e5..2a8b75a 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) { c.mu.Lock() c.closed = true + c.quitReason = reason c.mu.Unlock() } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 83f6cbe..3969a54 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -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) } } diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index a40c281..1cf28e1 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -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) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 229b43c..4b059a8 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -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