Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd5caeaba9 |
No files matched your search
@@ -2528,9 +2528,9 @@ Unique constraint on `(channel_id, session_id)`.
|
|||||||
|
|
||||||
Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`.
|
Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`.
|
||||||
`to_session_id` and `to_channel_id` are null on a message sent to neither, such
|
`to_session_id` and `to_channel_id` are null on a message sent to neither, such
|
||||||
as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than
|
as `QUIT`. `GET /api/v1/history` selects by these two columns rather than by
|
||||||
by `msg_to`, because nicks and channel names are reused once released, and
|
`msg_to`, because nicks and channel names are reused once released, and session
|
||||||
session and channel ids are not.
|
and channel ids are not.
|
||||||
|
|
||||||
#### `spent_hashcash`
|
#### `spent_hashcash`
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,6 @@ func (b *Broker) Notify(userID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenerCount returns how many channels from Wait are still
|
|
||||||
// registered for the user: those that neither Notify nor Remove
|
|
||||||
// has taken out.
|
|
||||||
func (b *Broker) ListenerCount(userID int64) int {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
return len(b.listeners[userID])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a specific wait channel (for cleanup on timeout).
|
// Remove removes a specific wait channel (for cleanup on timeout).
|
||||||
func (b *Broker) Remove(
|
func (b *Broker) Remove(
|
||||||
userID int64,
|
userID int64,
|
||||||
|
|||||||
@@ -59,39 +59,6 @@ func TestRemove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenerCount(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
|
|
||||||
wantCount := func(step string, userID int64, want int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got := brk.ListenerCount(userID)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("%s: user %d: got %d, want %d",
|
|
||||||
step, userID, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wantCount("before Wait", 1, 0)
|
|
||||||
|
|
||||||
waitCh := brk.Wait(1)
|
|
||||||
brk.Wait(1)
|
|
||||||
brk.Wait(2)
|
|
||||||
|
|
||||||
wantCount("after two Waits", 1, 2)
|
|
||||||
|
|
||||||
brk.Remove(1, waitCh)
|
|
||||||
|
|
||||||
wantCount("after Remove", 1, 1)
|
|
||||||
|
|
||||||
brk.Notify(1)
|
|
||||||
|
|
||||||
wantCount("after Notify", 1, 0)
|
|
||||||
wantCount("after Notify", 2, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipleWaiters(t *testing.T) {
|
func TestMultipleWaiters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,3 @@ func IsUniqueConstraintError(err error) bool {
|
|||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsForeignKeyConstraintError reports whether err is a
|
|
||||||
// SQLite foreign-key constraint violation.
|
|
||||||
func IsForeignKeyConstraintError(err error) bool {
|
|
||||||
var sqliteErr *sqlite.Error
|
|
||||||
if !errors.As(err, &sqliteErr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
|
|
||||||
}
|
|
||||||
+13
-16
@@ -9,7 +9,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1069,10 +1068,11 @@ func (database *Database) PollMessages(
|
|||||||
|
|
||||||
// GetHistory returns the PRIVMSG history of one recipient:
|
// GetHistory returns the PRIVMSG history of one recipient:
|
||||||
// the DMs sent to session toSessionID, or the messages sent
|
// the DMs sent to session toSessionID, or the messages sent
|
||||||
// to channel toChannelID. Pass 0 for the other. A non-empty
|
// to channel toChannelID. Pass 0 for the other, which
|
||||||
// before is the id of a PRIVMSG in that history, and only
|
// matches no message. A non-empty before is the id of a
|
||||||
// messages older than it are returned; an id with no such
|
// message in that history, and only messages older than it
|
||||||
// message gives an error wrapping sql.ErrNoRows.
|
// are returned; an id with no such message gives an error
|
||||||
|
// wrapping sql.ErrNoRows.
|
||||||
func (database *Database) GetHistory(
|
func (database *Database) GetHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
toSessionID, toChannelID int64,
|
toSessionID, toChannelID int64,
|
||||||
@@ -1083,13 +1083,12 @@ func (database *Database) GetHistory(
|
|||||||
limit = defaultHistLimit
|
limit = defaultHistLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
// With no before, every message is older than this.
|
var beforeID int64
|
||||||
beforeID := int64(math.MaxInt64)
|
|
||||||
|
|
||||||
if before != "" {
|
if before != "" {
|
||||||
err := database.conn.QueryRowContext(ctx,
|
err := database.conn.QueryRowContext(ctx,
|
||||||
`SELECT id FROM messages
|
`SELECT id FROM messages
|
||||||
WHERE uuid = ? AND command = 'PRIVMSG'
|
WHERE uuid = ?
|
||||||
AND (to_session_id = ? OR to_channel_id = ?)`,
|
AND (to_session_id = ? OR to_channel_id = ?)`,
|
||||||
before, toSessionID, toChannelID,
|
before, toSessionID, toChannelID,
|
||||||
).Scan(&beforeID)
|
).Scan(&beforeID)
|
||||||
@@ -1121,23 +1120,21 @@ func (database *Database) GetHistory(
|
|||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryHistory selects by to_session_id when toSessionID is
|
|
||||||
// set and by to_channel_id otherwise, so that the column's
|
|
||||||
// (column, id) index serves the order and the limit.
|
|
||||||
func (database *Database) queryHistory(
|
func (database *Database) queryHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
toSessionID, toChannelID, beforeID int64,
|
toSessionID, toChannelID, beforeID int64,
|
||||||
limit int,
|
limit int,
|
||||||
) (*sql.Rows, error) {
|
) (*sql.Rows, error) {
|
||||||
if toSessionID != 0 {
|
if beforeID > 0 {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, params, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE to_session_id = ? AND id < ?
|
WHERE (to_session_id = ? OR to_channel_id = ?)
|
||||||
|
AND id < ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
ORDER BY id DESC LIMIT ?`,
|
ORDER BY id DESC LIMIT ?`,
|
||||||
toSessionID, beforeID, limit)
|
toSessionID, toChannelID, beforeID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"query history: %w", err,
|
"query history: %w", err,
|
||||||
@@ -1151,10 +1148,10 @@ func (database *Database) queryHistory(
|
|||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, params, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE to_channel_id = ? AND id < ?
|
WHERE (to_session_id = ? OR to_channel_id = ?)
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
ORDER BY id DESC LIMIT ?`,
|
ORDER BY id DESC LIMIT ?`,
|
||||||
toChannelID, beforeID, limit)
|
toSessionID, toChannelID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query history: %w", err)
|
return nil, fmt.Errorf("query history: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1654,25 +1654,9 @@ func TestHistoryUnknownBefore(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// History holds only PRIVMSG, so the id of the JOIN to
|
|
||||||
// #here is not in it.
|
|
||||||
var joinID string
|
|
||||||
|
|
||||||
queued, _ := tserver.pollMessages(token, 0)
|
|
||||||
for _, msg := range queued {
|
|
||||||
if msg[commandKey] == joinCmd && msg[toKey] == "#here" {
|
|
||||||
joinID, _ = msg["id"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if joinID == "" {
|
|
||||||
t.Fatal("expected a JOIN to #here in the queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct{ name, before string }{
|
cases := []struct{ name, before string }{
|
||||||
{"made-up id", "no-such-message"},
|
{"made-up id", "no-such-message"},
|
||||||
{"id from another channel", elsewhere[0].ID},
|
{"id from another channel", elsewhere[0].ID},
|
||||||
{"id of a JOIN", joinID},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -60,10 +60,6 @@ func (s *Server) Listener() net.Listener {
|
|||||||
// to a killed victim.
|
// to a killed victim.
|
||||||
const KillWriteWindow = killWriteWindow
|
const KillWriteWindow = killWriteWindow
|
||||||
|
|
||||||
// PollInterval is how often the relay checks the client
|
|
||||||
// output queue when no broker notification arrives.
|
|
||||||
const PollInterval = pollInterval
|
|
||||||
|
|
||||||
// NewTestConn returns a Conn for nick on tcpConn, without a
|
// NewTestConn returns a Conn for nick on tcpConn, without a
|
||||||
// server, database or service behind it.
|
// server, database or service behind it.
|
||||||
func NewTestConn(
|
func NewTestConn(
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ func (c *Conn) relayMessages(ctx context.Context) {
|
|||||||
case <-waitCh:
|
case <-waitCh:
|
||||||
// New message notification — loop back.
|
// New message notification — loop back.
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Periodic check. Nothing used waitCh, so take it
|
// Periodic check.
|
||||||
// out of the broker before the next pass adds
|
|
||||||
// another.
|
|
||||||
c.brk.Remove(c.sessionID, waitCh)
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
c.brk.Remove(c.sessionID, waitCh)
|
c.brk.Remove(c.sessionID, waitCh)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// queueMessage queues a message for the user with nick
|
// queueMessage queues a message for the user with nick
|
||||||
@@ -47,51 +44,6 @@ func (env *testEnv) queueMessage(
|
|||||||
env.brk.Notify(sessionID)
|
env.brk.Notify(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRelayKeepsOneWaitChannel checks that an idle IRC
|
|
||||||
// listener connection holds at most one broker wait channel
|
|
||||||
// across several relay ticks, and none once it closes.
|
|
||||||
func TestRelayKeepsOneWaitChannel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const idleTicks = 5
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
client := env.dial(t)
|
|
||||||
client.register("idle")
|
|
||||||
|
|
||||||
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get session of idle: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for tick := range idleTicks {
|
|
||||||
time.Sleep(ircserver.PollInterval)
|
|
||||||
|
|
||||||
got := env.brk.ListenerCount(sessionID)
|
|
||||||
if got > 1 {
|
|
||||||
t.Fatalf("tick %d: got %d wait channels, want at most 1",
|
|
||||||
tick+1, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.conn.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("close: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(testTimeout)
|
|
||||||
|
|
||||||
for env.brk.ListenerCount(sessionID) != 0 {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("after close: got %d wait channels, want 0",
|
|
||||||
env.brk.ListenerCount(sessionID))
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(relayCheckStep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRelayMultiLineBody checks that each line of a PRIVMSG
|
// TestRelayMultiLineBody checks that each line of a PRIVMSG
|
||||||
// or NOTICE body sent over the HTTP API reaches an IRC
|
// or NOTICE body sent over the HTTP API reaches an IRC
|
||||||
// listener user as its own line.
|
// listener user as its own line.
|
||||||
|
|||||||
+10
-20
@@ -400,13 +400,13 @@ func (s *Service) SendChannelMessage(
|
|||||||
nick, command, channel string,
|
nick, command, channel string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
noSuchChannel := &IRCError{
|
|
||||||
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", noSuchChannel
|
return 0, "", &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
msgNoSuchChannel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if command == irc.CmdPrivmsg {
|
if command == irc.CmdPrivmsg {
|
||||||
@@ -466,11 +466,6 @@ func (s *Service) SendChannelMessage(
|
|||||||
ctx, command, nick, channel, 0, chID,
|
ctx, command, nick, channel, 0, chID,
|
||||||
nil, body, meta, recipients,
|
nil, body, meta, recipients,
|
||||||
)
|
)
|
||||||
// The channel was deleted after the lookup above.
|
|
||||||
if db.IsForeignKeyConstraintError(fanErr) {
|
|
||||||
return 0, "", noSuchChannel
|
|
||||||
}
|
|
||||||
|
|
||||||
if fanErr != nil {
|
if fanErr != nil {
|
||||||
return 0, "", fanErr
|
return 0, "", fanErr
|
||||||
}
|
}
|
||||||
@@ -487,13 +482,13 @@ func (s *Service) SendDirectMessage(
|
|||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (*DirectMsgResult, error) {
|
) (*DirectMsgResult, error) {
|
||||||
noSuchNick := &IRCError{
|
|
||||||
irc.ErrNoSuchNick, []string{target}, "No such nick",
|
|
||||||
}
|
|
||||||
|
|
||||||
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, noSuchNick
|
return nil, &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{target},
|
||||||
|
"No such nick",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
away, _ := s.db.GetAway(ctx, targetSID)
|
away, _ := s.db.GetAway(ctx, targetSID)
|
||||||
@@ -507,11 +502,6 @@ func (s *Service) SendDirectMessage(
|
|||||||
ctx, command, nick, target, targetSID, 0,
|
ctx, command, nick, target, targetSID, 0,
|
||||||
nil, body, meta, recipients,
|
nil, body, meta, recipients,
|
||||||
)
|
)
|
||||||
// The target's session was deleted after the lookup above.
|
|
||||||
if db.IsForeignKeyConstraintError(fanErr) {
|
|
||||||
return nil, noSuchNick
|
|
||||||
}
|
|
||||||
|
|
||||||
if fanErr != nil {
|
if fanErr != nil {
|
||||||
return nil, fanErr
|
return nil, fanErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,78 +339,6 @@ func TestBroadcastQuit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSendDirectMessageTargetQuits checks that a DM whose
|
|
||||||
// target quits after its nick is looked up gets the same
|
|
||||||
// error as a DM to an unknown nick. The trigger deletes the
|
|
||||||
// target's session just before the DM is stored.
|
|
||||||
func TestSendDirectMessageTargetQuits(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
createSession(ctx, t, env.db, "bob")
|
|
||||||
|
|
||||||
_, err := env.db.GetDB().ExecContext(ctx,
|
|
||||||
`CREATE TRIGGER delete_recipient
|
|
||||||
BEFORE INSERT ON messages
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM sessions WHERE id = NEW.to_session_id;
|
|
||||||
END`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create trigger: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
|
||||||
|
|
||||||
_, err = env.svc.SendDirectMessage(
|
|
||||||
ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchNick {
|
|
||||||
t.Fatalf("want ERR_NOSUCHNICK, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSendChannelMessageChannelDeleted checks that a channel
|
|
||||||
// message whose channel is deleted after it is looked up gets
|
|
||||||
// the same error as a message to an unknown channel. The
|
|
||||||
// trigger deletes the channel just before the message is
|
|
||||||
// stored.
|
|
||||||
func TestSendChannelMessageChannelDeleted(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("join alice: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = env.db.GetDB().ExecContext(ctx,
|
|
||||||
`CREATE TRIGGER delete_recipient
|
|
||||||
BEFORE INSERT ON messages
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM channels WHERE id = NEW.to_channel_id;
|
|
||||||
END`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create trigger: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
|
||||||
|
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
|
||||||
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if !errors.As(err, &ircErr) ||
|
|
||||||
ircErr.Code != irc.ErrNoSuchChannel {
|
|
||||||
t.Fatalf("want ERR_NOSUCHCHANNEL, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendChannelMessage_Moderated(t *testing.T) {
|
func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
Reference in new issue
Block a user