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>
d4d1d6d parent: 2d4a377 modified
src/frq/irc.jolt +12 -3 | @@ -27,10 +27,18 @@ | ||
| 27 | 27 | ;; ---------------------------------------------------------------- parsing |
| 28 | 28 | |
| 29 | 29 | (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." | |
| 32 | 36 | [line] |
| 33 | 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 | 42 | [prefix rest-line] (if (str/starts-with? line ":") |
| 35 | 43 | (let [i (str/index-of line " ")] |
| 36 | 44 | [(subs line 1 i) (subs line (inc i))]) |
| @@ -39,7 +47,8 @@ | ||
| 39 | 47 | head (if i (subs rest-line 0 i) rest-line) |
| 40 | 48 | trailing (when i (subs rest-line (+ i 2))) |
| 41 | 49 | parts (remove str/blank? (str/split head #" "))] |
| 42 | - {:prefix prefix | |
| 50 | + {:tags tags | |
| 51 | + :prefix prefix | |
| 43 | 52 | :command (str/upper-case (or (first parts) "")) |
| 44 | 53 | :params (cond-> (vec (rest parts)) trailing (conj trailing))})) |
| 45 | 54 | |
| @@ -27,10 +27,18 @@ | |||
| 27 | ;; ---------------------------------------------------------------- parsing | 27 | ;; ---------------------------------------------------------------- parsing |
| 28 | 28 | ||
| 29 | (defn parse-line | 29 | (defn parse-line |
| 30 | - "An IRC line into {:prefix :command :params}. The trailing parameter (after | 30 | + "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 prefix | 50 | + {: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 @@ | ||
| 68 | 68 | (defn- ensure-channel [m name] |
| 69 | 69 | (if (contains? m name) |
| 70 | 70 | 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 | 73 | (defn push-message! |
| 74 | 74 | "Append a line to a buffer, creating it if needed, and bump the unread count |
| @@ -90,10 +90,15 @@ | ||
| 90 | 90 | (reset! current name) |
| 91 | 91 | (reset! screen :chat) |
| 92 | 92 | (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)))) | |
| 97 | 102 | |
| 98 | 103 | (declare join!) |
| 99 | 104 | |
| @@ -117,12 +122,14 @@ | ||
| 117 | 122 | "JOIN" (let [ch (first params)] |
| 118 | 123 | (if (= from @form-nick) |
| 119 | 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 | 127 | (push-message! ch "*" (str "You joined " ch))) |
| 122 | 128 | (push-message! ch "*" (str from " joined")))) |
| 123 | 129 | "PART" (let [ch (first params)] |
| 124 | 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 | 133 | (push-message! ch "*" (str from " left")))) |
| 127 | 134 | "QUIT" nil |
| 128 | 135 | ("NOTICE" "372" "375" "376" "002" "003" "004") |
| @@ -140,7 +147,8 @@ | ||
| 140 | 147 | "*DISCONNECTED*" (do (reset! conn nil) |
| 141 | 148 | (reset! connecting? false) |
| 142 | 149 | (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 | 153 | (reset! status "Disconnected")) |
| 146 | 154 | "*ERROR*" (do (reset! error (first params)) |
| @@ -238,17 +246,24 @@ | ||
| 238 | 246 | (defn connect! |
| 239 | 247 | "Start connecting. The work happens on another thread: the OAuth wait sits on |
| 240 | 248 | 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) | |
| 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)))))))) | |
| 252 | 267 | |
| 253 | 268 | (defn disconnect! [] |
| 254 | 269 | (when-let [c @conn] (irc/close! c)) |
| @@ -256,7 +271,9 @@ | ||
| 256 | 271 | (reset! session nil) |
| 257 | 272 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 258 | 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 | 277 | (reset! status "Not connected") |
| 261 | 278 | (reset! screen :connect)) |
| 262 | 279 | |
| @@ -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 | m | 70 | 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 count | 74 | "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 @conn | 93 | + ;; `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" nil | 134 | "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! channels | 149 | (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 on | 247 | "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 to | 248 | 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 | - (future | 259 | + (reset! connecting? true) |
| 246 | - (try (connect-blocking!) | 260 | + (future |
| 247 | - (catch Exception e | 261 | + (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 would | 272 | ;; 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 | ||