nandi/frqpublic Fork 0
3d47bd9
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.

Keep the tree's shape fixed, and let a refused join be retried

A conditional child that renders nil when there is nothing to say shifts every
sibling after it, and the reconciler matches children by position — so the
first error to appear patched the chat header into a card. Losing the header
loses the way back to the channel list, which is what "the app wedged after
sign-in" was: not a hang, a screen with its navigation overwritten.

So error-note is always a node and only its contents vary, and every other
conditional sibling is wrapped the same way with a key of its own.

The refusal underneath it was real, though: a channel that answers JOIN with
473 left the buffer marked `joining?` forever, and nothing would ever send a
JOIN for it again. Those numerics now clear both flags, name the channel — the
error card never said which one, which is how this looked like it was about
#test — and leave the reason in that channel's own buffer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T00:52:17-07:00 Browse files
3d47bd9 parent: d4d1d6d
modified src/frq/app.jolt +28 -16
@@ -11,11 +11,19 @@
1111
1212 ;; ---------------------------------------------------------------- pieces
1313
14-(defn error-note []
15- (when-let [e @s/error]
16- [:card {}
17- [:label {:label (str "⚠ " e)}]
18- [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]]))
14+(defn error-note
15+ "Always a node, never nil.
16+
17+ A conditional child that disappears shifts every sibling after it, and the
18+ reconciler matches children by position — so an error appearing mid-screen
19+ would patch the header into a card. A stable wrapper with a stable key keeps
20+ the shape of the tree fixed and only its contents changing."
21+ []
22+ [:vbox {:key :error-note :spacing 6}
23+ (when-let [e @s/error]
24+ [:card {}
25+ [:label {:label (str "⚠ " e)}]
26+ [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])])
1927
2028 (defn tab-bar []
2129 [:hbox {:spacing 8}
@@ -79,12 +87,14 @@
7987 :width-request 320
8088 :placeholder "alice.bsky.social"
8189 :on-change #(reset! s/form-handle %)}]
82- (when @s/broker-token
83- [:dim-label {:label "Session remembered — Connect will not need the browser."}])
84- (when-let [url @s/login-url]
85- [:vbox {:spacing 4}
86- [:dim-label {:label "If the browser did not open, visit:"}]
87- [:label {:label url}]])]
90+ [:vbox {:key :remembered}
91+ (when @s/broker-token
92+ [:dim-label {:label "Session remembered — Connect will not need the browser."}])]
93+ [:vbox {:key :login-url :spacing 4}
94+ (when-let [url @s/login-url]
95+ [:vbox {:spacing 4}
96+ [:dim-label {:label "If the browser did not open, visit:"}]
97+ [:label {:label url}]])]]
8898
8999 :app-password
90100 [:vbox {:spacing 6}
@@ -122,7 +132,8 @@
122132 [:card {:key name}
123133 [:hbox {:spacing 12}
124134 [:title-2 {:label name}]
125- (when (pos? unread) [:label {:label (str "● " unread)}])]
135+ [:vbox {:key :unread}
136+ (when (pos? unread) [:label {:label (str "● " unread)}])]]
126137 [:dim-label {:label (s/last-preview buffer)}]
127138 [:hbox {:spacing 8}
128139 [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]
@@ -223,10 +234,11 @@
223234 [:vbox {:spacing 2}
224235 [:label {:label (str "Signed in as " (:handle sess))}]
225236 [:dim-label {:label (or (:did sess) "")}]
226- (when @s/broker-token
227- [:button {:label "Forget Bluesky session"
228- :kind :destructive
229- :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]
237+ [:vbox {:key :forget}
238+ (when @s/broker-token
239+ [:button {:label "Forget Bluesky session"
240+ :kind :destructive
241+ :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]]
230242 [:dim-label {:label "Guest — not signed in."}])
231243 [:separator {}]
232244 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
@@ -11,11 +11,19 @@
11 11
12 ;; ---------------------------------------------------------------- pieces12 ;; ---------------------------------------------------------------- pieces
13 13
14-(defn error-note []14+(defn error-note
15- (when-let [e @s/error]15+ "Always a node, never nil.
16- [:card {}16+
17- [:label {:label (str "⚠ " e)}]17+ A conditional child that disappears shifts every sibling after it, and the
18- [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]]))18+ reconciler matches children by position — so an error appearing mid-screen
19+ would patch the header into a card. A stable wrapper with a stable key keeps
20+ the shape of the tree fixed and only its contents changing."
21+ []
22+ [:vbox {:key :error-note :spacing 6}
23+ (when-let [e @s/error]
24+ [:card {}
25+ [:label {:label (str "⚠ " e)}]
26+ [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])])
19 27
20 (defn tab-bar []28 (defn tab-bar []
21 [:hbox {:spacing 8}29 [:hbox {:spacing 8}
@@ -79,12 +87,14 @@
79 :width-request 32087 :width-request 320
80 :placeholder "alice.bsky.social"88 :placeholder "alice.bsky.social"
81 :on-change #(reset! s/form-handle %)}]89 :on-change #(reset! s/form-handle %)}]
82- (when @s/broker-token90+ [:vbox {:key :remembered}
83- [:dim-label {:label "Session remembered — Connect will not need the browser."}])91+ (when @s/broker-token
84- (when-let [url @s/login-url]92+ [:dim-label {:label "Session remembered — Connect will not need the browser."}])]
85- [:vbox {:spacing 4}93+ [:vbox {:key :login-url :spacing 4}
86- [:dim-label {:label "If the browser did not open, visit:"}]94+ (when-let [url @s/login-url]
87- [:label {:label url}]])]95+ [:vbox {:spacing 4}
96+ [:dim-label {:label "If the browser did not open, visit:"}]
97+ [:label {:label url}]])]]
88 98
89 :app-password99 :app-password
90 [:vbox {:spacing 6}100 [:vbox {:spacing 6}
@@ -122,7 +132,8 @@
122 [:card {:key name}132 [:card {:key name}
123 [:hbox {:spacing 12}133 [:hbox {:spacing 12}
124 [:title-2 {:label name}]134 [:title-2 {:label name}]
125- (when (pos? unread) [:label {:label (str "● " unread)}])]135+ [:vbox {:key :unread}
136+ (when (pos? unread) [:label {:label (str "● " unread)}])]]
126 [:dim-label {:label (s/last-preview buffer)}]137 [:dim-label {:label (s/last-preview buffer)}]
127 [:hbox {:spacing 8}138 [:hbox {:spacing 8}
128 [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]139 [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]
@@ -223,10 +234,11 @@
223 [:vbox {:spacing 2}234 [:vbox {:spacing 2}
224 [:label {:label (str "Signed in as " (:handle sess))}]235 [:label {:label (str "Signed in as " (:handle sess))}]
225 [:dim-label {:label (or (:did sess) "")}]236 [:dim-label {:label (or (:did sess) "")}]
226- (when @s/broker-token237+ [:vbox {:key :forget}
227- [:button {:label "Forget Bluesky session"238+ (when @s/broker-token
228- :kind :destructive239+ [:button {:label "Forget Bluesky session"
229- :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]240+ :kind :destructive
241+ :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]]
230 [:dim-label {:label "Guest — not signed in."}])242 [:dim-label {:label "Guest — not signed in."}])
231 [:separator {}]243 [:separator {}]
232 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]244 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
modified src/frq/irc.jolt +5 -1
@@ -24,6 +24,10 @@
2424 (def ^:private sock-stream 1)
2525 (def ^:private buffer-size 8192)
2626
27+;; MSG_NOSIGNAL. Writing to a socket the far end has closed raises SIGPIPE
28+;; otherwise, and nothing here handles signals — the process simply goes.
29+(def ^:private no-signal @#'socket/msg-nosignal)
30+
2731 ;; ---------------------------------------------------------------- parsing
2832
2933 (defn parse-line
@@ -68,7 +72,7 @@
6872 (ffi/with-c-string [p text]
6973 (loop [sent 0]
7074 (when (< sent len)
71- (let [n (socket/c-send fd p (- len sent) 0)]
75+ (let [n (socket/c-send fd p (- len sent) no-signal)]
7276 (when (neg? n) (throw (ex-info "send failed" {:fd fd})))
7377 (recur (+ sent n))))))))
7478
@@ -24,6 +24,10 @@
24 (def ^:private sock-stream 1)24 (def ^:private sock-stream 1)
25 (def ^:private buffer-size 8192)25 (def ^:private buffer-size 8192)
26 26
27+;; MSG_NOSIGNAL. Writing to a socket the far end has closed raises SIGPIPE
28+;; otherwise, and nothing here handles signals — the process simply goes.
29+(def ^:private no-signal @#'socket/msg-nosignal)
30+
27 ;; ---------------------------------------------------------------- parsing31 ;; ---------------------------------------------------------------- parsing
28 32
29 (defn parse-line33 (defn parse-line
@@ -68,7 +72,7 @@
68 (ffi/with-c-string [p text]72 (ffi/with-c-string [p text]
69 (loop [sent 0]73 (loop [sent 0]
70 (when (< sent len)74 (when (< sent len)
71- (let [n (socket/c-send fd p (- len sent) 0)]75+ (let [n (socket/c-send fd p (- len sent) no-signal)]
72 (when (neg? n) (throw (ex-info "send failed" {:fd fd})))76 (when (neg? n) (throw (ex-info "send failed" {:fd fd})))
73 (recur (+ sent n))))))))77 (recur (+ sent n))))))))
74 78
modified src/frq/oauth.jolt +16 -5
@@ -18,6 +18,12 @@
1818
1919 (def default-broker "https://auth.freeq.at")
2020
21+;; MSG_NOSIGNAL. A browser opens more connections to a page than it reads
22+;; favicon, preconnect, prefetch and closes them without ceremony. Writing to
23+;; one of those raises SIGPIPE, which with no handler installed ends the
24+;; process: the app looked like it wedged the moment the redirect arrived.
25+(def ^:private no-signal @#'socket/msg-nosignal)
26+
2127 ;; ------------------------------------------------------------------ urls
2228
2329 (defn url-encode
@@ -64,11 +70,13 @@
6470 (count (.getBytes body)) "\r\n\r\n")
6571 text (str head body)]
6672 (ffi/with-c-string [p text]
67- (socket/c-send fd p (count (.getBytes text)) 0))))
73+ ;; A closed peer is ordinary here, so a failed write is not an error.
74+ (try (socket/c-send fd p (count (.getBytes text)) no-signal)
75+ (catch Exception _ -1)))))
6876
6977 (defn- read-request [fd]
7078 (let [buf (ffi/alloc 16384)
71- n (socket/c-recv fd buf 16384 0)]
79+ n (try (socket/c-recv fd buf 16384 no-signal) (catch Exception _ -1))]
7280 (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) "")))
7381
7482 (defn- bind-loopback!
@@ -117,10 +125,13 @@
117125 (let [req (read-request fd)
118126 line (first (str/split-lines req))]
119127 (if (str/starts-with? (or line "") "POST /capture")
120- (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))]
121- (respond! fd "ok" "text/plain")
128+ (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))
129+ tokens (try (tokens-of body) (catch Exception _ nil))]
130+ (respond! fd (if tokens "ok" "bad payload") "text/plain")
122131 (socket/c-close fd)
123- (tokens-of body))
132+ ;; A POST that carried nothing usable is not the end of the
133+ ;; wait keep serving, the real handoff may still arrive.
134+ (or tokens (recur)))
124135 (do (respond! fd capture-html "text/html; charset=utf-8")
125136 (socket/c-close fd)
126137 (recur)))))))
@@ -18,6 +18,12 @@
18 18
19 (def default-broker "https://auth.freeq.at")19 (def default-broker "https://auth.freeq.at")
20 20
21+;; MSG_NOSIGNAL. A browser opens more connections to a page than it reads
22+;; favicon, preconnect, prefetch and closes them without ceremony. Writing to
23+;; one of those raises SIGPIPE, which with no handler installed ends the
24+;; process: the app looked like it wedged the moment the redirect arrived.
25+(def ^:private no-signal @#'socket/msg-nosignal)
26+
21 ;; ------------------------------------------------------------------ urls27 ;; ------------------------------------------------------------------ urls
22 28
23 (defn url-encode29 (defn url-encode
@@ -64,11 +70,13 @@
64 (count (.getBytes body)) "\r\n\r\n")70 (count (.getBytes body)) "\r\n\r\n")
65 text (str head body)]71 text (str head body)]
66 (ffi/with-c-string [p text]72 (ffi/with-c-string [p text]
67- (socket/c-send fd p (count (.getBytes text)) 0))))73+ ;; A closed peer is ordinary here, so a failed write is not an error.
74+ (try (socket/c-send fd p (count (.getBytes text)) no-signal)
75+ (catch Exception _ -1)))))
68 76
69 (defn- read-request [fd]77 (defn- read-request [fd]
70 (let [buf (ffi/alloc 16384)78 (let [buf (ffi/alloc 16384)
71- n (socket/c-recv fd buf 16384 0)]79+ n (try (socket/c-recv fd buf 16384 no-signal) (catch Exception _ -1))]
72 (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) "")))80 (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) "")))
73 81
74 (defn- bind-loopback!82 (defn- bind-loopback!
@@ -117,10 +125,13 @@
117 (let [req (read-request fd)125 (let [req (read-request fd)
118 line (first (str/split-lines req))]126 line (first (str/split-lines req))]
119 (if (str/starts-with? (or line "") "POST /capture")127 (if (str/starts-with? (or line "") "POST /capture")
120- (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))]128+ (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))
121- (respond! fd "ok" "text/plain")129+ tokens (try (tokens-of body) (catch Exception _ nil))]
130+ (respond! fd (if tokens "ok" "bad payload") "text/plain")
122 (socket/c-close fd)131 (socket/c-close fd)
123- (tokens-of body))132+ ;; A POST that carried nothing usable is not the end of the
133+ ;; wait keep serving, the real handoff may still arrive.
134+ (or tokens (recur)))
124 (do (respond! fd capture-html "text/html; charset=utf-8")135 (do (respond! fd capture-html "text/html; charset=utf-8")
125 (socket/c-close fd)136 (socket/c-close fd)
126 (recur)))))))137 (recur)))))))
modified src/frq/state.jolt +11 -1
@@ -134,8 +134,18 @@
134134 "QUIT" nil
135135 ("NOTICE" "372" "375" "376" "002" "003" "004")
136136 (reset! status (or (last params) @status))
137+ ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
138+ ;; 471 full, 403 no such channel. The channel is params[1]; clearing its
139+ ;; flags is what lets a later attempt send a JOIN at all.
137140 ("473" "474" "475" "477" "403" "471")
138- (reset! error (str "Cannot join: " (last params)))
141+ (let [ch (second params)
142+ why (last params)]
143+ (when ch
144+ (swap! channels #(-> (ensure-channel % ch)
145+ (assoc-in [ch :joined?] false)
146+ (assoc-in [ch :joining?] false)))
147+ (push-message! ch "*" (str "Could not join " ch " — " why)))
148+ (reset! error (str "Cannot join: " why)))
139149 "903" (reset! status (str "Signed in as " (:handle @session)))
140150 ("904" "905" "906") (do (reset! session nil)
141151 ;; The broker token may still be good — but a
@@ -134,8 +134,18 @@
134 "QUIT" nil134 "QUIT" nil
135 ("NOTICE" "372" "375" "376" "002" "003" "004")135 ("NOTICE" "372" "375" "376" "002" "003" "004")
136 (reset! status (or (last params) @status))136 (reset! status (or (last params) @status))
137+ ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
138+ ;; 471 full, 403 no such channel. The channel is params[1]; clearing its
139+ ;; flags is what lets a later attempt send a JOIN at all.
137 ("473" "474" "475" "477" "403" "471")140 ("473" "474" "475" "477" "403" "471")
138- (reset! error (str "Cannot join: " (last params)))141+ (let [ch (second params)
142+ why (last params)]
143+ (when ch
144+ (swap! channels #(-> (ensure-channel % ch)
145+ (assoc-in [ch :joined?] false)
146+ (assoc-in [ch :joining?] false)))
147+ (push-message! ch "*" (str "Could not join " ch " — " why)))
148+ (reset! error (str "Cannot join: " why)))
139 "903" (reset! status (str "Signed in as " (:handle @session)))149 "903" (reset! status (str "Signed in as " (:handle @session)))
140 ("904" "905" "906") (do (reset! session nil)150 ("904" "905" "906") (do (reset! session nil)
141 ;; The broker token may still be good — but a151 ;; The broker token may still be good — but a