3 Commits
Author SHA1 Message Date
sneak faf2c861c1 Stop the earlier poll loop on a second /connect (closes #228)
check / check (push) Canceled after 0s
A second /connect started a new poll loop without stopping the first.
The first loop then polled with the new client too, so each message
could be shown twice. It also read stopPoll without a.mu while
cmdConnect wrote it.

cmdConnect now closes the earlier loop's channel in the same locked
step that replaces the client and the cursor, and hands the new
channel to the new loop. The loop checks its channel under a.mu
before each poll and again before it moves the cursor, so a stopped
loop never polls with the new client or moves the new cursor.

Model: opus-5-5
2026-11-02 04:07:17 +00:00
clawbot e7ae675155 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
2026-11-02 05:02:33 +01:00
clawbot 6a7989e08d List the missing channel modes in the 004 reply (closes #238)
check / check (push) Canceled after 0s
The channel-modes parameter of RPL_MYINFO left out modes that each
transport's MODE accepts. The IRC listener now sends Himnostv, adding
H, i, o and v. The HTTP API now sends Hbiklmnostv, adding H, b and v.
Each transport keeps its own list, because they accept different
modes. The tests compare the whole parameter, so a missing or extra
mode fails them. The README and the schema notes show the new HTTP API
list.

Model: opus-5-5
2026-11-02 04:48:18 +01:00
9 changed files with 149 additions and 32 deletions

No files matched your search

+1 -1
View File
@@ -1236,7 +1236,7 @@ IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named
| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` | | `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` | | `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` | | `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","ikmnostl"]}` | | `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","Hbiklmnostv"]}` |
| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` | | `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` | | `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | | `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
+2 -2
View File
@@ -24,8 +24,8 @@ const (
) )
// eventLoopTimeout is how long a test waits for a typed line to // eventLoopTimeout is how long a test waits for a typed line to
// be handled, for the welcome lines, or for the UI to stop, before // be handled, for the welcome lines, for the UI to stop, for a poll
// it fails. // or for a poll loop to end, before it fails.
const eventLoopTimeout = 5 * time.Second const eventLoopTimeout = 5 * time.Second
// holdTime is how long a test waits to see that something does not // holdTime is how long a test waits to see that something does not
+1 -1
View File
@@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO // 004 RPL_MYINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick, ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, service.UserModes, "ikmnostl"}, []string{srvName, version, service.UserModes, "Hbiklmnostv"},
"", "",
) )
+7 -5
View File
@@ -6797,9 +6797,10 @@ func TestISUPPORT(t *testing.T) {
} }
} }
// TestMyInfoListsUserModes verifies the 004 numeric lists // TestMyInfoListsModes verifies the 004 numeric lists the
// the user modes o and w as its third parameter. // user modes o and w as its third parameter, and the HTTP
func TestMyInfoListsUserModes(t *testing.T) { // API's channel modes Hbiklmnostv as its fourth.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel() t.Parallel()
tserver := newTestServer(t) tserver := newTestServer(t)
@@ -6813,9 +6814,10 @@ func TestMyInfoListsUserModes(t *testing.T) {
} }
params := getNumericParams(myInfo) params := getNumericParams(myInfo)
if len(params) != 4 || params[2] != "ow" { if len(params) != 4 || params[2] != "ow" ||
params[3] != "Hbiklmnostv" {
t.Fatalf( t.Fatalf(
"004 params %v, want four with user modes ow third", "004 params %v, want four ending ow Hbiklmnostv",
params, params,
) )
} }
+1
View File
@@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock() c.mu.Lock()
c.closed = true c.closed = true
c.quitReason = reason
c.mu.Unlock() c.mu.Unlock()
} }
+17 -12
View File
@@ -69,6 +69,11 @@ type Conn struct {
lastQueueID int64 lastQueueID int64
closed bool closed bool
killed 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( func newConn(
@@ -87,14 +92,15 @@ func newConn(
} }
conn := &Conn{ //nolint:exhaustruct // zero-value defaults conn := &Conn{ //nolint:exhaustruct // zero-value defaults
conn: tcpConn, conn: tcpConn,
log: log, log: log,
database: database, database: database,
brk: brk, brk: brk,
cfg: cfg, cfg: cfg,
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed",
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -278,6 +284,7 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed wasKilled := c.killed
sessID := c.sessionID sessID := c.sessionID
nick := c.nick nick := c.nick
quitReason := c.quitReason
c.closed = true c.closed = true
c.mu.Unlock() c.mu.Unlock()
@@ -287,9 +294,7 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the // KILL has already sent the QUIT and deleted the
// session. // session.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit( c.svc.BroadcastQuit(ctx, sessID, nick, quitReason)
ctx, sessID, nick, "Connection closed",
)
} }
} }
@@ -605,7 +610,7 @@ func (c *Conn) deliverWelcome() {
) )
c.sendNumeric( c.sendNumeric(
irc.RplMyInfo, irc.RplMyInfo,
c.serverSfx, "neoirc", service.UserModes, "mnst", c.serverSfx, "neoirc", service.UserModes, "Himnostv",
) )
c.sendNumeric( c.sendNumeric(
irc.RplIsupport, irc.RplIsupport,
+1 -1
View File
@@ -316,7 +316,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"}, {"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"}, {"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT {"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) capitalAlice.send(step.line)
+118 -9
View File
@@ -4,12 +4,14 @@ import (
"bufio" "bufio"
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -23,6 +25,7 @@ import (
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -486,20 +489,20 @@ func TestWelcomeContainsNick(t *testing.T) {
} }
} }
// TestMyInfoListsUserModes checks that RPL_MYINFO lists the // TestMyInfoListsModes checks that RPL_MYINFO lists the user
// user modes o and w, with no empty parameter before the // modes o and w, then the channel modes MODE accepts on the
// channel modes. // IRC listener. It compares the whole line, so a missing or
func TestMyInfoListsUserModes(t *testing.T) { // extra mode fails it.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
lines := env.dial(t).register("carol") lines := env.dial(t).register("carol")
assertContains( want := ":test.irc 004 carol test.irc neoirc ow Himnostv"
t, lines, if !slices.Contains(lines, want) {
":test.irc 004 carol test.irc neoirc ow mnst", t.Errorf("no line %q in %v", want, lines)
"RPL_MYINFO with user modes", }
)
} }
func TestPingPong(t *testing.T) { func TestPingPong(t *testing.T) {
@@ -872,6 +875,112 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") 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 // TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as // a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and // closing the connection does: the session, its client and
+1 -1
View File
@@ -145,7 +145,7 @@ Where a schema file and the HTTP API disagree, the HTTP API is right:
- `003.json` describes the server's creation date. The HTTP API sends the date - `003.json` describes the server's creation date. The HTTP API sends the date
the server last started. the server last started.
- `004.json` has example mode lists. The HTTP API sends `ow` as the user modes - `004.json` has example mode lists. The HTTP API sends `ow` as the user modes
and `ikmnostl` as the channel modes. and `Hbiklmnostv` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out a - `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel, one with mode `+s`, unless the user is on it. secret channel, one with mode `+s`, unless the user is on it.
- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such - `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such