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>
3d47bd9 parent: d4d1d6d modified
src/frq/app.jolt +28 -16 | @@ -11,11 +11,19 @@ | ||
| 11 | 11 | |
| 12 | 12 | ;; ---------------------------------------------------------------- pieces |
| 13 | 13 | |
| 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)}]])]) | |
| 19 | 27 | |
| 20 | 28 | (defn tab-bar [] |
| 21 | 29 | [:hbox {:spacing 8} |
| @@ -79,12 +87,14 @@ | ||
| 79 | 87 | :width-request 320 |
| 80 | 88 | :placeholder "alice.bsky.social" |
| 81 | 89 | :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}]])]] | |
| 88 | 98 | |
| 89 | 99 | :app-password |
| 90 | 100 | [:vbox {:spacing 6} |
| @@ -122,7 +132,8 @@ | ||
| 122 | 132 | [:card {:key name} |
| 123 | 133 | [:hbox {:spacing 12} |
| 124 | 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 | 137 | [:dim-label {:label (s/last-preview buffer)}] |
| 127 | 138 | [:hbox {:spacing 8} |
| 128 | 139 | [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}] |
| @@ -223,10 +234,11 @@ | ||
| 223 | 234 | [:vbox {:spacing 2} |
| 224 | 235 | [:label {:label (str "Signed in as " (:handle sess))}] |
| 225 | 236 | [: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))}])]] | |
| 230 | 242 | [:dim-label {:label "Guest — not signed in."}]) |
| 231 | 243 | [:separator {}] |
| 232 | 244 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| @@ -11,11 +11,19 @@ | |||
| 11 | 11 | ||
| 12 | ;; ---------------------------------------------------------------- pieces | 12 | ;; ---------------------------------------------------------------- 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 320 | 87 | :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-token | 90 | + [: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-password | 99 | :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-token | 237 | + [:vbox {:key :forget} |
| 227 | - [:button {:label "Forget Bluesky session" | 238 | + (when @s/broker-token |
| 228 | - :kind :destructive | 239 | + [: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 @@ | ||
| 24 | 24 | (def ^:private sock-stream 1) |
| 25 | 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 | 31 | ;; ---------------------------------------------------------------- parsing |
| 28 | 32 | |
| 29 | 33 | (defn parse-line |
| @@ -68,7 +72,7 @@ | ||
| 68 | 72 | (ffi/with-c-string [p text] |
| 69 | 73 | (loop [sent 0] |
| 70 | 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 | 76 | (when (neg? n) (throw (ex-info "send failed" {:fd fd}))) |
| 73 | 77 | (recur (+ sent n)))))))) |
| 74 | 78 | |
| @@ -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 | ;; ---------------------------------------------------------------- parsing | 31 | ;; ---------------------------------------------------------------- parsing |
| 28 | 32 | ||
| 29 | (defn parse-line | 33 | (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 @@ | ||
| 18 | 18 | |
| 19 | 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 | 27 | ;; ------------------------------------------------------------------ urls |
| 22 | 28 | |
| 23 | 29 | (defn url-encode |
| @@ -64,11 +70,13 @@ | ||
| 64 | 70 | (count (.getBytes body)) "\r\n\r\n") |
| 65 | 71 | text (str head body)] |
| 66 | 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 | 77 | (defn- read-request [fd] |
| 70 | 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 | 80 | (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) ""))) |
| 73 | 81 | |
| 74 | 82 | (defn- bind-loopback! |
| @@ -117,10 +125,13 @@ | ||
| 117 | 125 | (let [req (read-request fd) |
| 118 | 126 | line (first (str/split-lines req))] |
| 119 | 127 | (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") | |
| 122 | 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 | 135 | (do (respond! fd capture-html "text/html; charset=utf-8") |
| 125 | 136 | (socket/c-close fd) |
| 126 | 137 | (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 | ;; ------------------------------------------------------------------ urls | 27 | ;; ------------------------------------------------------------------ urls |
| 22 | 28 | ||
| 23 | (defn url-encode | 29 | (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 @@ | ||
| 134 | 134 | "QUIT" nil |
| 135 | 135 | ("NOTICE" "372" "375" "376" "002" "003" "004") |
| 136 | 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 | 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 | 149 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 140 | 150 | ("904" "905" "906") (do (reset! session nil) |
| 141 | 151 | ;; The broker token may still be good — but a |
| @@ -134,8 +134,18 @@ | |||
| 134 | "QUIT" nil | 134 | "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 a | 151 | ;; The broker token may still be good — but a |