1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
## The IRC connection: a socket on its own thread, and two queues.
##
## The threading model is the whole design, and it is chosen to avoid a
## problem rather than to be clever. Nim's ORC is thread-local for ref types,
## so sharing the `State` record between a reader thread and the UI thread
## would mean a lock around every field and a heap two threads both collect.
## Instead **nothing is shared**: the socket thread owns the socket and speaks
## only in channels, and the state stays where it always was, on whichever
## thread called in from Dart.
##
## reader thread ──lines──▶ inbound ──▶ drain() on the UI thread
## UI thread ──lines──▶ outbound ──▶ writer, on the socket thread
##
## `drain` is called from `frq_ui_render`, so the tree Dart gets is always
## built after every line that had arrived when it asked. Dart polls; there is
## no callback into Dart and deliberately so — a Dart callback invoked from a
## foreign thread has to be marshalled onto the main isolate, which is a whole
## mechanism (`NativeCallable`, ports) for something a 100ms timer does for
## free at this size.
import std/[net, strutils]
import trace
type
ConnConfig* = object
host*: string
port*: int
tls*: bool
nick*: string
Status* = enum
stIdle, stConnecting, stRegistered, stFailed, stClosed
var
inbound: Channel[string] ## raw lines from the server
outbound: Channel[string] ## raw lines to the server
statusChan: Channel[string] ## "connecting"/"registered"/"failed: …"/"closed"
thread: Thread[ConnConfig]
running: bool
inbound.open()
outbound.open()
statusChan.open()
proc send*(line: string) =
## Queue a line for the server. Safe from the UI thread.
trace("irc.out", line)
outbound.send(line)
proc tryRecvLine*(): (bool, string) = inbound.tryRecv()
proc tryRecvStatus*(): (bool, string) = statusChan.tryRecv()
proc readerBody(cfg: ConnConfig) {.thread.} =
## The socket, end to end. Every failure answers with a status rather than
## an exception: this thread has nobody to throw to.
{.gcsafe.}:
var sock: Socket
try:
statusChan.send("connecting")
trace("irc", "dialling " & cfg.host & ":" & $cfg.port &
(if cfg.tls: " over TLS" else: " plain"))
sock = newSocket(buffered = true)
if cfg.tls:
# CVerifyPeer, not CVerifyNone: this carries a nick and, later, a
# token. Nim loads libssl by soname at run time, so a bundle that
# cannot find one fails here rather than at build.
let ctx = newContext(verifyMode = CVerifyPeer)
ctx.wrapSocket(sock)
sock.connect(cfg.host, Port(cfg.port))
trace("irc", "connected")
# Registration. No CAP and no SASL in the spike — a guest connect is
# NICK and USER, which is the whole of what freeq needs to let one in.
sock.send("NICK " & cfg.nick & "\c\L")
sock.send("USER " & cfg.nick & " 0 * :" & cfg.nick & "\c\L")
trace("irc.out", "NICK/USER as " & cfg.nick)
# Non-blocking-ish loop: recvLine with a timeout so the outbound queue
# gets a look in between lines. A dedicated writer thread would avoid
# the timeout, at the price of a second thread to shut down cleanly.
while running:
var line: string
var timedOut = false
try:
line = sock.recvLine(timeout = 200)
except TimeoutError:
timedOut = true
except OSError as e:
statusChan.send("failed: " & e.msg)
break
if line == "" and not timedOut:
# recvLine answering with an empty string and no timeout is the
# server having gone away. A timeout answers the same way, which is
# why the two are told apart by the flag rather than by the string.
statusChan.send("closed")
break
if line.len > 0:
trace("irc.in", line)
# PING is answered here rather than in the reducer: it is the
# transport's own housekeeping and the screen has no opinion on it.
if line.startsWith("PING"):
let token = if ' ' in line: line[line.find(' ') + 1 .. ^1] else: ""
sock.send("PONG " & token & "\c\L")
trace("irc.out", "PONG " & token)
else:
inbound.send(line)
while true:
let (ok, pending) = outbound.tryRecv()
if not ok: break
sock.send(pending & "\c\L")
except CatchableError as e:
trace("irc", "!! " & e.msg)
statusChan.send("failed: " & e.msg)
finally:
if not sock.isNil:
try: sock.close() except CatchableError: discard
trace("irc", "reader thread done")
proc startReal(cfg: ConnConfig) {.nimcall, gcsafe.} =
if running: return
running = true
{.cast(gcsafe).}:
createThread(thread, readerBody, cfg)
var connector*: proc(cfg: ConnConfig) {.nimcall, gcsafe.} = startReal
## How a connection gets opened, as a variable so a test can replace it.
##
## Without this the reducer's tests open real sockets to irc.freeq.at —
## which they did, and which is why this exists: a unit test for "Connect
## sets connecting" should not need a network, a DNS server or a running
## freeq. `tests/tui.nim` swaps in a stub that records the config instead.
proc goOffline*() =
## Replace the dialler with one that records and does nothing.
##
## For the widget tests, which build the real screens and tap the real
## Connect button — and which, without this, opened a TLS connection to
## irc.freeq.at from a unit-test runner. A test suite that needs a network
## is a test suite that fails on a train.
connector = proc(cfg: ConnConfig) {.nimcall, gcsafe.} =
trace("irc", "offline: would have dialled " & cfg.host & ":" & $cfg.port)
proc start*(cfg: ConnConfig) =
## Open a connection. A second call while one is running is ignored.
trace("irc", "start " & cfg.host & ":" & $cfg.port)
connector(cfg)
proc stop*() =
if not running: return
running = false
# Up to the recvLine timeout plus a moment; joining rather than detaching so
# the socket is shut before anything tries to open another.
joinThread(thread)
trace("irc", "stopped")
|