Two clients of one session leaving at once broadcast its QUIT twice #154

Closed
opened 2026-10-20 17:14:51 +02:00 by clawbot · 1 comment
Collaborator

HandleLogout in internal/handlers/api.go deletes the client, counts the session's remaining clients and, at zero, calls cleanupUser, which is BroadcastQuit in internal/service/service.go. The three steps are separate statements, so when two clients of the same session log out at the same time both deletes run before either count, both see zero, and BroadcastQuit runs twice: every channel peer gets two QUIT messages from the nick. Two concurrent HTTP QUIT commands do the same, since handleQuit calls BroadcastQuit straight away and the second call still finds the channels unless the first has parted them.

Seen on eceb5f2: a session with a password and two clients, both sending POST /api/v1/logout at once; the peer in the shared channel received two QUITs in nine of nine trials. README.md says the QUIT is broadcast to the users who share a channel; a client shows the user quitting twice.

Fix: make the teardown run once. BroadcastQuit claims the session before it does anything, with one statement whose result says whether it won (a DELETE FROM sessions WHERE id = ? reported through rows affected, after reading the channel list and the peers in the same transaction), and a caller that did not win sends nothing. HandleLogout's delete and count belong in that transaction too.

Definition of done

  1. Concurrent logouts or QUITs of one session produce one QUIT per peer; the session, its clients and its memberships are deleted either way.
  2. A test under -race with two concurrent logouts and one with two concurrent QUITs, each checking that the peer's queue holds one QUIT.
  3. One reviewed PR against next.

Model: fable-5-1

`HandleLogout` in `internal/handlers/api.go` deletes the client, counts the session's remaining clients and, at zero, calls `cleanupUser`, which is `BroadcastQuit` in `internal/service/service.go`. The three steps are separate statements, so when two clients of the same session log out at the same time both deletes run before either count, both see zero, and `BroadcastQuit` runs twice: every channel peer gets two QUIT messages from the nick. Two concurrent HTTP `QUIT` commands do the same, since `handleQuit` calls `BroadcastQuit` straight away and the second call still finds the channels unless the first has parted them. Seen on `eceb5f2`: a session with a password and two clients, both sending `POST /api/v1/logout` at once; the peer in the shared channel received two QUITs in nine of nine trials. `README.md` says the QUIT is broadcast to the users who share a channel; a client shows the user quitting twice. Fix: make the teardown run once. `BroadcastQuit` claims the session before it does anything, with one statement whose result says whether it won (a `DELETE FROM sessions WHERE id = ?` reported through rows affected, after reading the channel list and the peers in the same transaction), and a caller that did not win sends nothing. `HandleLogout`'s delete and count belong in that transaction too. ## Definition of done 1. Concurrent logouts or QUITs of one session produce one QUIT per peer; the session, its clients and its memberships are deleted either way. 2. A test under `-race` with two concurrent logouts and one with two concurrent QUITs, each checking that the peer's queue holds one QUIT. 3. One reviewed PR against `next`. Model: fable-5-1
clawbot self-assigned this 2026-10-20 17:14:51 +02:00
Author
Collaborator

Also in scope, found by the review at #164 (comment): BroadcastQuit parts a session's channels and deletes the emptied ones as two statements, so a JOIN racing the last member's QUIT can land between them, find the channel with no members, and join as its operator, skipping the ban, +i, +k and +l checks of a channel that keeps them. The fix here parts and deletes each channel in one transaction, as #164 does for PART and KICK (PartChannelAndDeleteIfEmpty), inside the transaction that claims the session. Definition of done gains: a test where a JOIN races the last member's QUIT on a +k channel and never joins without the key.

Model: opus-5-5

Also in scope, found by the review at https://git.eeqj.de/sneak/neoirc/pulls/164#issuecomment-152068: `BroadcastQuit` parts a session's channels and deletes the emptied ones as two statements, so a JOIN racing the last member's QUIT can land between them, find the channel with no members, and join as its operator, skipping the ban, `+i`, `+k` and `+l` checks of a channel that keeps them. The fix here parts and deletes each channel in one transaction, as https://git.eeqj.de/sneak/neoirc/pulls/164 does for PART and KICK (`PartChannelAndDeleteIfEmpty`), inside the transaction that claims the session. Definition of done gains: a test where a JOIN races the last member's QUIT on a `+k` channel and never joins without the key. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/neoirc#154