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>
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) ?? ''; | ||
| 191 | 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 | 251 | // ---------------------------------------------------------------- the UI |
| 195 | 252 | // |
| 196 | 253 | // 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 UI | 251 | // ---------------------------------------------------------------- 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: | ||
| 526 | 526 | cd dart/frq_core |
| 527 | 527 | dart pub get >/dev/null |
| 528 | 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 | |
| @@ -526,3 +526,37 @@ nim-live *args: | |||
| 526 | cd dart/frq_core | 526 | cd dart/frq_core |
| 527 | dart pub get >/dev/null | 527 | 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 @@ | ||
| 22 | 22 | |
| 23 | 23 | import std/json |
| 24 | 24 | import frq/[ircparse, ui, state, irc] |
| 25 | +import frq/conn as tr | |
| 26 | +import frq/trace | |
| 25 | 27 | import frq/screens/connect as connectScreen |
| 26 | 28 | import frq/screens/chat as chatScreen |
| 27 | 29 | |
| @@ -157,3 +159,40 @@ proc frq_ui_reset*() {.exportc, dynlib.} = | ||
| 157 | 159 | ## starting point rather than whatever the last run left. |
| 158 | 160 | irc.stop() |
| 159 | 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 | |
| @@ -22,6 +22,8 @@ | |||
| 22 | 22 | ||
| 23 | import std/json | 23 | 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 connectScreen | 27 | import frq/screens/connect as connectScreen |
| 26 | import frq/screens/chat as chatScreen | 28 | 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 | ||