nandi/frqpublic Fork 0
d4d1d6d
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Connect once, join once — an empty channel was the second of each

An authenticated connection showed a channel with two "You joined" lines and
nothing else. Both halves are the same fault seen twice: the client dialled
more than once, and freeq treats the second session as a reconnect of the
first. A reconnect is not replayed the channel history a fresh join gets, so
the connection the UI ended up holding was the one told nothing.

So connect! now refuses while a connection is in flight or up, and a channel
carries `joining?` beside `joined?` — the JOIN echo takes a round trip, and a
second JOIN sent inside it is enough to lose the replay.

parse-line also learned IRCv3 tags, which is what made this look like an
authentication bug: tags arrive on a connection that negotiates CAP and not on
a bare one, so a client that drops them works as a guest and goes quiet the
moment it signs in. That is not what happened here, but it is what would have
happened next.

Three Connect taps in a row now give one connection, one join, and the full
102-message backlog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T00:46:02-07:00 Browse files
d4d1d6d parent: 2d4a377
modified src/frq/irc.jolt +12 -3
@@ -27,10 +27,18 @@
2727 ;; ---------------------------------------------------------------- parsing
2828
2929 (defn parse-line
30- "An IRC line into {:prefix :command :params}. The trailing parameter (after
31- \" :\") keeps its spaces; everything before it splits on whitespace."
30+ "An IRC line into {:tags :prefix :command :params}. The trailing parameter
31+ (after \" :\") keeps its spaces; everything before it splits on whitespace.
32+
33+ IRCv3 tags come first when there are any. A connection that negotiates CAP
34+ gets them where a bare one does not — which is why a client that ignores them
35+ looks fine as a guest and goes silent once it authenticates."
3236 [line]
3337 (let [line (str/trimr line)
38+ [tags line] (if (str/starts-with? line "@")
39+ (let [i (str/index-of line " ")]
40+ [(subs line 1 i) (str/triml (subs line i))])
41+ [nil line])
3442 [prefix rest-line] (if (str/starts-with? line ":")
3543 (let [i (str/index-of line " ")]
3644 [(subs line 1 i) (subs line (inc i))])
@@ -39,7 +47,8 @@
3947 head (if i (subs rest-line 0 i) rest-line)
4048 trailing (when i (subs rest-line (+ i 2)))
4149 parts (remove str/blank? (str/split head #" "))]
42- {:prefix prefix
50+ {:tags tags
51+ :prefix prefix
4352 :command (str/upper-case (or (first parts) ""))
4453 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
4554
@@ -27,10 +27,18 @@
27 ;; ---------------------------------------------------------------- parsing27 ;; ---------------------------------------------------------------- parsing
28 28
29 (defn parse-line29 (defn parse-line
30- "An IRC line into {:prefix :command :params}. The trailing parameter (after30+ "An IRC line into {:tags :prefix :command :params}. The trailing parameter
31- \" :\") keeps its spaces; everything before it splits on whitespace."31+ (after \" :\") keeps its spaces; everything before it splits on whitespace.
32+
33+ IRCv3 tags come first when there are any. A connection that negotiates CAP
34+ gets them where a bare one does not — which is why a client that ignores them
35+ looks fine as a guest and goes silent once it authenticates."
32 [line]36 [line]
33 (let [line (str/trimr line)37 (let [line (str/trimr line)
38+ [tags line] (if (str/starts-with? line "@")
39+ (let [i (str/index-of line " ")]
40+ [(subs line 1 i) (str/triml (subs line i))])
41+ [nil line])
34 [prefix rest-line] (if (str/starts-with? line ":")42 [prefix rest-line] (if (str/starts-with? line ":")
35 (let [i (str/index-of line " ")]43 (let [i (str/index-of line " ")]
36 [(subs line 1 i) (subs line (inc i))])44 [(subs line 1 i) (subs line (inc i))])
@@ -39,7 +47,8 @@
39 head (if i (subs rest-line 0 i) rest-line)47 head (if i (subs rest-line 0 i) rest-line)
40 trailing (when i (subs rest-line (+ i 2)))48 trailing (when i (subs rest-line (+ i 2)))
41 parts (remove str/blank? (str/split head #" "))]49 parts (remove str/blank? (str/split head #" "))]
42- {:prefix prefix50+ {:tags tags
51+ :prefix prefix
43 :command (str/upper-case (or (first parts) ""))52 :command (str/upper-case (or (first parts) ""))
44 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))53 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
45 54
modified src/frq/state.jolt +36 -19
@@ -68,7 +68,7 @@
6868 (defn- ensure-channel [m name]
6969 (if (contains? m name)
7070 m
71- (assoc m name {:name name :messages [] :unread 0 :joined? false})))
71+ (assoc m name {:name name :messages [] :unread 0 :joined? false :joining? false})))
7272
7373 (defn push-message!
7474 "Append a line to a buffer, creating it if needed, and bump the unread count
@@ -90,10 +90,15 @@
9090 (reset! current name)
9191 (reset! screen :chat)
9292 (swap! channels #(assoc-in (ensure-channel % name) [name :unread] 0))
93- (when (and @conn
94- (str/starts-with? name "#")
95- (not (get-in @channels [name :joined?])))
96- (irc/join! @conn name)))
93+ ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
94+ ;; second JOIN sent in the meantime is what makes the server replay nothing.
95+ (let [buffer (get @channels name)]
96+ (when (and @conn
97+ (str/starts-with? name "#")
98+ (not (:joined? buffer))
99+ (not (:joining? buffer)))
100+ (swap! channels #(assoc-in % [name :joining?] true))
101+ (irc/join! @conn name))))
97102
98103 (declare join!)
99104
@@ -117,12 +122,14 @@
117122 "JOIN" (let [ch (first params)]
118123 (if (= from @form-nick)
119124 (do (swap! channels #(-> (ensure-channel % ch)
120- (assoc-in [ch :joined?] true)))
125+ (assoc-in [ch :joined?] true)
126+ (assoc-in [ch :joining?] false)))
121127 (push-message! ch "*" (str "You joined " ch)))
122128 (push-message! ch "*" (str from " joined"))))
123129 "PART" (let [ch (first params)]
124130 (if (= from @form-nick)
125- (swap! channels #(assoc-in % [ch :joined?] false))
131+ (swap! channels #(-> % (assoc-in [ch :joined?] false)
132+ (assoc-in [ch :joining?] false)))
126133 (push-message! ch "*" (str from " left"))))
127134 "QUIT" nil
128135 ("NOTICE" "372" "375" "376" "002" "003" "004")
@@ -140,7 +147,8 @@
140147 "*DISCONNECTED*" (do (reset! conn nil)
141148 (reset! connecting? false)
142149 (swap! channels
143- #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false)))
150+ #(reduce-kv (fn [m k v]
151+ (assoc m k (assoc v :joined? false :joining? false)))
144152 {} %))
145153 (reset! status "Disconnected"))
146154 "*ERROR*" (do (reset! error (first params))
@@ -238,17 +246,24 @@
238246 (defn connect!
239247 "Start connecting. The work happens on another thread: the OAuth wait sits on
240248 a loopback accept until the browser comes back, and the UI has frames to
241- paint in the meantime."
249+ paint in the meantime.
250+
251+ A second call while one is in flight is ignored. Dialling twice does not just
252+ waste a socket: the server treats the second session as a reconnect of the
253+ first, and a reconnect is not replayed the channel history a fresh join gets,
254+ so the second connection — the one the UI ends up holding — shows an empty
255+ channel."
242256 []
243- (reset! error nil)
244- (reset! connecting? true)
245- (future
246- (try (connect-blocking!)
247- (catch Exception e
248- (reset! connecting? false)
249- (reset! conn nil)
250- (reset! status "Not connected")
251- (reset! error (str "Could not connect: " (describe e)))))))
257+ (when-not (or @connecting? @conn)
258+ (reset! error nil)
259+ (reset! connecting? true)
260+ (future
261+ (try (connect-blocking!)
262+ (catch Exception e
263+ (reset! connecting? false)
264+ (reset! conn nil)
265+ (reset! status "Not connected")
266+ (reset! error (str "Could not connect: " (describe e))))))))
252267
253268 (defn disconnect! []
254269 (when-let [c @conn] (irc/close! c))
@@ -256,7 +271,9 @@
256271 (reset! session nil)
257272 ;; The buffers survive, the memberships do not — leaving `joined?` set would
258273 ;; have the next Open show a channel nobody is in.
259- (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %))
274+ (swap! channels #(reduce-kv (fn [m k v]
275+ (assoc m k (assoc v :joined? false :joining? false)))
276+ {} %))
260277 (reset! status "Not connected")
261278 (reset! screen :connect))
262279
@@ -68,7 +68,7 @@
68 (defn- ensure-channel [m name]68 (defn- ensure-channel [m name]
69 (if (contains? m name)69 (if (contains? m name)
70 m70 m
71- (assoc m name {:name name :messages [] :unread 0 :joined? false})))71+ (assoc m name {:name name :messages [] :unread 0 :joined? false :joining? false})))
72 72
73 (defn push-message!73 (defn push-message!
74 "Append a line to a buffer, creating it if needed, and bump the unread count74 "Append a line to a buffer, creating it if needed, and bump the unread count
@@ -90,10 +90,15 @@
90 (reset! current name)90 (reset! current name)
91 (reset! screen :chat)91 (reset! screen :chat)
92 (swap! channels #(assoc-in (ensure-channel % name) [name :unread] 0))92 (swap! channels #(assoc-in (ensure-channel % name) [name :unread] 0))
93- (when (and @conn93+ ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
94- (str/starts-with? name "#")94+ ;; second JOIN sent in the meantime is what makes the server replay nothing.
95- (not (get-in @channels [name :joined?])))95+ (let [buffer (get @channels name)]
96- (irc/join! @conn name)))96+ (when (and @conn
97+ (str/starts-with? name "#")
98+ (not (:joined? buffer))
99+ (not (:joining? buffer)))
100+ (swap! channels #(assoc-in % [name :joining?] true))
101+ (irc/join! @conn name))))
97 102
98 (declare join!)103 (declare join!)
99 104
@@ -117,12 +122,14 @@
117 "JOIN" (let [ch (first params)]122 "JOIN" (let [ch (first params)]
118 (if (= from @form-nick)123 (if (= from @form-nick)
119 (do (swap! channels #(-> (ensure-channel % ch)124 (do (swap! channels #(-> (ensure-channel % ch)
120- (assoc-in [ch :joined?] true)))125+ (assoc-in [ch :joined?] true)
126+ (assoc-in [ch :joining?] false)))
121 (push-message! ch "*" (str "You joined " ch)))127 (push-message! ch "*" (str "You joined " ch)))
122 (push-message! ch "*" (str from " joined"))))128 (push-message! ch "*" (str from " joined"))))
123 "PART" (let [ch (first params)]129 "PART" (let [ch (first params)]
124 (if (= from @form-nick)130 (if (= from @form-nick)
125- (swap! channels #(assoc-in % [ch :joined?] false))131+ (swap! channels #(-> % (assoc-in [ch :joined?] false)
132+ (assoc-in [ch :joining?] false)))
126 (push-message! ch "*" (str from " left"))))133 (push-message! ch "*" (str from " left"))))
127 "QUIT" nil134 "QUIT" nil
128 ("NOTICE" "372" "375" "376" "002" "003" "004")135 ("NOTICE" "372" "375" "376" "002" "003" "004")
@@ -140,7 +147,8 @@
140 "*DISCONNECTED*" (do (reset! conn nil)147 "*DISCONNECTED*" (do (reset! conn nil)
141 (reset! connecting? false)148 (reset! connecting? false)
142 (swap! channels149 (swap! channels
143- #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false)))150+ #(reduce-kv (fn [m k v]
151+ (assoc m k (assoc v :joined? false :joining? false)))
144 {} %))152 {} %))
145 (reset! status "Disconnected"))153 (reset! status "Disconnected"))
146 "*ERROR*" (do (reset! error (first params))154 "*ERROR*" (do (reset! error (first params))
@@ -238,17 +246,24 @@
238 (defn connect!246 (defn connect!
239 "Start connecting. The work happens on another thread: the OAuth wait sits on247 "Start connecting. The work happens on another thread: the OAuth wait sits on
240 a loopback accept until the browser comes back, and the UI has frames to248 a loopback accept until the browser comes back, and the UI has frames to
241- paint in the meantime."249+ paint in the meantime.
250+
251+ A second call while one is in flight is ignored. Dialling twice does not just
252+ waste a socket: the server treats the second session as a reconnect of the
253+ first, and a reconnect is not replayed the channel history a fresh join gets,
254+ so the second connection — the one the UI ends up holding — shows an empty
255+ channel."
242 []256 []
243- (reset! error nil)257+ (when-not (or @connecting? @conn)
244- (reset! connecting? true)258+ (reset! error nil)
245- (future259+ (reset! connecting? true)
246- (try (connect-blocking!)260+ (future
247- (catch Exception e261+ (try (connect-blocking!)
248- (reset! connecting? false)262+ (catch Exception e
249- (reset! conn nil)263+ (reset! connecting? false)
250- (reset! status "Not connected")264+ (reset! conn nil)
251- (reset! error (str "Could not connect: " (describe e)))))))265+ (reset! status "Not connected")
266+ (reset! error (str "Could not connect: " (describe e))))))))
252 267
253 (defn disconnect! []268 (defn disconnect! []
254 (when-let [c @conn] (irc/close! c))269 (when-let [c @conn] (irc/close! c))
@@ -256,7 +271,9 @@
256 (reset! session nil)271 (reset! session nil)
257 ;; The buffers survive, the memberships do not — leaving `joined?` set would272 ;; The buffers survive, the memberships do not — leaving `joined?` set would
258 ;; have the next Open show a channel nobody is in.273 ;; have the next Open show a channel nobody is in.
259- (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %))274+ (swap! channels #(reduce-kv (fn [m k v]
275+ (assoc m k (assoc v :joined? false :joining? false)))
276+ {} %))
260 (reset! status "Not connected")277 (reset! status "Not connected")
261 (reset! screen :connect))278 (reset! screen :connect))
262 279