fix: OnStart ctx bug, rename session→user, full logout cleanup
All checks were successful
check / check (push) Successful in 1m57s

- Use context.Background() for cleanup goroutine instead of
  OnStart ctx which is cancelled after startup completes
- Rename GetSessionCount→GetUserCount, DeleteStaleSessions→
  DeleteStaleUsers to reflect that sessions represent users
- HandleLogout now fully cleans up when last client disconnects:
  parts all channels (notifying members via QUIT), removes
  empty channels, and deletes the session/user record
- docker build passes, all tests green, 0 lint issues
This commit is contained in:
user
2026-02-28 11:14:23 -08:00
parent bdc243224b
commit 910a5c2606
3 changed files with 94 additions and 16 deletions

View File

@@ -796,8 +796,8 @@ func (database *Database) DeleteClient(
return nil
}
// GetSessionCount returns the number of active sessions.
func (database *Database) GetSessionCount(
// GetUserCount returns the number of active users.
func (database *Database) GetUserCount(
ctx context.Context,
) (int64, error) {
var count int64
@@ -808,7 +808,7 @@ func (database *Database) GetSessionCount(
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get session count: %w", err,
"get user count: %w", err,
)
}
@@ -838,9 +838,9 @@ func (database *Database) ClientCountForSession(
return count, nil
}
// DeleteStaleSessions removes clients not seen since the
// cutoff and cleans up orphaned sessions.
func (database *Database) DeleteStaleSessions(
// DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleUsers(
ctx context.Context,
cutoff time.Time,
) (int64, error) {

View File

@@ -1361,20 +1361,23 @@ func (hdlr *Handlers) canAccessChannelHistory(
return true
}
// HandleLogout deletes the authenticated client's token.
// HandleLogout deletes the authenticated client's token
// and cleans up the user (session) if no clients remain.
func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
_, clientID, _, ok :=
sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
ctx := request.Context()
err := hdlr.params.Database.DeleteClient(
request.Context(), clientID,
ctx, clientID,
)
if err != nil {
hdlr.log.Error(
@@ -1389,12 +1392,79 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return
}
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
request, sessionID, nick,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
}
// cleanupUser parts the user from all channels (notifying
// members) and deletes the session.
func (hdlr *Handlers) cleanupUser(
request *http.Request,
sessionID int64,
nick string,
) {
ctx := request.Context()
channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
notified := map[int64]bool{}
var quitDBID int64
if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "", nil, nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(ctx, chanInfo.ID)
for _, mid := range memberIDs {
if mid != sessionID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
ctx, mid, quitDBID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
ctx, chanInfo.ID, sessionID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
ctx, chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(ctx, sessionID)
}
// HandleUsersMe returns the current user's session info.
func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc {
return hdlr.HandleState()
@@ -1406,12 +1476,12 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
users, err := hdlr.params.Database.GetSessionCount(
users, err := hdlr.params.Database.GetUserCount(
request.Context(),
)
if err != nil {
hdlr.log.Error(
"get session count failed", "error", err,
"get user count failed", "error", err,
)
hdlr.respondError(
writer, request,

View File

@@ -124,8 +124,16 @@ func (hdlr *Handlers) idleTimeout() time.Duration {
return dur
}
func (hdlr *Handlers) startCleanup(ctx context.Context) {
cleanupCtx, cancel := context.WithCancel(ctx)
// startCleanup launches the idle-user cleanup goroutine.
// We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
@@ -161,12 +169,12 @@ func (hdlr *Handlers) runCleanup(
) {
cutoff := time.Now().Add(-timeout)
deleted, err := hdlr.params.Database.DeleteStaleSessions(
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"session cleanup failed", "error", err,
"user cleanup failed", "error", err,
)
return
@@ -174,7 +182,7 @@ func (hdlr *Handlers) runCleanup(
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale clients",
"cleaned up stale users",
"deleted", deleted,
)
}