diff options
| author | EuAndreh <eu@euandre.org> | 2026-05-01 07:44:58 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-05-01 07:44:58 -0300 |
| commit | 4ea09f1e389fe410aa67faab866e67bc781214fa (patch) | |
| tree | d7f51900de3f666019dfcda4076297a9411cdb34 /src/papod.clj | |
| parent | Bump welcome-banner counts after RPL_005 split (diff) | |
| download | papod-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.clj | 57 |
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 |
