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

Nim under the existing UI, not instead of it

`just nim-app run` is the real client — every screen, every cell, every action
exactly as they were — with the Nim core as its transport. It signs in over
SASL as the actual Bluesky identity, joins the saved rooms and renders the
backlog with msgsig and account tags intact.

The previous spike had Nim owning the screens, and that was the wrong place to
put the boundary. It meant a 96-line connect screen standing in for a 148-line
one and a 43-line chat screen standing in for 1,518 — no reactions, no
replies, no images, no emoji picker — and the path forward from it was
rewriting 2,373 lines of screens into Nim and losing all of that. The screens
are the part of this client worth keeping.

So the seam is `frq.net` instead, which already existed and already had two
implementations. `frq.net.nim` is a third beside `frq.net.dart` and
`frq.net.web`, answering the same three questions, and `frq.main-nim` is
`frq.main` with one line changed — `net-nim/install!` where it says
`net-dart/install!`. `common/` and `flutter/src/frq/main.cljd` are byte for
byte what they were.

Nim owns the socket, the TLS and the line framing. It does not own the
parsing: the line goes to `frq.irc.parse` exactly as the other two transports
hand it, so `on-msg` gets the same map from the same parser and nothing
upstairs can tell which transport it is talking to. Nim has a parser of its
own, tested against the same cases, and swapping to it is a later change with
its own way of being wrong.

One deadlock, found because the window connected and then sat there. The
reader loop read before it drained the write queue, and IRC has the client
speak first: CAP/NICK/USER were queued before the socket finished connecting,
the loop went straight into a read, and the server had nothing to say because
we had not registered. Both sides waited. Writes go first now, which also
bounds write latency by nothing instead of by the read timeout — which matters
for a keystroke.

Finding it needed the two languages in one log, so `frq_trace` lets the Dart
side write through the Nim facility and FRQ_TRACE=1 gives one interleaved
story instead of two half-ones.

The earlier spike is still here — `just nim-spike`, Nim owning the screens —
and is now superseded. It should probably go.

Verified: 43 Nim, 29 Dart, 7 widget tests, check-common clean, and the real
app against the real server: CAP, SASL 903, five rooms, 473 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:28:10-07:00 Browse files
56551a8 parent: 1d62d1a
modified dart/frq_core/lib/frq_core.dart +57 -0
@@ -191,6 +191,63 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
191191 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
192192
193193
194+/// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one
195+/// interleaved story rather than two half-ones in different places.
196+void trace(String topic, String msg) {
197+ final f = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
198+ final a = _toC(topic);
199+ final b = _toC(msg);
200+ try {
201+ f(a, b);
202+ } finally {
203+ _freeArg(a);
204+ _freeArg(b);
205+ }
206+}
207+
208+// --------------------------------------------------------------- transport
209+//
210+// `frq.net`'s three operations, with a Nim socket behind them. This is the
211+// wiring that leaves the existing ClojureDart screens, cells and actions
212+// alone: only the transport underneath them is Nim.
213+
214+typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
215+typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
216+
217+/// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
218+/// through [connEvent].
219+void connOpen(String host, int port, {bool tls = true}) {
220+ final f = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
221+ final a = _toC(host);
222+ try {
223+ f(a, port, tls ? 1 : 0);
224+ } finally {
225+ _freeArg(a);
226+ }
227+}
228+
229+/// Queue a line. The transport adds the CRLF.
230+void connSend(String line) {
231+ final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
232+ final a = _toC(line);
233+ try {
234+ f(a);
235+ } finally {
236+ _freeArg(a);
237+ }
238+}
239+
240+void connClose() =>
241+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close')();
242+
243+/// The next line, or null when none is waiting. Never blocks.
244+String? connRecv() => _takeString(
245+ _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv')());
246+
247+/// The next transport event — `open`, `close: …`, `error: …` — or null.
248+String? connEvent() => _takeString(
249+ _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());
250+
194251 // ---------------------------------------------------------------- the UI
195252 //
196253 // The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
@@ -191,6 +191,63 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
191 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';191 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
192 192
193 193
194+/// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one
195+/// interleaved story rather than two half-ones in different places.
196+void trace(String topic, String msg) {
197+ final f = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
198+ final a = _toC(topic);
199+ final b = _toC(msg);
200+ try {
201+ f(a, b);
202+ } finally {
203+ _freeArg(a);
204+ _freeArg(b);
205+ }
206+}
207+
208+// --------------------------------------------------------------- transport
209+//
210+// `frq.net`'s three operations, with a Nim socket behind them. This is the
211+// wiring that leaves the existing ClojureDart screens, cells and actions
212+// alone: only the transport underneath them is Nim.
213+
214+typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
215+typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
216+
217+/// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
218+/// through [connEvent].
219+void connOpen(String host, int port, {bool tls = true}) {
220+ final f = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
221+ final a = _toC(host);
222+ try {
223+ f(a, port, tls ? 1 : 0);
224+ } finally {
225+ _freeArg(a);
226+ }
227+}
228+
229+/// Queue a line. The transport adds the CRLF.
230+void connSend(String line) {
231+ final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
232+ final a = _toC(line);
233+ try {
234+ f(a);
235+ } finally {
236+ _freeArg(a);
237+ }
238+}
239+
240+void connClose() =>
241+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close')();
242+
243+/// The next line, or null when none is waiting. Never blocks.
244+String? connRecv() => _takeString(
245+ _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv')());
246+
247+/// The next transport event — `open`, `close: …`, `error: …` — or null.
248+String? connEvent() => _takeString(
249+ _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());
250+
194 // ---------------------------------------------------------------- the UI251 // ---------------------------------------------------------------- the UI
195 //252 //
196 // The spike's claim: Nim owns the state and the screen, Dart owns the pixels.253 // The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
added flutter/lib/main_nim_app.dart +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+/// The real frq, with the Nim core as its transport.
2+///
3+/// Not to be confused with `main_nim.dart`, which is the earlier spike where
4+/// Nim owned the screens too. This one keeps every screen, every cell and
5+/// every action exactly as they are and swaps only what is underneath them.
6+export "cljd-out/frq/main-nim.dart" show main;
new file mode 100644
@@ -0,0 +1,6 @@
1+/// The real frq, with the Nim core as its transport.
2+///
3+/// Not to be confused with `main_nim.dart`, which is the earlier spike where
4+/// Nim owned the screens too. This one keeps every screen, every cell and
5+/// every action exactly as they are and swaps only what is underneath them.
6+export "cljd-out/frq/main-nim.dart" show main;
added flutter/src/frq/main_nim.cljd +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+(ns frq.main-nim
2+ "The real app, with the Nim core underneath it.
3+
4+ `frq.main` with one line changed: `frq.net.nim/install!` where it says
5+ `frq.net.dart/install!`. Everything else is shared with it — `bind!` and
6+ `start!` are its own, the screens are `common/frq/screens/`, the state is
7+ `frq.cells`, the actions are `frq.actions`. Nothing was reimplemented and
8+ nothing was moved.
9+
10+ A second entry namespace rather than a flag inside `frq.main`, for the
11+ reason `frq.main-web` is one: `clojure -M:cljd compile` walks out from a
12+ single namespace, so what `frq.main` requires is what every target compiles.
13+ `frq.net.nim` reaches `package:frq_core`, which is `dart:ffi`, which the web
14+ target has no library for — requiring it from `frq.main` would break the
15+ build that works to serve the one being tried.
16+
17+ Built as `flutter build linux -t lib/main_nim_app.dart`; see `just nim-app`."
18+ (:require ["package:path_provider/path_provider.dart" :as pp]
19+ [frq.main :as app]
20+ [frq.io :as _io]
21+ [frq.io.dart :as host]
22+ [frq.net.nim :as net-nim]
23+ [frq.oauth.dart :as oauth-dart]
24+ [frq.atproto.dart :as atproto-dart]
25+ [frq.pictures.dart :as pictures-dart]))
26+
27+(defn ^:async main []
28+ (app/bind!)
29+ (host/install! (.-path (await (pp/getApplicationSupportDirectory))))
30+ ;; The one line that differs from `frq.main/main`.
31+ (net-nim/install!)
32+ (oauth-dart/install!)
33+ (atproto-dart/install!)
34+ (pictures-dart/install!)
35+ (await (app/start!)))
new file mode 100644
@@ -0,0 +1,35 @@
1+(ns frq.main-nim
2+ "The real app, with the Nim core underneath it.
3+
4+ `frq.main` with one line changed: `frq.net.nim/install!` where it says
5+ `frq.net.dart/install!`. Everything else is shared with it — `bind!` and
6+ `start!` are its own, the screens are `common/frq/screens/`, the state is
7+ `frq.cells`, the actions are `frq.actions`. Nothing was reimplemented and
8+ nothing was moved.
9+
10+ A second entry namespace rather than a flag inside `frq.main`, for the
11+ reason `frq.main-web` is one: `clojure -M:cljd compile` walks out from a
12+ single namespace, so what `frq.main` requires is what every target compiles.
13+ `frq.net.nim` reaches `package:frq_core`, which is `dart:ffi`, which the web
14+ target has no library for — requiring it from `frq.main` would break the
15+ build that works to serve the one being tried.
16+
17+ Built as `flutter build linux -t lib/main_nim_app.dart`; see `just nim-app`."
18+ (:require ["package:path_provider/path_provider.dart" :as pp]
19+ [frq.main :as app]
20+ [frq.io :as _io]
21+ [frq.io.dart :as host]
22+ [frq.net.nim :as net-nim]
23+ [frq.oauth.dart :as oauth-dart]
24+ [frq.atproto.dart :as atproto-dart]
25+ [frq.pictures.dart :as pictures-dart]))
26+
27+(defn ^:async main []
28+ (app/bind!)
29+ (host/install! (.-path (await (pp/getApplicationSupportDirectory))))
30+ ;; The one line that differs from `frq.main/main`.
31+ (net-nim/install!)
32+ (oauth-dart/install!)
33+ (atproto-dart/install!)
34+ (pictures-dart/install!)
35+ (await (app/start!)))
added flutter/src/frq/net/nim.cljd +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+(ns frq.net.nim
2+ "`frq.net`, over the Nim core's socket.
3+
4+ The third transport beside `frq.net.dart` and `frq.net.web`, answering the
5+ same three questions they do — so the screens, `frq.cells`, `frq.actions`
6+ and every line of `frq.main` above it are untouched. That is the point of
7+ putting Nim *here* rather than higher up: the screens are the part of this
8+ client worth keeping, and a transport is the part that was always platform
9+ code.
10+
11+ Deliberately narrow. Nim owns the socket, the TLS and the line framing, and
12+ nothing else: the line is handed to `frq.irc.parse` exactly as the other two
13+ transports hand it, so `on-msg` receives the same map from the same parser
14+ and nothing upstairs can tell which transport it is talking to. Nim has a
15+ parser of its own that is tested against the same cases, and swapping to it
16+ is a later change with its own way of being wrong — one at a time.
17+
18+ What differs from `frq.net.dart` is who owns the socket. There, a
19+ `SecureSocket` and a Dart `Stream`; here, a socket on a Nim thread and a
20+ poll. Nim never calls back into Dart — a callback from a foreign thread has
21+ to be marshalled onto the main isolate, which is a whole mechanism for
22+ something a timer does for free."
23+ (:require ["dart:async" :as async]
24+ ["package:frq_core/frq_core.dart" :as core]
25+ [frq.irc.parse :as parse]
26+ [frq.net :as net]))
27+
28+(def ^:private poll-ms
29+ ;; Fast enough that a conversation feels live, slow enough that an idle
30+ ;; client is not waking twenty times a second for nothing. Each tick is two
31+ ;; FFI calls that answer null.
32+ 50)
33+
34+(defn- pump!
35+ "Drain whatever the socket thread has queued, once."
36+ [timer on-msg on-close]
37+ ;; Events first: a close seen before the lines that preceded it would drop
38+ ;; the tail of the session.
39+ (loop []
40+ (when-let [e (core/connEvent)]
41+ (cond
42+ (.startsWith e "close:")
43+ (do (some-> @timer .cancel)
44+ (when on-close (on-close nil)))
45+
46+ (.startsWith e "error:")
47+ (do (some-> @timer .cancel)
48+ (when on-close (on-close (.trim (subs e 6))))))
49+ (recur)))
50+
51+ (loop []
52+ (when-let [line (core/connRecv)]
53+ (let [m (parse/parse-line line)]
54+ ;; PING is answered here rather than upstairs, exactly as
55+ ;; `frq.net.dart` does it: a client that leaves keepalive to the
56+ ;; reducer is one queue away from a timeout, and nothing about the
57+ ;; answer is a decision.
58+ (when (= "PING" (:command m))
59+ (core/connSend (str "PONG :" (first (:params m)))))
60+ (when on-msg (on-msg m)))
61+ (recur))))
62+
63+(defn ^:async connect!
64+ "Dial, and start draining.
65+
66+ The `sock` this hands back is the timer. There is one connection in the core
67+ and no handle to give out, so the seam's socket argument is the thing that
68+ has to be stopped when the caller says close."
69+ [{:keys [host port tls? on-msg on-close]}]
70+ (core/trace "net.nim" (str "connect! " host ":" port " tls=" (boolean tls?)))
71+ (core/connOpen host (int port) .tls (boolean tls?))
72+ (let [timer (atom nil)]
73+ (reset! timer
74+ (async/Timer.periodic
75+ (Duration. .milliseconds poll-ms)
76+ (fn [_] (pump! timer on-msg on-close))))
77+ (core/trace "net.nim" "timer armed; handing the socket back")
78+ @timer))
79+
80+(defn send-line! [_sock line]
81+ (core/trace "net.nim" (str "send-line! " line))
82+ (core/connSend line))
83+
84+(defn close! [sock]
85+ (some-> sock .cancel)
86+ (core/connClose))
87+
88+(defn install!
89+ "Register this as the transport in place of `frq.net.dart`. An entry point
90+ calls one or the other and never both."
91+ []
92+ (net/install!
93+ {:connect! connect!
94+ :send-line! send-line!
95+ :close! close!}))
new file mode 100644
@@ -0,0 +1,95 @@
1+(ns frq.net.nim
2+ "`frq.net`, over the Nim core's socket.
3+
4+ The third transport beside `frq.net.dart` and `frq.net.web`, answering the
5+ same three questions they do — so the screens, `frq.cells`, `frq.actions`
6+ and every line of `frq.main` above it are untouched. That is the point of
7+ putting Nim *here* rather than higher up: the screens are the part of this
8+ client worth keeping, and a transport is the part that was always platform
9+ code.
10+
11+ Deliberately narrow. Nim owns the socket, the TLS and the line framing, and
12+ nothing else: the line is handed to `frq.irc.parse` exactly as the other two
13+ transports hand it, so `on-msg` receives the same map from the same parser
14+ and nothing upstairs can tell which transport it is talking to. Nim has a
15+ parser of its own that is tested against the same cases, and swapping to it
16+ is a later change with its own way of being wrong — one at a time.
17+
18+ What differs from `frq.net.dart` is who owns the socket. There, a
19+ `SecureSocket` and a Dart `Stream`; here, a socket on a Nim thread and a
20+ poll. Nim never calls back into Dart — a callback from a foreign thread has
21+ to be marshalled onto the main isolate, which is a whole mechanism for
22+ something a timer does for free."
23+ (:require ["dart:async" :as async]
24+ ["package:frq_core/frq_core.dart" :as core]
25+ [frq.irc.parse :as parse]
26+ [frq.net :as net]))
27+
28+(def ^:private poll-ms
29+ ;; Fast enough that a conversation feels live, slow enough that an idle
30+ ;; client is not waking twenty times a second for nothing. Each tick is two
31+ ;; FFI calls that answer null.
32+ 50)
33+
34+(defn- pump!
35+ "Drain whatever the socket thread has queued, once."
36+ [timer on-msg on-close]
37+ ;; Events first: a close seen before the lines that preceded it would drop
38+ ;; the tail of the session.
39+ (loop []
40+ (when-let [e (core/connEvent)]
41+ (cond
42+ (.startsWith e "close:")
43+ (do (some-> @timer .cancel)
44+ (when on-close (on-close nil)))
45+
46+ (.startsWith e "error:")
47+ (do (some-> @timer .cancel)
48+ (when on-close (on-close (.trim (subs e 6))))))
49+ (recur)))
50+
51+ (loop []
52+ (when-let [line (core/connRecv)]
53+ (let [m (parse/parse-line line)]
54+ ;; PING is answered here rather than upstairs, exactly as
55+ ;; `frq.net.dart` does it: a client that leaves keepalive to the
56+ ;; reducer is one queue away from a timeout, and nothing about the
57+ ;; answer is a decision.
58+ (when (= "PING" (:command m))
59+ (core/connSend (str "PONG :" (first (:params m)))))
60+ (when on-msg (on-msg m)))
61+ (recur))))
62+
63+(defn ^:async connect!
64+ "Dial, and start draining.
65+
66+ The `sock` this hands back is the timer. There is one connection in the core
67+ and no handle to give out, so the seam's socket argument is the thing that
68+ has to be stopped when the caller says close."
69+ [{:keys [host port tls? on-msg on-close]}]
70+ (core/trace "net.nim" (str "connect! " host ":" port " tls=" (boolean tls?)))
71+ (core/connOpen host (int port) .tls (boolean tls?))
72+ (let [timer (atom nil)]
73+ (reset! timer
74+ (async/Timer.periodic
75+ (Duration. .milliseconds poll-ms)
76+ (fn [_] (pump! timer on-msg on-close))))
77+ (core/trace "net.nim" "timer armed; handing the socket back")
78+ @timer))
79+
80+(defn send-line! [_sock line]
81+ (core/trace "net.nim" (str "send-line! " line))
82+ (core/connSend line))
83+
84+(defn close! [sock]
85+ (some-> sock .cancel)
86+ (core/connClose))
87+
88+(defn install!
89+ "Register this as the transport in place of `frq.net.dart`. An entry point
90+ calls one or the other and never both."
91+ []
92+ (net/install!
93+ {:connect! connect!
94+ :send-line! send-line!
95+ :close! close!}))
modified justfile +34 -0
@@ -526,3 +526,37 @@ nim-live *args:
526526 cd dart/frq_core
527527 dart pub get >/dev/null
528528 exec dart run tool/live_send.dart "$@"
529+
530+# The real app, with the Nim core as its transport.
531+#
532+# This is the wiring that matters: `frq.main-nim` is `frq.main` with one line
533+# changed — `frq.net.nim/install!` where it says `frq.net.dart/install!`. Every
534+# screen, every cell and every action is the one that was already there. Nim
535+# owns the socket, the TLS and the line framing, and nothing else.
536+#
537+# Not to be confused with `nim-spike`, which is the earlier experiment where
538+# Nim owned the screens too. That one reimplemented a 1,518-line chat screen in
539+# forty lines and lost everything in between; this one reimplements nothing.
540+#
541+# just nim-app build it
542+# just nim-app run open the window
543+nim-app action="build":
544+ #!/usr/bin/env bash
545+ set -euo pipefail
546+ cd "{{justfile_directory()}}"
547+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
548+ just nim-lib
549+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
550+ --command just nim-app "$@"
551+ fi
552+ cd flutter
553+ flutter pub get
554+ export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
555+ clojure -M:cljd compile frq.main-nim
556+ runner=()
557+ [ -e /run/current-system ] || runner=("$NIXGL")
558+ case "{{action}}" in
559+ build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim_app.dart ;;
560+ run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim_app.dart ;;
561+ *) echo "usage: just nim-app [build|run]" >&2; exit 1 ;;
562+ esac
@@ -526,3 +526,37 @@ nim-live *args:
526 cd dart/frq_core526 cd dart/frq_core
527 dart pub get >/dev/null527 dart pub get >/dev/null
528 exec dart run tool/live_send.dart "$@"528 exec dart run tool/live_send.dart "$@"
529+
530+# The real app, with the Nim core as its transport.
531+#
532+# This is the wiring that matters: `frq.main-nim` is `frq.main` with one line
533+# changed — `frq.net.nim/install!` where it says `frq.net.dart/install!`. Every
534+# screen, every cell and every action is the one that was already there. Nim
535+# owns the socket, the TLS and the line framing, and nothing else.
536+#
537+# Not to be confused with `nim-spike`, which is the earlier experiment where
538+# Nim owned the screens too. That one reimplemented a 1,518-line chat screen in
539+# forty lines and lost everything in between; this one reimplements nothing.
540+#
541+# just nim-app build it
542+# just nim-app run open the window
543+nim-app action="build":
544+ #!/usr/bin/env bash
545+ set -euo pipefail
546+ cd "{{justfile_directory()}}"
547+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
548+ just nim-lib
549+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
550+ --command just nim-app "$@"
551+ fi
552+ cd flutter
553+ flutter pub get
554+ export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
555+ clojure -M:cljd compile frq.main-nim
556+ runner=()
557+ [ -e /run/current-system ] || runner=("$NIXGL")
558+ case "{{action}}" in
559+ build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim_app.dart ;;
560+ run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim_app.dart ;;
561+ *) echo "usage: just nim-app [build|run]" >&2; exit 1 ;;
562+ esac
added nim/src/frq/conn.nim +117 -0
new file mode 100644
@@ -0,0 +1,117 @@
1+## A transport, and nothing above it.
2+##
3+## This is `frq.net`'s three operations — connect, send, close — with a socket
4+## and a TLS context behind them. It knows about lines; it does not know about
5+## IRC, about a state machine, or about a screen.
6+##
7+## That boundary is the whole point of this module existing separately from
8+## `irc.nim`. The spike had Nim owning the state and the screens, which meant
9+## reimplementing screens that already exist and are far better — 1,518 lines
10+## of chat with reactions, replies and images, against forty lines of
11+## facsimile. Wiring Nim in *under* `frq.net` instead leaves every screen, the
12+## cells they read and the actions they call exactly where they are, and
13+## replaces only the part that was always platform code.
14+##
15+## Threading as before: the socket thread shares nothing, and speaks in
16+## channels. See irc.nim's comment for why ORC makes that the sane choice.
17+
18+import std/[net, strutils]
19+import trace
20+
21+type
22+ ConnConfig* = object
23+ host*: string
24+ port*: int
25+ tls*: bool
26+
27+var
28+ inbound: Channel[string]
29+ outbound: Channel[string]
30+ events: Channel[string] ## "open" | "close: reason" | "error: reason"
31+ thread: Thread[ConnConfig]
32+ running: bool
33+
34+inbound.open()
35+outbound.open()
36+events.open()
37+
38+proc readerBody(cfg: ConnConfig) {.thread.} =
39+ {.gcsafe.}:
40+ var sock: Socket
41+ try:
42+ trace("conn", "dialling " & cfg.host & ":" & $cfg.port &
43+ (if cfg.tls: " over TLS" else: " plain"))
44+ sock = newSocket(buffered = true)
45+ if cfg.tls:
46+ # CVerifyPeer: this carries a nick and, once SASL is wired, a token.
47+ let ctx = newContext(verifyMode = CVerifyPeer)
48+ ctx.wrapSocket(sock)
49+ sock.connect(cfg.host, Port(cfg.port))
50+ events.send("open")
51+ trace("conn", "connected")
52+
53+ while running:
54+ # Writes FIRST, and this is not a preference: the client speaks first
55+ # in IRC. Draining after the read deadlocked exactly once and
56+ # completely — CAP/NICK/USER were queued before the socket finished
57+ # connecting, the loop went straight into a read, and the server had
58+ # nothing to say because we had not registered. Both sides waited.
59+ #
60+ # It also bounds write latency by nothing instead of by the read
61+ # timeout, which matters for a keystroke.
62+ while true:
63+ let (ok, pending) = outbound.tryRecv()
64+ if not ok: break
65+ trace("conn.out", pending)
66+ sock.send(pending & "\c\L")
67+
68+ var line: string
69+ var timedOut = false
70+ try:
71+ # A timeout rather than a second thread for the writer: it gives the
72+ # outbound queue a look between lines at the price of one syscall
73+ # every 200ms, and one thread is one thread to shut down cleanly.
74+ line = sock.recvLine(timeout = 200)
75+ except TimeoutError:
76+ timedOut = true
77+ except OSError as e:
78+ events.send("error: " & e.msg)
79+ break
80+
81+ if not timedOut and line.len == 0:
82+ # recvLine answering empty with no timeout is the peer going away.
83+ events.send("close: ")
84+ break
85+
86+ if line.len > 0:
87+ trace("conn.in", line)
88+ inbound.send(line)
89+
90+ except CatchableError as e:
91+ trace("conn", "!! " & e.msg)
92+ events.send("error: " & e.msg)
93+ finally:
94+ if not sock.isNil:
95+ try: sock.close() except CatchableError: discard
96+ trace("conn", "reader done")
97+
98+proc open*(cfg: ConnConfig) =
99+ if running: return
100+ # Drain anything a previous connection left, so a reconnect does not deliver
101+ # the last one's backlog to the new one's callbacks.
102+ while inbound.tryRecv()[0]: discard
103+ while events.tryRecv()[0]: discard
104+ running = true
105+ createThread(thread, readerBody, cfg)
106+
107+proc send*(line: string) =
108+ if running: outbound.send(line)
109+
110+proc close*() =
111+ if not running: return
112+ running = false
113+ joinThread(thread)
114+ trace("conn", "closed")
115+
116+proc tryLine*(): (bool, string) = inbound.tryRecv()
117+proc tryEvent*(): (bool, string) = events.tryRecv()
new file mode 100644
@@ -0,0 +1,117 @@
1+## A transport, and nothing above it.
2+##
3+## This is `frq.net`'s three operations — connect, send, close — with a socket
4+## and a TLS context behind them. It knows about lines; it does not know about
5+## IRC, about a state machine, or about a screen.
6+##
7+## That boundary is the whole point of this module existing separately from
8+## `irc.nim`. The spike had Nim owning the state and the screens, which meant
9+## reimplementing screens that already exist and are far better — 1,518 lines
10+## of chat with reactions, replies and images, against forty lines of
11+## facsimile. Wiring Nim in *under* `frq.net` instead leaves every screen, the
12+## cells they read and the actions they call exactly where they are, and
13+## replaces only the part that was always platform code.
14+##
15+## Threading as before: the socket thread shares nothing, and speaks in
16+## channels. See irc.nim's comment for why ORC makes that the sane choice.
17+
18+import std/[net, strutils]
19+import trace
20+
21+type
22+ ConnConfig* = object
23+ host*: string
24+ port*: int
25+ tls*: bool
26+
27+var
28+ inbound: Channel[string]
29+ outbound: Channel[string]
30+ events: Channel[string] ## "open" | "close: reason" | "error: reason"
31+ thread: Thread[ConnConfig]
32+ running: bool
33+
34+inbound.open()
35+outbound.open()
36+events.open()
37+
38+proc readerBody(cfg: ConnConfig) {.thread.} =
39+ {.gcsafe.}:
40+ var sock: Socket
41+ try:
42+ trace("conn", "dialling " & cfg.host & ":" & $cfg.port &
43+ (if cfg.tls: " over TLS" else: " plain"))
44+ sock = newSocket(buffered = true)
45+ if cfg.tls:
46+ # CVerifyPeer: this carries a nick and, once SASL is wired, a token.
47+ let ctx = newContext(verifyMode = CVerifyPeer)
48+ ctx.wrapSocket(sock)
49+ sock.connect(cfg.host, Port(cfg.port))
50+ events.send("open")
51+ trace("conn", "connected")
52+
53+ while running:
54+ # Writes FIRST, and this is not a preference: the client speaks first
55+ # in IRC. Draining after the read deadlocked exactly once and
56+ # completely — CAP/NICK/USER were queued before the socket finished
57+ # connecting, the loop went straight into a read, and the server had
58+ # nothing to say because we had not registered. Both sides waited.
59+ #
60+ # It also bounds write latency by nothing instead of by the read
61+ # timeout, which matters for a keystroke.
62+ while true:
63+ let (ok, pending) = outbound.tryRecv()
64+ if not ok: break
65+ trace("conn.out", pending)
66+ sock.send(pending & "\c\L")
67+
68+ var line: string
69+ var timedOut = false
70+ try:
71+ # A timeout rather than a second thread for the writer: it gives the
72+ # outbound queue a look between lines at the price of one syscall
73+ # every 200ms, and one thread is one thread to shut down cleanly.
74+ line = sock.recvLine(timeout = 200)
75+ except TimeoutError:
76+ timedOut = true
77+ except OSError as e:
78+ events.send("error: " & e.msg)
79+ break
80+
81+ if not timedOut and line.len == 0:
82+ # recvLine answering empty with no timeout is the peer going away.
83+ events.send("close: ")
84+ break
85+
86+ if line.len > 0:
87+ trace("conn.in", line)
88+ inbound.send(line)
89+
90+ except CatchableError as e:
91+ trace("conn", "!! " & e.msg)
92+ events.send("error: " & e.msg)
93+ finally:
94+ if not sock.isNil:
95+ try: sock.close() except CatchableError: discard
96+ trace("conn", "reader done")
97+
98+proc open*(cfg: ConnConfig) =
99+ if running: return
100+ # Drain anything a previous connection left, so a reconnect does not deliver
101+ # the last one's backlog to the new one's callbacks.
102+ while inbound.tryRecv()[0]: discard
103+ while events.tryRecv()[0]: discard
104+ running = true
105+ createThread(thread, readerBody, cfg)
106+
107+proc send*(line: string) =
108+ if running: outbound.send(line)
109+
110+proc close*() =
111+ if not running: return
112+ running = false
113+ joinThread(thread)
114+ trace("conn", "closed")
115+
116+proc tryLine*(): (bool, string) = inbound.tryRecv()
117+proc tryEvent*(): (bool, string) = events.tryRecv()
modified nim/src/frq_core.nim +39 -0
@@ -22,6 +22,8 @@
2222
2323 import std/json
2424 import frq/[ircparse, ui, state, irc]
25+import frq/conn as tr
26+import frq/trace
2527 import frq/screens/connect as connectScreen
2628 import frq/screens/chat as chatScreen
2729
@@ -157,3 +159,40 @@ proc frq_ui_reset*() {.exportc, dynlib.} =
157159 ## starting point rather than whatever the last run left.
158160 irc.stop()
159161 app = initState()
162+
163+
164+# --------------------------------------------------------------- transport
165+#
166+# `frq.net`'s three operations, for `frq.net.nim` to install. This is the
167+# wiring that matters: the existing ClojureDart screens, cells and actions are
168+# untouched, and only the socket underneath them becomes Nim.
169+#
170+# Polled rather than callback-driven, for the reason the UI is: a Dart callback
171+# invoked from a foreign thread has to be marshalled onto the main isolate, and
172+# a timer on the Dart side does the same job with no mechanism at all.
173+
174+proc frq_trace*(topic, msg: cstring) {.exportc, dynlib.} =
175+ ## Let the Dart side log through the same facility, so one FRQ_TRACE=1 gives
176+ ## one interleaved story instead of two half-ones in different places.
177+ if topic != nil and msg != nil:
178+ trace($topic, $msg)
179+
180+proc frq_conn_open*(host: cstring, port: cint, tls: cint) {.exportc, dynlib.} =
181+ if host == nil: return
182+ tr.open(tr.ConnConfig(host: $host, port: port.int, tls: tls != 0))
183+
184+proc frq_conn_send*(line: cstring) {.exportc, dynlib.} =
185+ if line != nil: tr.send($line)
186+
187+proc frq_conn_close*() {.exportc, dynlib.} =
188+ tr.close()
189+
190+proc frq_conn_recv*(): cstring {.exportc, dynlib.} =
191+ ## The next line, or null when there is none waiting. Never blocks.
192+ let (ok, line) = tr.tryLine()
193+ if ok: dup(line) else: nil
194+
195+proc frq_conn_event*(): cstring {.exportc, dynlib.} =
196+ ## The next transport event — "open", "close: …", "error: …" — or null.
197+ let (ok, e) = tr.tryEvent()
198+ if ok: dup(e) else: nil
@@ -22,6 +22,8 @@
22 22
23 import std/json23 import std/json
24 import frq/[ircparse, ui, state, irc]24 import frq/[ircparse, ui, state, irc]
25+import frq/conn as tr
26+import frq/trace
25 import frq/screens/connect as connectScreen27 import frq/screens/connect as connectScreen
26 import frq/screens/chat as chatScreen28 import frq/screens/chat as chatScreen
27 29
@@ -157,3 +159,40 @@ proc frq_ui_reset*() {.exportc, dynlib.} =
157 ## starting point rather than whatever the last run left.159 ## starting point rather than whatever the last run left.
158 irc.stop()160 irc.stop()
159 app = initState()161 app = initState()
162+
163+
164+# --------------------------------------------------------------- transport
165+#
166+# `frq.net`'s three operations, for `frq.net.nim` to install. This is the
167+# wiring that matters: the existing ClojureDart screens, cells and actions are
168+# untouched, and only the socket underneath them becomes Nim.
169+#
170+# Polled rather than callback-driven, for the reason the UI is: a Dart callback
171+# invoked from a foreign thread has to be marshalled onto the main isolate, and
172+# a timer on the Dart side does the same job with no mechanism at all.
173+
174+proc frq_trace*(topic, msg: cstring) {.exportc, dynlib.} =
175+ ## Let the Dart side log through the same facility, so one FRQ_TRACE=1 gives
176+ ## one interleaved story instead of two half-ones in different places.
177+ if topic != nil and msg != nil:
178+ trace($topic, $msg)
179+
180+proc frq_conn_open*(host: cstring, port: cint, tls: cint) {.exportc, dynlib.} =
181+ if host == nil: return
182+ tr.open(tr.ConnConfig(host: $host, port: port.int, tls: tls != 0))
183+
184+proc frq_conn_send*(line: cstring) {.exportc, dynlib.} =
185+ if line != nil: tr.send($line)
186+
187+proc frq_conn_close*() {.exportc, dynlib.} =
188+ tr.close()
189+
190+proc frq_conn_recv*(): cstring {.exportc, dynlib.} =
191+ ## The next line, or null when there is none waiting. Never blocks.
192+ let (ok, line) = tr.tryLine()
193+ if ok: dup(line) else: nil
194+
195+proc frq_conn_event*(): cstring {.exportc, dynlib.} =
196+ ## The next transport event — "open", "close: …", "error: …" — or null.
197+ let (ok, e) = tr.tryEvent()
198+ if ok: dup(e) else: nil