From 95e4a8fd8f9d01dba1efd89c9ba9d684c4f9acd3 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 07:00:55 +0000 Subject: [PATCH] Keep active IRC listener users from expiring (closes #121) Every command a registered IRC listener client sends, PING and PONG included, now sets last_seen on its session and its client, so the idle cleanup no longer removes an IRC user who is still active; one who sends nothing for SESSION_IDLE_TIMEOUT is still removed. The two UPDATEs that GetSessionByToken ran for every authenticated HTTP API request move into UpdateLastSeen, which both transports now call. The IRC listener refreshes on every command, with no interval between writes: that is what every HTTP API request already costs. README.md describes last_seen to match. Model: opus-5-5 --- README.md | 4 ++-- internal/db/queries.go | 27 ++++++++++++++++++++++--- internal/ircserver/conn.go | 11 +++++++++++ internal/ircserver/server_test.go | 33 +++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6080c68..f6c166a 100644 --- a/README.md +++ b/README.md @@ -2243,7 +2243,7 @@ The database schema is managed via embedded SQL migration files in | `signing_key` | TEXT | Public signing key (empty string if unset) | | `away_message` | TEXT | Away message (empty string if not away) | | `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last API request or IRC listener command time | Index on `(uuid)`. @@ -2258,7 +2258,7 @@ Index on `(uuid)`. | `ip` | TEXT | Real IP address of this client connection | | `hostname` | TEXT | Reverse DNS hostname of this client connection | | `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last API request or IRC listener command time | Indexes on `(token)` and `(session_id)`. diff --git a/internal/db/queries.go b/internal/db/queries.go index 7b437df..29239ea 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -204,21 +204,42 @@ func (database *Database) GetSessionByToken( ) } + _ = database.UpdateLastSeen(ctx, sessionID, clientID) + + return sessionID, clientID, nick, nil +} + +// UpdateLastSeen sets last_seen to now on a session and on +// one of its clients, so that the idle cleanup keeps them. +func (database *Database) UpdateLastSeen( + ctx context.Context, + sessionID, clientID int64, +) error { now := time.Now() - _, _ = database.conn.ExecContext( + _, err := database.conn.ExecContext( ctx, "UPDATE sessions SET last_seen = ? WHERE id = ?", now, sessionID, ) + if err != nil { + return fmt.Errorf( + "update session last_seen: %w", err, + ) + } - _, _ = database.conn.ExecContext( + _, err = database.conn.ExecContext( ctx, "UPDATE clients SET last_seen = ? WHERE id = ?", now, clientID, ) + if err != nil { + return fmt.Errorf( + "update client last_seen: %w", err, + ) + } - return sessionID, clientID, nick, nil + return nil } // GetSessionByNick returns session id for a given nick. diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 74e4dd1..f8024f0 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -368,6 +368,17 @@ func (c *Conn) handleMessage( return } + // Every command is activity, so the idle cleanup must not + // remove this user. + err := c.database.UpdateLastSeen( + ctx, c.sessionID, c.clientID, + ) + if err != nil { + c.log.Error( + "failed to update last_seen", "error", err, + ) + } + handler, ok := c.commands[msg.Command] if !ok { c.sendNumeric( diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 3717474..99e860b 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -369,6 +369,39 @@ func TestPingPong(t *testing.T) { assertContains(t, lines, "PONG", "PONG response") } +// TestIdleCleanupKeepsActiveUser checks that a user who +// sends commands is kept by the idle cleanup, and a user who +// sends nothing is not. +func TestIdleCleanupKeepsActiveUser(t *testing.T) { + t.Parallel() + + const idleTimeout = 500 * time.Millisecond + + env := newTestEnv(t) + + active := env.dial(t) + active.register("active") + + idle := env.dial(t) + idle.register("idle") + + time.Sleep(idleTimeout) + active.sendAndExpect("PING :still here", "PONG") + + // The idle cleanup in internal/handlers removes exactly + // the users this returns. + stale, err := env.database.GetStaleOrphanSessions( + t.Context(), time.Now().Add(-idleTimeout), + ) + if err != nil { + t.Fatalf("get stale sessions: %v", err) + } + + if len(stale) != 1 || stale[0].Nick != "idle" { + t.Errorf("cleanup removes %v, want only idle", stale) + } +} + func TestJoinChannel(t *testing.T) { t.Parallel() -- 2.54.0