Say what the server refused, and log what crossed the wire
A reply that shows up here and nowhere else is a question the client had no way to answer: nothing read FAIL, so a refusal arrived, parsed, and was dropped — the send looked like it worked — and nothing logged the lines, so what a tag looked like when it left and whether the server sent it back were both unobservable. FAIL now lands in `error`, worded as `frq.state` words it so the same refusal reads the same on both halves, with the code kept for a bug report; before 001 it takes the connect screen down rather than leaving a spinner behind the message. `frq.net.dart` logs every line in and out under `frq.wire` — dart:developer, so it reaches a terminal under `just flutter-desktop` and logcat on a phone — and `parse-line` keeps the `:raw` line it read, because every field beside it says what we made of a line rather than what arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7659480 parent: 879ae18 modified
common/frq/irc/parse.cljc +8 -3 | @@ -13,14 +13,15 @@ | ||
| 13 | 13 | (:require [clojure.string :as str])) |
| 14 | 14 | |
| 15 | 15 | (defn parse-line |
| 16 | - "An IRC line into {:tags :prefix :command :params}. The trailing parameter | |
| 16 | + "An IRC line into {:raw :tags :prefix :command :params}. The trailing parameter | |
| 17 | 17 | (after \" :\") keeps its spaces; everything before it splits on whitespace. |
| 18 | 18 | |
| 19 | 19 | IRCv3 tags come first when there are any. A connection that negotiates CAP |
| 20 | 20 | gets them where a bare one does not — which is why a client that ignores them |
| 21 | 21 | looks fine as a guest and goes silent once it authenticates." |
| 22 | 22 | [line] |
| 23 | - (let [line (str/trimr line) | |
| 23 | + (let [raw (str/trimr line) | |
| 24 | + line raw | |
| 24 | 25 | [tags line] (if (str/starts-with? line "@") |
| 25 | 26 | (let [i (str/index-of line " ")] |
| 26 | 27 | [(subs line 1 i) (str/triml (subs line i))]) |
| @@ -33,7 +34,11 @@ | ||
| 33 | 34 | head (if i (subs rest-line 0 i) rest-line) |
| 34 | 35 | trailing (when i (subs rest-line (+ i 2))) |
| 35 | 36 | parts (remove str/blank? (str/split head #" "))] |
| 36 | - {:tags tags | |
| 37 | + {;; The line this was read off, kept so a reader can say what actually | |
| 38 | + ;; arrived rather than what we made of it — a tag the server dropped is | |
| 39 | + ;; invisible in every field below. | |
| 40 | + :raw raw | |
| 41 | + :tags tags | |
| 37 | 42 | :account (when tags |
| 38 | 43 | (second (re-find #"(?:^|;)account=([^;]*)" tags))) |
| 39 | 44 | :prefix prefix |
| @@ -13,14 +13,15 @@ | |||
| 13 | (:require [clojure.string :as str])) | 13 | (:require [clojure.string :as str])) |
| 14 | 14 | ||
| 15 | (defn parse-line | 15 | (defn parse-line |
| 16 | - "An IRC line into {:tags :prefix :command :params}. The trailing parameter | 16 | + "An IRC line into {:raw :tags :prefix :command :params}. The trailing parameter |
| 17 | (after \" :\") keeps its spaces; everything before it splits on whitespace. | 17 | (after \" :\") keeps its spaces; everything before it splits on whitespace. |
| 18 | 18 | ||
| 19 | IRCv3 tags come first when there are any. A connection that negotiates CAP | 19 | IRCv3 tags come first when there are any. A connection that negotiates CAP |
| 20 | gets them where a bare one does not — which is why a client that ignores them | 20 | gets them where a bare one does not — which is why a client that ignores them |
| 21 | looks fine as a guest and goes silent once it authenticates." | 21 | looks fine as a guest and goes silent once it authenticates." |
| 22 | [line] | 22 | [line] |
| 23 | - (let [line (str/trimr line) | 23 | + (let [raw (str/trimr line) |
| 24 | + line raw | ||
| 24 | [tags line] (if (str/starts-with? line "@") | 25 | [tags line] (if (str/starts-with? line "@") |
| 25 | (let [i (str/index-of line " ")] | 26 | (let [i (str/index-of line " ")] |
| 26 | [(subs line 1 i) (str/triml (subs line i))]) | 27 | [(subs line 1 i) (str/triml (subs line i))]) |
| @@ -33,7 +34,11 @@ | |||
| 33 | head (if i (subs rest-line 0 i) rest-line) | 34 | head (if i (subs rest-line 0 i) rest-line) |
| 34 | trailing (when i (subs rest-line (+ i 2))) | 35 | trailing (when i (subs rest-line (+ i 2))) |
| 35 | parts (remove str/blank? (str/split head #" "))] | 36 | parts (remove str/blank? (str/split head #" "))] |
| 36 | - {:tags tags | 37 | + {;; The line this was read off, kept so a reader can say what actually |
| 38 | + ;; arrived rather than what we made of it — a tag the server dropped is | ||
| 39 | + ;; invisible in every field below. | ||
| 40 | + :raw raw | ||
| 41 | + :tags tags | ||
| 37 | :account (when tags | 42 | :account (when tags |
| 38 | (second (re-find #"(?:^|;)account=([^;]*)" tags))) | 43 | (second (re-find #"(?:^|;)account=([^;]*)" tags))) |
| 39 | :prefix prefix | 44 | :prefix prefix |
modified
flutter/src/frq/main.cljd +41 -0 | @@ -759,6 +759,47 @@ | ||
| 759 | 759 | (actions/open-channel! last-room) |
| 760 | 760 | (actions/join! auto-join))) |
| 761 | 761 | |
| 762 | + ;; IRCv3 standard replies. The | |
| 763 | + ;; server's way of refusing | |
| 764 | + ;; something it took in and did not | |
| 765 | + ;; do — a signature it would not | |
| 766 | + ;; verify, a tag it would not | |
| 767 | + ;; accept, a command it would not | |
| 768 | + ;; run. Nothing read these, so every | |
| 769 | + ;; such refusal arrived, parsed, and | |
| 770 | + ;; was dropped: the send looked like | |
| 771 | + ;; it worked and the line never | |
| 772 | + ;; reached the room. FAIL is an | |
| 773 | + ;; error because the thing did not | |
| 774 | + ;; happen; WARN is the same shape | |
| 775 | + ;; and did, so it only goes to the | |
| 776 | + ;; log. | |
| 777 | + ;; | |
| 778 | + ;; `FAIL <command> <code> [context…] | |
| 779 | + ;; :description`. Worded as | |
| 780 | + ;; `frq.state` words it — the same | |
| 781 | + ;; refusal should read the same on | |
| 782 | + ;; both halves — with the code kept, | |
| 783 | + ;; because "refused" is what a | |
| 784 | + ;; reader needs and the code is what | |
| 785 | + ;; a bug report does. | |
| 786 | + (= "FAIL" cmd) | |
| 787 | + (let [ps (vec (:params m)) | |
| 788 | + why (str (or (first ps) "Request") | |
| 789 | + " refused — " | |
| 790 | + (if (seq text) | |
| 791 | + text "no reason given") | |
| 792 | + " (" (second ps) ")")] | |
| 793 | + ;; A refusal before 001 is a | |
| 794 | + ;; connect that will not finish, so | |
| 795 | + ;; it takes the connect screen | |
| 796 | + ;; down with it rather than | |
| 797 | + ;; leaving the spinner up behind | |
| 798 | + ;; the message. | |
| 799 | + (if @cells/connecting? | |
| 800 | + (fail! why) | |
| 801 | + (reset! cells/error why))) | |
| 802 | + | |
| 762 | 803 | (contains? registration-failed cmd) |
| 763 | 804 | (fail! (if (= "433" cmd) |
| 764 | 805 | (str "Nick " @cells/form-nick |
| @@ -759,6 +759,47 @@ | |||
| 759 | (actions/open-channel! last-room) | 759 | (actions/open-channel! last-room) |
| 760 | (actions/join! auto-join))) | 760 | (actions/join! auto-join))) |
| 761 | 761 | ||
| 762 | + ;; IRCv3 standard replies. The | ||
| 763 | + ;; server's way of refusing | ||
| 764 | + ;; something it took in and did not | ||
| 765 | + ;; do — a signature it would not | ||
| 766 | + ;; verify, a tag it would not | ||
| 767 | + ;; accept, a command it would not | ||
| 768 | + ;; run. Nothing read these, so every | ||
| 769 | + ;; such refusal arrived, parsed, and | ||
| 770 | + ;; was dropped: the send looked like | ||
| 771 | + ;; it worked and the line never | ||
| 772 | + ;; reached the room. FAIL is an | ||
| 773 | + ;; error because the thing did not | ||
| 774 | + ;; happen; WARN is the same shape | ||
| 775 | + ;; and did, so it only goes to the | ||
| 776 | + ;; log. | ||
| 777 | + ;; | ||
| 778 | + ;; `FAIL <command> <code> [context…] | ||
| 779 | + ;; :description`. Worded as | ||
| 780 | + ;; `frq.state` words it — the same | ||
| 781 | + ;; refusal should read the same on | ||
| 782 | + ;; both halves — with the code kept, | ||
| 783 | + ;; because "refused" is what a | ||
| 784 | + ;; reader needs and the code is what | ||
| 785 | + ;; a bug report does. | ||
| 786 | + (= "FAIL" cmd) | ||
| 787 | + (let [ps (vec (:params m)) | ||
| 788 | + why (str (or (first ps) "Request") | ||
| 789 | + " refused — " | ||
| 790 | + (if (seq text) | ||
| 791 | + text "no reason given") | ||
| 792 | + " (" (second ps) ")")] | ||
| 793 | + ;; A refusal before 001 is a | ||
| 794 | + ;; connect that will not finish, so | ||
| 795 | + ;; it takes the connect screen | ||
| 796 | + ;; down with it rather than | ||
| 797 | + ;; leaving the spinner up behind | ||
| 798 | + ;; the message. | ||
| 799 | + (if @cells/connecting? | ||
| 800 | + (fail! why) | ||
| 801 | + (reset! cells/error why))) | ||
| 802 | + | ||
| 762 | (contains? registration-failed cmd) | 803 | (contains? registration-failed cmd) |
| 763 | (fail! (if (= "433" cmd) | 804 | (fail! (if (= "433" cmd) |
| 764 | (str "Nick " @cells/form-nick | 805 | (str "Nick " @cells/form-nick |
modified
flutter/src/frq/net/dart.cljd +28 -1 | @@ -15,9 +15,29 @@ | ||
| 15 | 15 | What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the |
| 16 | 16 | desktop runs, out of common/." |
| 17 | 17 | (:require ["dart:convert" :as conv] |
| 18 | + ["dart:developer" :as dev] | |
| 18 | 19 | ["dart:io" :as io] |
| 19 | 20 | [frq.irc.parse :as parse])) |
| 20 | 21 | |
| 22 | +;; Whether to log every line in and out, under the name `frq.wire`. | |
| 23 | +;; | |
| 24 | +;; On by default because the questions it answers are the ones a client cannot | |
| 25 | +;; answer any other way: what a tag looked like when it left, and whether the | |
| 26 | +;; server sent it back. A field on a parsed message says what we made of a | |
| 27 | +;; line; only the line says what arrived. | |
| 28 | +;; | |
| 29 | +;; `dart:developer` rather than stdout, so it reaches both targets the same | |
| 30 | +;; way — a terminal under `just flutter-desktop`, and `adb logcat` on a phone. | |
| 31 | +;; | |
| 32 | +;; A comment rather than a docstring because `defonce` takes none: it is | |
| 33 | +;; `(defonce name expr)` and nothing else, and the extra form is an arity | |
| 34 | +;; error the compiler reports against the namespace rather than the line. | |
| 35 | +(defonce wire-log? (atom true)) | |
| 36 | + | |
| 37 | +(defn- wire! [direction line] | |
| 38 | + (when @wire-log? | |
| 39 | + (dev/log (str direction " " line) .name "frq.wire"))) | |
| 40 | + | |
| 21 | 41 | (defn ^:async connect! |
| 22 | 42 | "Open a connection and start reading it. |
| 23 | 43 | |
| @@ -54,13 +74,19 @@ | ||
| 54 | 74 | (doseq [raw whole] |
| 55 | 75 | (let [line (.trim (str raw))] |
| 56 | 76 | (when (and on-msg (pos? (count line))) |
| 77 | + (wire! "<" line) | |
| 57 | 78 | (let [m (parse/parse-line line)] |
| 58 | 79 | ;; PING is answered here rather than upstairs: a |
| 59 | 80 | ;; client that leaves it to the reducer is one |
| 60 | 81 | ;; queue away from a timeout, and nothing about |
| 61 | 82 | ;; the answer is a decision. |
| 62 | 83 | (when (= "PING" (:command m)) |
| 63 | - (.write sock (str "PONG :" (first (:params m)) "\r\n"))) | |
| 84 | + ;; Logged like any other write even though it is | |
| 85 | + ;; keepalive noise: a log that quietly omits a | |
| 86 | + ;; line is worse than a long one. | |
| 87 | + (let [pong (str "PONG :" (first (:params m)))] | |
| 88 | + (wire! ">" pong) | |
| 89 | + (.write sock (str pong "\r\n")))) | |
| 64 | 90 | (on-msg m))))))) |
| 65 | 91 | .onDone (fn [] (when on-close (on-close nil))) |
| 66 | 92 | .onError (fn [e _] (when on-close (on-close (str e)))) |
| @@ -68,6 +94,7 @@ | ||
| 68 | 94 | sock)) |
| 69 | 95 | |
| 70 | 96 | (defn send-line! [^io/Socket sock line] |
| 97 | + (wire! ">" line) | |
| 71 | 98 | (.write sock (str line "\r\n"))) |
| 72 | 99 | |
| 73 | 100 | (defn close! [^io/Socket sock] |
| @@ -15,9 +15,29 @@ | |||
| 15 | What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the | 15 | What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the |
| 16 | desktop runs, out of common/." | 16 | desktop runs, out of common/." |
| 17 | (:require ["dart:convert" :as conv] | 17 | (:require ["dart:convert" :as conv] |
| 18 | + ["dart:developer" :as dev] | ||
| 18 | ["dart:io" :as io] | 19 | ["dart:io" :as io] |
| 19 | [frq.irc.parse :as parse])) | 20 | [frq.irc.parse :as parse])) |
| 20 | 21 | ||
| 22 | +;; Whether to log every line in and out, under the name `frq.wire`. | ||
| 23 | +;; | ||
| 24 | +;; On by default because the questions it answers are the ones a client cannot | ||
| 25 | +;; answer any other way: what a tag looked like when it left, and whether the | ||
| 26 | +;; server sent it back. A field on a parsed message says what we made of a | ||
| 27 | +;; line; only the line says what arrived. | ||
| 28 | +;; | ||
| 29 | +;; `dart:developer` rather than stdout, so it reaches both targets the same | ||
| 30 | +;; way — a terminal under `just flutter-desktop`, and `adb logcat` on a phone. | ||
| 31 | +;; | ||
| 32 | +;; A comment rather than a docstring because `defonce` takes none: it is | ||
| 33 | +;; `(defonce name expr)` and nothing else, and the extra form is an arity | ||
| 34 | +;; error the compiler reports against the namespace rather than the line. | ||
| 35 | +(defonce wire-log? (atom true)) | ||
| 36 | + | ||
| 37 | +(defn- wire! [direction line] | ||
| 38 | + (when @wire-log? | ||
| 39 | + (dev/log (str direction " " line) .name "frq.wire"))) | ||
| 40 | + | ||
| 21 | (defn ^:async connect! | 41 | (defn ^:async connect! |
| 22 | "Open a connection and start reading it. | 42 | "Open a connection and start reading it. |
| 23 | 43 | ||
| @@ -54,13 +74,19 @@ | |||
| 54 | (doseq [raw whole] | 74 | (doseq [raw whole] |
| 55 | (let [line (.trim (str raw))] | 75 | (let [line (.trim (str raw))] |
| 56 | (when (and on-msg (pos? (count line))) | 76 | (when (and on-msg (pos? (count line))) |
| 77 | + (wire! "<" line) | ||
| 57 | (let [m (parse/parse-line line)] | 78 | (let [m (parse/parse-line line)] |
| 58 | ;; PING is answered here rather than upstairs: a | 79 | ;; PING is answered here rather than upstairs: a |
| 59 | ;; client that leaves it to the reducer is one | 80 | ;; client that leaves it to the reducer is one |
| 60 | ;; queue away from a timeout, and nothing about | 81 | ;; queue away from a timeout, and nothing about |
| 61 | ;; the answer is a decision. | 82 | ;; the answer is a decision. |
| 62 | (when (= "PING" (:command m)) | 83 | (when (= "PING" (:command m)) |
| 63 | - (.write sock (str "PONG :" (first (:params m)) "\r\n"))) | 84 | + ;; Logged like any other write even though it is |
| 85 | + ;; keepalive noise: a log that quietly omits a | ||
| 86 | + ;; line is worse than a long one. | ||
| 87 | + (let [pong (str "PONG :" (first (:params m)))] | ||
| 88 | + (wire! ">" pong) | ||
| 89 | + (.write sock (str pong "\r\n")))) | ||
| 64 | (on-msg m))))))) | 90 | (on-msg m))))))) |
| 65 | .onDone (fn [] (when on-close (on-close nil))) | 91 | .onDone (fn [] (when on-close (on-close nil))) |
| 66 | .onError (fn [e _] (when on-close (on-close (str e)))) | 92 | .onError (fn [e _] (when on-close (on-close (str e)))) |
| @@ -68,6 +94,7 @@ | |||
| 68 | sock)) | 94 | sock)) |
| 69 | 95 | ||
| 70 | (defn send-line! [^io/Socket sock line] | 96 | (defn send-line! [^io/Socket sock line] |
| 97 | + (wire! ">" line) | ||
| 71 | (.write sock (str line "\r\n"))) | 98 | (.write sock (str line "\r\n"))) |
| 72 | 99 | ||
| 73 | (defn close! [^io/Socket sock] | 100 | (defn close! [^io/Socket sock] |