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

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>
nandi committed 2026-09-12T12:11:11-07:00 Browse files
7659480 parent: 879ae18
modified common/frq/irc/parse.cljc +8 -3
@@ -13,14 +13,15 @@
1313 (:require [clojure.string :as str]))
1414
1515 (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
1717 (after \" :\") keeps its spaces; everything before it splits on whitespace.
1818
1919 IRCv3 tags come first when there are any. A connection that negotiates CAP
2020 gets them where a bare one does not — which is why a client that ignores them
2121 looks fine as a guest and goes silent once it authenticates."
2222 [line]
23- (let [line (str/trimr line)
23+ (let [raw (str/trimr line)
24+ line raw
2425 [tags line] (if (str/starts-with? line "@")
2526 (let [i (str/index-of line " ")]
2627 [(subs line 1 i) (str/triml (subs line i))])
@@ -33,7 +34,11 @@
3334 head (if i (subs rest-line 0 i) rest-line)
3435 trailing (when i (subs rest-line (+ i 2)))
3536 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
3742 :account (when tags
3843 (second (re-find #"(?:^|;)account=([^;]*)" tags)))
3944 :prefix prefix
@@ -13,14 +13,15 @@
13 (:require [clojure.string :as str]))13 (:require [clojure.string :as str]))
14 14
15 (defn parse-line15 (defn parse-line
16- "An IRC line into {:tags :prefix :command :params}. The trailing parameter16+ "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 CAP19 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 them20 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 tags37+ {;; 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 tags42 :account (when tags
38 (second (re-find #"(?:^|;)account=([^;]*)" tags)))43 (second (re-find #"(?:^|;)account=([^;]*)" tags)))
39 :prefix prefix44 :prefix prefix
modified flutter/src/frq/main.cljd +41 -0
@@ -759,6 +759,47 @@
759759 (actions/open-channel! last-room)
760760 (actions/join! auto-join)))
761761
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+
762803 (contains? registration-failed cmd)
763804 (fail! (if (= "433" cmd)
764805 (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-nick805 (str "Nick " @cells/form-nick
modified flutter/src/frq/net/dart.cljd +28 -1
@@ -15,9 +15,29 @@
1515 What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the
1616 desktop runs, out of common/."
1717 (:require ["dart:convert" :as conv]
18+ ["dart:developer" :as dev]
1819 ["dart:io" :as io]
1920 [frq.irc.parse :as parse]))
2021
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+
2141 (defn ^:async connect!
2242 "Open a connection and start reading it.
2343
@@ -54,13 +74,19 @@
5474 (doseq [raw whole]
5575 (let [line (.trim (str raw))]
5676 (when (and on-msg (pos? (count line)))
77+ (wire! "<" line)
5778 (let [m (parse/parse-line line)]
5879 ;; PING is answered here rather than upstairs: a
5980 ;; client that leaves it to the reducer is one
6081 ;; queue away from a timeout, and nothing about
6182 ;; the answer is a decision.
6283 (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"))))
6490 (on-msg m)))))))
6591 .onDone (fn [] (when on-close (on-close nil)))
6692 .onError (fn [e _] (when on-close (on-close (str e))))
@@ -68,6 +94,7 @@
6894 sock))
6995
7096 (defn send-line! [^io/Socket sock line]
97+ (wire! ">" line)
7198 (.write sock (str line "\r\n")))
7299
73100 (defn close! [^io/Socket sock]
@@ -15,9 +15,29 @@
15 What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the15 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: a79 ;; PING is answered here rather than upstairs: a
59 ;; client that leaves it to the reducer is one80 ;; client that leaves it to the reducer is one
60 ;; queue away from a timeout, and nothing about81 ;; 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]