summaryrefslogtreecommitdiff
path: root/src/papod.clj
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-05-01 07:44:58 -0300
committerEuAndreh <eu@euandre.org>2026-05-01 07:44:58 -0300
commit4ea09f1e389fe410aa67faab866e67bc781214fa (patch)
treed7f51900de3f666019dfcda4076297a9411cdb34 /src/papod.clj
parentBump welcome-banner counts after RPL_005 split (diff)
downloadpapod-4ea09f1e389fe410aa67faab866e67bc781214fa.tar.gz
papod-4ea09f1e389fe410aa67faab866e67bc781214fa.tar.xz
Fix JOIN race: pin NAMES/broadcast to atomic membership snapshot
Two clients JOINing the same channel concurrently could each see the other in their own NAMES reply, and both could end up auto-opped on a freshly-created channel. The cause was that join-one! did: (swap! channels update handle (fnil conj #{}) nick) ... (get @channels handle) ;; for NAMES + broadcast + auto-op Between the swap! and the @channels read, a peer's swap! could land, so the read returned a set that already included them. Switch the swap to swap-vals!, capture members-before / members-after as a snapshot of state at the moment of our atomic update, and feed that snapshot to the broadcast loop, the metadata-2 push, the away- notify push, the auto-op decision, and the NAMES reply (via a new :members keyword on names-for). Auto-op now keys off (empty? members-before) instead of comparing the live atom to #{nick}. Surfaced and verified by the differential test in chat.papo.im, which previously had to insert quiesce checkpoints between concurrent JOINs to dodge the race; those checkpoints are no longer needed.
Diffstat (limited to 'src/papod.clj')
-rw-r--r--src/papod.clj57
1 files changed, 34 insertions, 23 deletions
diff --git a/src/papod.clj b/src/papod.clj
index edbedf5..d4272f1 100644
--- a/src/papod.clj
+++ b/src/papod.clj
@@ -2629,9 +2629,10 @@
(contains? (get @ops handle) nick)))
(defn- names-for
- [components handle & {:keys [multi-prefix? userhost?]}]
- (let [members (when (:channels components)
- (get @(:channels components) handle))
+ [components handle & {:keys [multi-prefix? userhost? members]}]
+ (let [members (or members
+ (when (:channels components)
+ (get @(:channels components) handle)))
ops (:ops components)
voiced (:voiced components)]
(when (seq members)
@@ -2922,15 +2923,23 @@
(when (and eid2 uuid2)
@(d/transact conn
(build-tx eid2 uuid2 eid2)))))))
- ;; UPDATE in-memory
- (when channels
- (swap! channels update handle (fnil conj #{}) nick))
- ;; Auto-op first member (channel creator or
- ;; first join to empty channel)
+ ;; UPDATE in-memory. swap-vals! gives us an atomic
+ ;; snapshot of the channel membership before and after
+ ;; this nick was added, so concurrent JOINs from peers
+ ;; can't slip into our NAMES reply (or our broadcast
+ ;; recipient set, or our auto-op decision).
+ (let [[old-chs new-chs]
+ (if channels
+ (swap-vals! channels update handle
+ (fnil conj #{}) nick)
+ [nil nil])
+ members-before (get old-chs handle #{})
+ members-after (get new-chs handle #{})]
+ ;; Auto-op the first joiner only — empty members-before
+ ;; is the unambiguous signal, regardless of how many
+ ;; peers raced behind us.
(when (and (:ops components)
- (or (nil? channel-eid)
- (= #{nick}
- (get @channels handle))))
+ (empty? members-before))
(swap! (:ops components) update handle
(fnil conj #{}) nick))
;; DELIVER to other channel members (not self)
@@ -2946,8 +2955,10 @@
"extended-join")
echo (if my-ej? ext-line line)]
(when (and clients channels)
- (doseq [member-nick (get @channels handle)
- :when (not= member-nick nick)
+ ;; Broadcast goes to who-was-here-before, NOT to
+ ;; concurrent joiners (they get their own JOIN
+ ;; broadcast from their own thread's perspective).
+ (doseq [member-nick members-before
:let [member (get @clients
member-nick)
ca (:client-atom member)
@@ -2966,11 +2977,9 @@
(let [subs-atom (:metadata-subs components)
subs (when subs-atom
(get @subs-atom
- (string/lower-case nick) #{}))
- members (get @channels handle)]
+ (string/lower-case nick) #{}))]
(when (and (seq subs) clients)
- (doseq [target (cons handle members)
- :when (not= target nick)
+ (doseq [target (cons handle members-before)
:let [low (string/lower-case target)
kv (get @meta-atom low {})]
[k v] kv
@@ -2984,9 +2993,7 @@
(let [src (client-prefix client)
aline (str ":" src " AWAY :" a)]
(when (and clients channels)
- (doseq [member-nick
- (get @channels handle)
- :when (not= member-nick nick)
+ (doseq [member-nick members-before
:let [member (get @clients
member-nick)
ca (:client-atom member)
@@ -2999,14 +3006,18 @@
"away-notify"))]
(deliver-to-client!
(:w member) aline)))))
- ;; Build replies for the joining client
+ ;; Build replies for the joining client. Pin the
+ ;; member set to the snapshot taken at our swap-vals!
+ ;; so concurrent JOINs from peers can't show up in
+ ;; this client's NAMES burst.
(let [mp? (contains? my-caps
"multi-prefix")
uh? (contains? my-caps
"userhost-in-names")
members (names-for components handle
:multi-prefix? mp?
- :userhost? uh?)
+ :userhost? uh?
+ :members members-after)
sym (chan-symbol components handle)
db' (when conn (d/db conn))
ch-eid' (when db'
@@ -3052,7 +3063,7 @@
[echo]
topic-lines
(when markread-line [markread-line])
- names-lines))))))))))))
+ names-lines)))))))))))))
(defn- handle-join