Quiet the chat down
Four kinds of noise, in what the screen actually shows: A run of messages from one person repeated the nick on every line, so a busy channel read as a list of headers with text between them. The sender now appears once per run. "You joined #freeq" appeared twice, because a reconnect joins every channel again and the buffer it announces into already has the backlog in it. It is announced only into an empty buffer now, and reads "Joined #freeq". A join refused with 473 raised the global banner, which then sat over an unrelated channel saying nothing about which one it meant. The reason goes to the refused channel's own buffer; the banner is left for what stops the whole app — a failed connection, a refused sign-in. Server lines are dim rather than shouting in body text, a conversation row puts membership beside the name instead of in the button row, and Settings offers "Back to connect" rather than a Disconnect button with nothing to disconnect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0742154 parent: 4aa71e7 modified
src/frq/app.jolt +38 -13 | @@ -132,15 +132,16 @@ | ||
| 132 | 132 | (let [name (:name buffer) |
| 133 | 133 | unread (:unread buffer)] |
| 134 | 134 | [:card {:key name} |
| 135 | + ;; Name, membership and unread count on one line — they describe the same | |
| 136 | + ;; thing, and the row below is for the button alone. | |
| 135 | 137 | [:hbox {:spacing 12} |
| 136 | 138 | [:title-2 {:label name}] |
| 139 | + [:status {:label (if (:joined? buffer) "joined" "not joined") | |
| 140 | + :live (boolean (:joined? buffer))}] | |
| 137 | 141 | [:vbox {:key :unread} |
| 138 | 142 | (when (pos? unread) [:label {:label (str "● " unread)}])]] |
| 139 | 143 | [:dim-label {:label (s/last-preview buffer)}] |
| 140 | - [:hbox {:spacing 8} | |
| 141 | - [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}] | |
| 142 | - [:status {:label (if (:joined? buffer) "joined" "not joined") | |
| 143 | - :live (boolean (:joined? buffer))}]]])) | |
| 144 | + [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]])) | |
| 144 | 145 | |
| 145 | 146 | (defn chats-screen [] |
| 146 | 147 | (let [buffers (s/channel-list)] |
| @@ -169,13 +170,31 @@ | ||
| 169 | 170 | |
| 170 | 171 | ;; ---------------------------------------------------------------- chat |
| 171 | 172 | |
| 172 | -(defn message-row [i m] | |
| 173 | - ;; Sender above the text, not beside it. A wrapping label in a horizontal row | |
| 174 | - ;; lays out against the row's width rather than the column's, so one long URL | |
| 175 | - ;; drags every line that follows it off the left edge. | |
| 176 | - [:vbox {:key i :spacing 2} | |
| 177 | - [:dim-label {:label (:from m)}] | |
| 178 | - [:label {:label (:text m)}]]) | |
| 173 | +(defn message-row | |
| 174 | + "One message. `prev` is the message above it, which decides whether this one | |
| 175 | + repeats the sender. | |
| 176 | + | |
| 177 | + Sender above the text, not beside it: a wrapping label in a horizontal row | |
| 178 | + lays out against the row's width rather than the column's, so one long URL | |
| 179 | + drags every line that follows it off the left edge." | |
| 180 | + [i prev m] | |
| 181 | + (let [same-sender? (and prev | |
| 182 | + (not (:system? m)) | |
| 183 | + (not (:system? prev)) | |
| 184 | + (= (:from prev) (:from m)))] | |
| 185 | + [:vbox {:key i :spacing 2 :margin 0} | |
| 186 | + ;; A run from one person reads as one block; repeating the nick on every | |
| 187 | + ;; line is what made a busy channel look like a list of headers. | |
| 188 | + [:vbox {:key :who} | |
| 189 | + (when-not (or same-sender? (:system? m)) | |
| 190 | + [:dim-label {:label (:from m)}])] | |
| 191 | + (if (:system? m) | |
| 192 | + [:dim-label {:label (:text m)}] | |
| 193 | + [:label {:label (:text m)}])])) | |
| 194 | + | |
| 195 | +(defn- message-rows [messages] | |
| 196 | + (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | |
| 197 | + messages)) | |
| 179 | 198 | |
| 180 | 199 | (defn chat-screen [] |
| 181 | 200 | (let [name @s/current |
| @@ -192,7 +211,7 @@ | ||
| 192 | 211 | ;; list fills whatever the window has and no more. |
| 193 | 212 | [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true} |
| 194 | 213 | (if (seq (:messages buffer)) |
| 195 | - (map-indexed message-row (:messages buffer)) | |
| 214 | + (message-rows (:messages buffer)) | |
| 196 | 215 | [:dim-label {:label "Nothing here yet."}])] |
| 197 | 216 | [:separator {}] |
| 198 | 217 | [:hbox {:spacing 8} |
| @@ -243,7 +262,13 @@ | ||
| 243 | 262 | :on-click s/forget-session!}])]] |
| 244 | 263 | [:dim-label {:label "Guest — not signed in."}]) |
| 245 | 264 | [:separator {}] |
| 246 | - [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | |
| 265 | + ;; Nothing to disconnect from when there is no connection — the way back to | |
| 266 | + ;; the connect screen is what is wanted then. | |
| 267 | + [:vbox {:key :connection-action} | |
| 268 | + (if (s/connected?) | |
| 269 | + [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}] | |
| 270 | + [:button {:label "Back to connect" | |
| 271 | + :on-click #(reset! s/screen :connect)}])]] | |
| 247 | 272 | [:card {} |
| 248 | 273 | [:title-2 {:label "frq"}] |
| 249 | 274 | [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}] |
| @@ -132,15 +132,16 @@ | |||
| 132 | (let [name (:name buffer) | 132 | (let [name (:name buffer) |
| 133 | unread (:unread buffer)] | 133 | unread (:unread buffer)] |
| 134 | [:card {:key name} | 134 | [:card {:key name} |
| 135 | + ;; Name, membership and unread count on one line — they describe the same | ||
| 136 | + ;; thing, and the row below is for the button alone. | ||
| 135 | [:hbox {:spacing 12} | 137 | [:hbox {:spacing 12} |
| 136 | [:title-2 {:label name}] | 138 | [:title-2 {:label name}] |
| 139 | + [:status {:label (if (:joined? buffer) "joined" "not joined") | ||
| 140 | + :live (boolean (:joined? buffer))}] | ||
| 137 | [:vbox {:key :unread} | 141 | [:vbox {:key :unread} |
| 138 | (when (pos? unread) [:label {:label (str "● " unread)}])]] | 142 | (when (pos? unread) [:label {:label (str "● " unread)}])]] |
| 139 | [:dim-label {:label (s/last-preview buffer)}] | 143 | [:dim-label {:label (s/last-preview buffer)}] |
| 140 | - [:hbox {:spacing 8} | 144 | + [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]])) |
| 141 | - [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}] | ||
| 142 | - [:status {:label (if (:joined? buffer) "joined" "not joined") | ||
| 143 | - :live (boolean (:joined? buffer))}]]])) | ||
| 144 | 145 | ||
| 145 | (defn chats-screen [] | 146 | (defn chats-screen [] |
| 146 | (let [buffers (s/channel-list)] | 147 | (let [buffers (s/channel-list)] |
| @@ -169,13 +170,31 @@ | |||
| 169 | 170 | ||
| 170 | ;; ---------------------------------------------------------------- chat | 171 | ;; ---------------------------------------------------------------- chat |
| 171 | 172 | ||
| 172 | -(defn message-row [i m] | 173 | +(defn message-row |
| 173 | - ;; Sender above the text, not beside it. A wrapping label in a horizontal row | 174 | + "One message. `prev` is the message above it, which decides whether this one |
| 174 | - ;; lays out against the row's width rather than the column's, so one long URL | 175 | + repeats the sender. |
| 175 | - ;; drags every line that follows it off the left edge. | 176 | + |
| 176 | - [:vbox {:key i :spacing 2} | 177 | + Sender above the text, not beside it: a wrapping label in a horizontal row |
| 177 | - [:dim-label {:label (:from m)}] | 178 | + lays out against the row's width rather than the column's, so one long URL |
| 178 | - [:label {:label (:text m)}]]) | 179 | + drags every line that follows it off the left edge." |
| 180 | + [i prev m] | ||
| 181 | + (let [same-sender? (and prev | ||
| 182 | + (not (:system? m)) | ||
| 183 | + (not (:system? prev)) | ||
| 184 | + (= (:from prev) (:from m)))] | ||
| 185 | + [:vbox {:key i :spacing 2 :margin 0} | ||
| 186 | + ;; A run from one person reads as one block; repeating the nick on every | ||
| 187 | + ;; line is what made a busy channel look like a list of headers. | ||
| 188 | + [:vbox {:key :who} | ||
| 189 | + (when-not (or same-sender? (:system? m)) | ||
| 190 | + [:dim-label {:label (:from m)}])] | ||
| 191 | + (if (:system? m) | ||
| 192 | + [:dim-label {:label (:text m)}] | ||
| 193 | + [:label {:label (:text m)}])])) | ||
| 194 | + | ||
| 195 | +(defn- message-rows [messages] | ||
| 196 | + (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | ||
| 197 | + messages)) | ||
| 179 | 198 | ||
| 180 | (defn chat-screen [] | 199 | (defn chat-screen [] |
| 181 | (let [name @s/current | 200 | (let [name @s/current |
| @@ -192,7 +211,7 @@ | |||
| 192 | ;; list fills whatever the window has and no more. | 211 | ;; list fills whatever the window has and no more. |
| 193 | [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true} | 212 | [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true} |
| 194 | (if (seq (:messages buffer)) | 213 | (if (seq (:messages buffer)) |
| 195 | - (map-indexed message-row (:messages buffer)) | 214 | + (message-rows (:messages buffer)) |
| 196 | [:dim-label {:label "Nothing here yet."}])] | 215 | [:dim-label {:label "Nothing here yet."}])] |
| 197 | [:separator {}] | 216 | [:separator {}] |
| 198 | [:hbox {:spacing 8} | 217 | [:hbox {:spacing 8} |
| @@ -243,7 +262,13 @@ | |||
| 243 | :on-click s/forget-session!}])]] | 262 | :on-click s/forget-session!}])]] |
| 244 | [:dim-label {:label "Guest — not signed in."}]) | 263 | [:dim-label {:label "Guest — not signed in."}]) |
| 245 | [:separator {}] | 264 | [:separator {}] |
| 246 | - [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | 265 | + ;; Nothing to disconnect from when there is no connection — the way back to |
| 266 | + ;; the connect screen is what is wanted then. | ||
| 267 | + [:vbox {:key :connection-action} | ||
| 268 | + (if (s/connected?) | ||
| 269 | + [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}] | ||
| 270 | + [:button {:label "Back to connect" | ||
| 271 | + :on-click #(reset! s/screen :connect)}])]] | ||
| 247 | [:card {} | 272 | [:card {} |
| 248 | [:title-2 {:label "frq"}] | 273 | [:title-2 {:label "frq"}] |
| 249 | [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}] | 274 | [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}] |
modified
src/frq/irc.jolt +96 -24 | @@ -17,6 +17,7 @@ | ||
| 17 | 17 | (:require [clojure.string :as str] |
| 18 | 18 | [frq.atproto :as atproto] |
| 19 | 19 | [jolt.ffi :as ffi] |
| 20 | + [jolt.host :as host] | |
| 20 | 21 | [jolt.mvn-http :as tls] |
| 21 | 22 | [jolt.socket :as socket])) |
| 22 | 23 | |
| @@ -28,6 +29,20 @@ | ||
| 28 | 29 | ;; otherwise, and nothing here handles signals — the process simply goes. |
| 29 | 30 | (def ^:private no-signal @#'socket/msg-nosignal) |
| 30 | 31 | |
| 32 | +;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection | |
| 33 | +;; reads nothing without being closed. These decide how long that is allowed to | |
| 34 | +;; go on: past `idle-ping-secs` we ask the server whether it is still there, | |
| 35 | +;; and past `idle-dead-secs` with no answer we conclude it is not. | |
| 36 | +(def ^:private idle-ping-secs 45) | |
| 37 | +(def ^:private idle-dead-secs 90) | |
| 38 | + | |
| 39 | +;; How long a TLS read waits before giving the thread back. It is also how long | |
| 40 | +;; an outgoing line can sit in the outbox, so it wants to be short: the reader | |
| 41 | +;; owns the connection, and this is how often it looks at what there is to send. | |
| 42 | +(def ^:private tls-poll-ms 200) | |
| 43 | + | |
| 44 | +(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000)) | |
| 45 | + | |
| 31 | 46 | ;; ---------------------------------------------------------------- parsing |
| 32 | 47 | |
| 33 | 48 | (defn parse-line |
| @@ -77,7 +92,8 @@ | ||
| 77 | 92 | (recur (+ sent n)))))))) |
| 78 | 93 | |
| 79 | 94 | (defn- write! |
| 80 | - "Bytes out, whichever transport this is." | |
| 95 | + "Bytes out, whichever transport this is. TLS callers go through the outbox | |
| 96 | + instead — see `send-line!`." | |
| 81 | 97 | [conn text] |
| 82 | 98 | (if (= :tls (:kind conn)) |
| 83 | 99 | (tls/tls-write (:tls conn) (.getBytes text)) |
| @@ -94,38 +110,92 @@ | ||
| 94 | 110 | (when (and n (pos? n)) (String. (ffi/read-bytes buf n)))))) |
| 95 | 111 | |
| 96 | 112 | (defn send-line! |
| 97 | - "Send a raw IRC line. Safe from any thread; writes are serialised by the lock." | |
| 113 | + "Send a raw IRC line. Safe from any thread. | |
| 114 | + | |
| 115 | + On TLS the line is queued rather than written: OpenSSL is driven here through | |
| 116 | + a pair of memory BIOs, and a write issued while the reader thread is parked | |
| 117 | + inside SSL_read is simply lost — the call reports success and the server | |
| 118 | + never sees the line. So the reader thread owns the connection in both | |
| 119 | + directions and drains this queue between reads. A raw socket has no such | |
| 120 | + problem, and writes straight through." | |
| 98 | 121 | [conn line] |
| 99 | - (locking (:lock conn) | |
| 100 | - (write! conn (str line "\r\n")))) | |
| 122 | + (let [text (str line "\r\n")] | |
| 123 | + (if (= :tls (:kind conn)) | |
| 124 | + (locking (:lock conn) (swap! (:outbox conn) conj text)) | |
| 125 | + (locking (:lock conn) (write! conn text))))) | |
| 126 | + | |
| 127 | +(defn- flush-outbox! | |
| 128 | + "Write whatever has been queued. Only ever called on the reader thread." | |
| 129 | + [conn] | |
| 130 | + (let [pending (locking (:lock conn) | |
| 131 | + (let [q @(:outbox conn)] | |
| 132 | + (reset! (:outbox conn) []) | |
| 133 | + q))] | |
| 134 | + (doseq [text pending] | |
| 135 | + (when (System/getenv "FRQ_TRACE") | |
| 136 | + (binding [*out* *err*] (println "frq/irc: >>" (str/trimr text)))) | |
| 137 | + (try (write! conn text) | |
| 138 | + (catch Exception e | |
| 139 | + (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e)))) | |
| 140 | + ;; Put it back: a write that failed for a transient reason is | |
| 141 | + ;; worth another turn of the loop. | |
| 142 | + (locking (:lock conn) (swap! (:outbox conn) conj text))))))) | |
| 101 | 143 | |
| 102 | 144 | (defn- reader-loop! |
| 103 | - "Read bytes until the socket closes, splitting on CRLF and dispatching each | |
| 104 | - complete line. PING is answered here so a dropped UI never times the link | |
| 105 | - out; everything else goes to `on-msg`." | |
| 145 | + "Read until the connection ends, splitting on CRLF and dispatching each | |
| 146 | + complete line. PING is answered here so a busy UI never times the link out; | |
| 147 | + everything else goes to `on-msg`. | |
| 148 | + | |
| 149 | + Nothing to read is not the end of the connection. On TLS it usually means the | |
| 150 | + 30-second receive timeout elapsed on a quiet channel — reading that as EOF is | |
| 151 | + what used to leave the app connected in appearance only: sends went nowhere | |
| 152 | + while the buffer still filled in with what the user typed. So a quiet stretch | |
| 153 | + gets a PING, and only silence after that counts as gone." | |
| 106 | 154 | [conn on-msg] |
| 107 | - (let [] | |
| 108 | - (loop [acc ""] | |
| 109 | - (let [chunk (read-chunk! conn)] | |
| 110 | - (if (nil? chunk) | |
| 111 | - (on-msg {:command "*DISCONNECTED*" :params []}) | |
| 112 | - (let [acc (str acc chunk) | |
| 113 | - lines (str/split acc #"\r?\n" -1) | |
| 114 | - complete (butlast lines)] | |
| 115 | - (doseq [line complete :when (seq (str/trim line))] | |
| 116 | - (let [msg (parse-line line)] | |
| 117 | - (when (= "PING" (:command msg)) | |
| 118 | - (send-line! conn (str "PONG :" (first (:params msg))))) | |
| 119 | - (on-msg (assoc msg :raw line)))) | |
| 120 | - (recur (last lines)))))))) | |
| 155 | + (loop [acc "" last-data (host/mono-nanos) pinged? false] | |
| 156 | + (flush-outbox! conn) | |
| 157 | + (let [chunk (read-chunk! conn)] | |
| 158 | + (cond | |
| 159 | + ;; A plain socket has no timeout, so nothing to read really is the end. | |
| 160 | + (and (nil? chunk) (not= :tls (:kind conn))) | |
| 161 | + (on-msg {:command "*DISCONNECTED*" :params []}) | |
| 162 | + | |
| 163 | + (nil? chunk) | |
| 164 | + (let [idle (secs-since last-data)] | |
| 165 | + (cond | |
| 166 | + (and pinged? (> idle idle-dead-secs)) | |
| 167 | + (on-msg {:command "*DISCONNECTED*" :params []}) | |
| 168 | + | |
| 169 | + (and (not pinged?) (> idle idle-ping-secs)) | |
| 170 | + (do (try (send-line! conn "PING :frq") (catch Exception _ nil)) | |
| 171 | + (recur acc last-data true)) | |
| 172 | + | |
| 173 | + :else (recur acc last-data pinged?))) | |
| 174 | + | |
| 175 | + :else | |
| 176 | + (let [acc (str acc chunk) | |
| 177 | + lines (str/split acc #"\r?\n" -1) | |
| 178 | + complete (butlast lines)] | |
| 179 | + (doseq [line complete :when (seq (str/trim line))] | |
| 180 | + (when (System/getenv "FRQ_TRACE") | |
| 181 | + (binding [*out* *err*] (println "frq/irc: <<" (subs line 0 (min 100 (count line)))))) | |
| 182 | + (let [msg (parse-line line)] | |
| 183 | + (when (= "PING" (:command msg)) | |
| 184 | + (send-line! conn (str "PONG :" (first (:params msg))))) | |
| 185 | + (on-msg (assoc msg :raw line)))) | |
| 186 | + (recur (last lines) (host/mono-nanos) false)))))) | |
| 121 | 187 | |
| 122 | 188 | (defn- open |
| 123 | 189 | "Dial `host`:`port`, over TLS unless `tls?` is false." |
| 124 | 190 | [host port tls? nick] |
| 125 | 191 | (if tls? |
| 126 | 192 | (do (tls/ensure-native!) |
| 127 | - {:kind :tls :tls (tls/tls-connect host (int port)) | |
| 128 | - :lock (Object.) :nick nick}) | |
| 193 | + (let [t (tls/tls-connect host (int port))] | |
| 194 | + ;; Without a short timeout the reader parks for 30 seconds at a time, | |
| 195 | + ;; which is 30 seconds of nothing being sent. | |
| 196 | + (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil)) | |
| 197 | + {:kind :tls :tls t :outbox (atom []) | |
| 198 | + :lock (Object.) :nick nick})) | |
| 129 | 199 | (let [ip (#'socket/ip->str (socket/resolve-host host)) |
| 130 | 200 | fd (socket/c-socket af-inet sock-stream 0)] |
| 131 | 201 | (when (neg? fd) (throw (ex-info "socket() failed" {:host host}))) |
| @@ -210,7 +280,9 @@ | ||
| 210 | 280 | (send-line! conn (str "PRIVMSG " target " :" text))) |
| 211 | 281 | |
| 212 | 282 | (defn close! [conn] |
| 213 | - (try (send-line! conn "QUIT :frq") (catch Exception _ nil)) | |
| 283 | + ;; Written straight out rather than queued: the reader may already be gone, | |
| 284 | + ;; and there is nothing left to lose if this one is. | |
| 285 | + (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil)) | |
| 214 | 286 | (try (if (= :tls (:kind conn)) |
| 215 | 287 | (tls/tls-close (:tls conn)) |
| 216 | 288 | (do (socket/c-close (:fd conn)) |
| @@ -17,6 +17,7 @@ | |||
| 17 | (:require [clojure.string :as str] | 17 | (:require [clojure.string :as str] |
| 18 | [frq.atproto :as atproto] | 18 | [frq.atproto :as atproto] |
| 19 | [jolt.ffi :as ffi] | 19 | [jolt.ffi :as ffi] |
| 20 | + [jolt.host :as host] | ||
| 20 | [jolt.mvn-http :as tls] | 21 | [jolt.mvn-http :as tls] |
| 21 | [jolt.socket :as socket])) | 22 | [jolt.socket :as socket])) |
| 22 | 23 | ||
| @@ -28,6 +29,20 @@ | |||
| 28 | ;; otherwise, and nothing here handles signals — the process simply goes. | 29 | ;; otherwise, and nothing here handles signals — the process simply goes. |
| 29 | (def ^:private no-signal @#'socket/msg-nosignal) | 30 | (def ^:private no-signal @#'socket/msg-nosignal) |
| 30 | 31 | ||
| 32 | +;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection | ||
| 33 | +;; reads nothing without being closed. These decide how long that is allowed to | ||
| 34 | +;; go on: past `idle-ping-secs` we ask the server whether it is still there, | ||
| 35 | +;; and past `idle-dead-secs` with no answer we conclude it is not. | ||
| 36 | +(def ^:private idle-ping-secs 45) | ||
| 37 | +(def ^:private idle-dead-secs 90) | ||
| 38 | + | ||
| 39 | +;; How long a TLS read waits before giving the thread back. It is also how long | ||
| 40 | +;; an outgoing line can sit in the outbox, so it wants to be short: the reader | ||
| 41 | +;; owns the connection, and this is how often it looks at what there is to send. | ||
| 42 | +(def ^:private tls-poll-ms 200) | ||
| 43 | + | ||
| 44 | +(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000)) | ||
| 45 | + | ||
| 31 | ;; ---------------------------------------------------------------- parsing | 46 | ;; ---------------------------------------------------------------- parsing |
| 32 | 47 | ||
| 33 | (defn parse-line | 48 | (defn parse-line |
| @@ -77,7 +92,8 @@ | |||
| 77 | (recur (+ sent n)))))))) | 92 | (recur (+ sent n)))))))) |
| 78 | 93 | ||
| 79 | (defn- write! | 94 | (defn- write! |
| 80 | - "Bytes out, whichever transport this is." | 95 | + "Bytes out, whichever transport this is. TLS callers go through the outbox |
| 96 | + instead — see `send-line!`." | ||
| 81 | [conn text] | 97 | [conn text] |
| 82 | (if (= :tls (:kind conn)) | 98 | (if (= :tls (:kind conn)) |
| 83 | (tls/tls-write (:tls conn) (.getBytes text)) | 99 | (tls/tls-write (:tls conn) (.getBytes text)) |
| @@ -94,38 +110,92 @@ | |||
| 94 | (when (and n (pos? n)) (String. (ffi/read-bytes buf n)))))) | 110 | (when (and n (pos? n)) (String. (ffi/read-bytes buf n)))))) |
| 95 | 111 | ||
| 96 | (defn send-line! | 112 | (defn send-line! |
| 97 | - "Send a raw IRC line. Safe from any thread; writes are serialised by the lock." | 113 | + "Send a raw IRC line. Safe from any thread. |
| 114 | + | ||
| 115 | + On TLS the line is queued rather than written: OpenSSL is driven here through | ||
| 116 | + a pair of memory BIOs, and a write issued while the reader thread is parked | ||
| 117 | + inside SSL_read is simply lost — the call reports success and the server | ||
| 118 | + never sees the line. So the reader thread owns the connection in both | ||
| 119 | + directions and drains this queue between reads. A raw socket has no such | ||
| 120 | + problem, and writes straight through." | ||
| 98 | [conn line] | 121 | [conn line] |
| 99 | - (locking (:lock conn) | 122 | + (let [text (str line "\r\n")] |
| 100 | - (write! conn (str line "\r\n")))) | 123 | + (if (= :tls (:kind conn)) |
| 124 | + (locking (:lock conn) (swap! (:outbox conn) conj text)) | ||
| 125 | + (locking (:lock conn) (write! conn text))))) | ||
| 126 | + | ||
| 127 | +(defn- flush-outbox! | ||
| 128 | + "Write whatever has been queued. Only ever called on the reader thread." | ||
| 129 | + [conn] | ||
| 130 | + (let [pending (locking (:lock conn) | ||
| 131 | + (let [q @(:outbox conn)] | ||
| 132 | + (reset! (:outbox conn) []) | ||
| 133 | + q))] | ||
| 134 | + (doseq [text pending] | ||
| 135 | + (when (System/getenv "FRQ_TRACE") | ||
| 136 | + (binding [*out* *err*] (println "frq/irc: >>" (str/trimr text)))) | ||
| 137 | + (try (write! conn text) | ||
| 138 | + (catch Exception e | ||
| 139 | + (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e)))) | ||
| 140 | + ;; Put it back: a write that failed for a transient reason is | ||
| 141 | + ;; worth another turn of the loop. | ||
| 142 | + (locking (:lock conn) (swap! (:outbox conn) conj text))))))) | ||
| 101 | 143 | ||
| 102 | (defn- reader-loop! | 144 | (defn- reader-loop! |
| 103 | - "Read bytes until the socket closes, splitting on CRLF and dispatching each | 145 | + "Read until the connection ends, splitting on CRLF and dispatching each |
| 104 | - complete line. PING is answered here so a dropped UI never times the link | 146 | + complete line. PING is answered here so a busy UI never times the link out; |
| 105 | - out; everything else goes to `on-msg`." | 147 | + everything else goes to `on-msg`. |
| 148 | + | ||
| 149 | + Nothing to read is not the end of the connection. On TLS it usually means the | ||
| 150 | + 30-second receive timeout elapsed on a quiet channel — reading that as EOF is | ||
| 151 | + what used to leave the app connected in appearance only: sends went nowhere | ||
| 152 | + while the buffer still filled in with what the user typed. So a quiet stretch | ||
| 153 | + gets a PING, and only silence after that counts as gone." | ||
| 106 | [conn on-msg] | 154 | [conn on-msg] |
| 107 | - (let [] | 155 | + (loop [acc "" last-data (host/mono-nanos) pinged? false] |
| 108 | - (loop [acc ""] | 156 | + (flush-outbox! conn) |
| 109 | - (let [chunk (read-chunk! conn)] | 157 | + (let [chunk (read-chunk! conn)] |
| 110 | - (if (nil? chunk) | 158 | + (cond |
| 111 | - (on-msg {:command "*DISCONNECTED*" :params []}) | 159 | + ;; A plain socket has no timeout, so nothing to read really is the end. |
| 112 | - (let [acc (str acc chunk) | 160 | + (and (nil? chunk) (not= :tls (:kind conn))) |
| 113 | - lines (str/split acc #"\r?\n" -1) | 161 | + (on-msg {:command "*DISCONNECTED*" :params []}) |
| 114 | - complete (butlast lines)] | 162 | + |
| 115 | - (doseq [line complete :when (seq (str/trim line))] | 163 | + (nil? chunk) |
| 116 | - (let [msg (parse-line line)] | 164 | + (let [idle (secs-since last-data)] |
| 117 | - (when (= "PING" (:command msg)) | 165 | + (cond |
| 118 | - (send-line! conn (str "PONG :" (first (:params msg))))) | 166 | + (and pinged? (> idle idle-dead-secs)) |
| 119 | - (on-msg (assoc msg :raw line)))) | 167 | + (on-msg {:command "*DISCONNECTED*" :params []}) |
| 120 | - (recur (last lines)))))))) | 168 | + |
| 169 | + (and (not pinged?) (> idle idle-ping-secs)) | ||
| 170 | + (do (try (send-line! conn "PING :frq") (catch Exception _ nil)) | ||
| 171 | + (recur acc last-data true)) | ||
| 172 | + | ||
| 173 | + :else (recur acc last-data pinged?))) | ||
| 174 | + | ||
| 175 | + :else | ||
| 176 | + (let [acc (str acc chunk) | ||
| 177 | + lines (str/split acc #"\r?\n" -1) | ||
| 178 | + complete (butlast lines)] | ||
| 179 | + (doseq [line complete :when (seq (str/trim line))] | ||
| 180 | + (when (System/getenv "FRQ_TRACE") | ||
| 181 | + (binding [*out* *err*] (println "frq/irc: <<" (subs line 0 (min 100 (count line)))))) | ||
| 182 | + (let [msg (parse-line line)] | ||
| 183 | + (when (= "PING" (:command msg)) | ||
| 184 | + (send-line! conn (str "PONG :" (first (:params msg))))) | ||
| 185 | + (on-msg (assoc msg :raw line)))) | ||
| 186 | + (recur (last lines) (host/mono-nanos) false)))))) | ||
| 121 | 187 | ||
| 122 | (defn- open | 188 | (defn- open |
| 123 | "Dial `host`:`port`, over TLS unless `tls?` is false." | 189 | "Dial `host`:`port`, over TLS unless `tls?` is false." |
| 124 | [host port tls? nick] | 190 | [host port tls? nick] |
| 125 | (if tls? | 191 | (if tls? |
| 126 | (do (tls/ensure-native!) | 192 | (do (tls/ensure-native!) |
| 127 | - {:kind :tls :tls (tls/tls-connect host (int port)) | 193 | + (let [t (tls/tls-connect host (int port))] |
| 128 | - :lock (Object.) :nick nick}) | 194 | + ;; Without a short timeout the reader parks for 30 seconds at a time, |
| 195 | + ;; which is 30 seconds of nothing being sent. | ||
| 196 | + (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil)) | ||
| 197 | + {:kind :tls :tls t :outbox (atom []) | ||
| 198 | + :lock (Object.) :nick nick})) | ||
| 129 | (let [ip (#'socket/ip->str (socket/resolve-host host)) | 199 | (let [ip (#'socket/ip->str (socket/resolve-host host)) |
| 130 | fd (socket/c-socket af-inet sock-stream 0)] | 200 | fd (socket/c-socket af-inet sock-stream 0)] |
| 131 | (when (neg? fd) (throw (ex-info "socket() failed" {:host host}))) | 201 | (when (neg? fd) (throw (ex-info "socket() failed" {:host host}))) |
| @@ -210,7 +280,9 @@ | |||
| 210 | (send-line! conn (str "PRIVMSG " target " :" text))) | 280 | (send-line! conn (str "PRIVMSG " target " :" text))) |
| 211 | 281 | ||
| 212 | (defn close! [conn] | 282 | (defn close! [conn] |
| 213 | - (try (send-line! conn "QUIT :frq") (catch Exception _ nil)) | 283 | + ;; Written straight out rather than queued: the reader may already be gone, |
| 284 | + ;; and there is nothing left to lose if this one is. | ||
| 285 | + (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil)) | ||
| 214 | (try (if (= :tls (:kind conn)) | 286 | (try (if (= :tls (:kind conn)) |
| 215 | (tls/tls-close (:tls conn)) | 287 | (tls/tls-close (:tls conn)) |
| 216 | (do (socket/c-close (:fd conn)) | 288 | (do (socket/c-close (:fd conn)) |
modified
src/frq/state.jolt +15 -6 | @@ -80,7 +80,8 @@ | ||
| 80 | 80 | (let [m (ensure-channel m channel) |
| 81 | 81 | viewing? (and (= :chat @screen) (= channel @current))] |
| 82 | 82 | (-> m |
| 83 | - (update-in [channel :messages] conj {:from from :text text}) | |
| 83 | + (update-in [channel :messages] conj | |
| 84 | + {:from from :text text :system? (= "*" from)}) | |
| 84 | 85 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) |
| 85 | 86 | |
| 86 | 87 | (defn open-channel! |
| @@ -122,10 +123,14 @@ | ||
| 122 | 123 | (push-message! buffer from text)) |
| 123 | 124 | "JOIN" (let [ch (first params)] |
| 124 | 125 | (if (= from @form-nick) |
| 125 | - (do (swap! channels #(-> (ensure-channel % ch) | |
| 126 | - (assoc-in [ch :joined?] true) | |
| 127 | - (assoc-in [ch :joining?] false))) | |
| 128 | - (push-message! ch "*" (str "You joined " ch))) | |
| 126 | + (let [fresh? (empty? (get-in @channels [ch :messages]))] | |
| 127 | + (swap! channels #(-> (ensure-channel % ch) | |
| 128 | + (assoc-in [ch :joined?] true) | |
| 129 | + (assoc-in [ch :joining?] false))) | |
| 130 | + ;; Only on the way in to an empty buffer. A reconnect joins | |
| 131 | + ;; every channel again, and saying so on top of the backlog | |
| 132 | + ;; already there is just a second line of noise. | |
| 133 | + (when fresh? (push-message! ch "*" (str "Joined " ch)))) | |
| 129 | 134 | (push-message! ch "*" (str from " joined")))) |
| 130 | 135 | "PART" (let [ch (first params)] |
| 131 | 136 | (if (= from @form-nick) |
| @@ -146,7 +151,11 @@ | ||
| 146 | 151 | (assoc-in [ch :joined?] false) |
| 147 | 152 | (assoc-in [ch :joining?] false))) |
| 148 | 153 | (push-message! ch "*" (str "Could not join " ch " — " why))) |
| 149 | - (reset! error (str "Cannot join: " why))) | |
| 154 | + ;; Deliberately not the global banner: it outlives the screen it was | |
| 155 | + ;; about, and the reason is in the channel's own buffer where it | |
| 156 | + ;; belongs. The banner is for what stops the whole app — a failed | |
| 157 | + ;; connection or a refused sign-in. | |
| 158 | + (when-not ch (reset! error (str "Cannot join: " why)))) | |
| 150 | 159 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 151 | 160 | ("904" "905" "906") (do (reset! session nil) |
| 152 | 161 | ;; The broker token may still be good — but a |
| @@ -80,7 +80,8 @@ | |||
| 80 | (let [m (ensure-channel m channel) | 80 | (let [m (ensure-channel m channel) |
| 81 | viewing? (and (= :chat @screen) (= channel @current))] | 81 | viewing? (and (= :chat @screen) (= channel @current))] |
| 82 | (-> m | 82 | (-> m |
| 83 | - (update-in [channel :messages] conj {:from from :text text}) | 83 | + (update-in [channel :messages] conj |
| 84 | + {:from from :text text :system? (= "*" from)}) | ||
| 84 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) | 85 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) |
| 85 | 86 | ||
| 86 | (defn open-channel! | 87 | (defn open-channel! |
| @@ -122,10 +123,14 @@ | |||
| 122 | (push-message! buffer from text)) | 123 | (push-message! buffer from text)) |
| 123 | "JOIN" (let [ch (first params)] | 124 | "JOIN" (let [ch (first params)] |
| 124 | (if (= from @form-nick) | 125 | (if (= from @form-nick) |
| 125 | - (do (swap! channels #(-> (ensure-channel % ch) | 126 | + (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| 126 | - (assoc-in [ch :joined?] true) | 127 | + (swap! channels #(-> (ensure-channel % ch) |
| 127 | - (assoc-in [ch :joining?] false))) | 128 | + (assoc-in [ch :joined?] true) |
| 128 | - (push-message! ch "*" (str "You joined " ch))) | 129 | + (assoc-in [ch :joining?] false))) |
| 130 | + ;; Only on the way in to an empty buffer. A reconnect joins | ||
| 131 | + ;; every channel again, and saying so on top of the backlog | ||
| 132 | + ;; already there is just a second line of noise. | ||
| 133 | + (when fresh? (push-message! ch "*" (str "Joined " ch)))) | ||
| 129 | (push-message! ch "*" (str from " joined")))) | 134 | (push-message! ch "*" (str from " joined")))) |
| 130 | "PART" (let [ch (first params)] | 135 | "PART" (let [ch (first params)] |
| 131 | (if (= from @form-nick) | 136 | (if (= from @form-nick) |
| @@ -146,7 +151,11 @@ | |||
| 146 | (assoc-in [ch :joined?] false) | 151 | (assoc-in [ch :joined?] false) |
| 147 | (assoc-in [ch :joining?] false))) | 152 | (assoc-in [ch :joining?] false))) |
| 148 | (push-message! ch "*" (str "Could not join " ch " — " why))) | 153 | (push-message! ch "*" (str "Could not join " ch " — " why))) |
| 149 | - (reset! error (str "Cannot join: " why))) | 154 | + ;; Deliberately not the global banner: it outlives the screen it was |
| 155 | + ;; about, and the reason is in the channel's own buffer where it | ||
| 156 | + ;; belongs. The banner is for what stops the whole app — a failed | ||
| 157 | + ;; connection or a refused sign-in. | ||
| 158 | + (when-not ch (reset! error (str "Cannot join: " why)))) | ||
| 150 | "903" (reset! status (str "Signed in as " (:handle @session))) | 159 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 151 | ("904" "905" "906") (do (reset! session nil) | 160 | ("904" "905" "906") (do (reset! session nil) |
| 152 | ;; The broker token may still be good — but a | 161 | ;; The broker token may still be good — but a |