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

Ask for the backlog, and survive hanging up

Two bugs on the same connection.

The history: this client never sent CHATHISTORY. freeq re-joins an
authenticated user's channels at registration and leaves the backlog for
the client to ask for, so a signed-in connection came back to rooms
showing new lines and nothing else. Nothing pointed at it because the
only comments about CHATHISTORY are in `rooms` and `reactions`,
describing a replay that had never been ported — the request went out of
`main.cljd`, which is not a file anything was transcribed from.

It goes out at 366, end of NAMES, which is the one moment a room is
known to have fully arrived. Only where there is no conversation yet:
system lines do not count, and getting that wrong made the first version
of this do nothing at all, since joining a room writes "alice joined
#freeq" into the buffer before 366 lands.

The crash: `SIGSEGV: Illegal storage access. (Attempt to read from
nil?)`, on disconnect. `close` ran on the caller's thread and called
`Socket.close()` — SSL_shutdown and SSL_free — on the handle the reader
thread was inside SSL_read on. A use-after-free in `uniRecv`, and it
took the app with it. `shutdown(2)` on the descriptor instead: it frees
nothing and owns nothing, it just makes the blocked read return, and the
reader closes the socket in its own `finally` as the only thread that
may.

Both of these lived where nothing could see them. The reducer's answer
to an incoming line was the largest untested thing in the program,
reachable only through a real socket. `conn.feed` puts a line in as
though a server had sent it and `conn.tryOutbound` reads what went out,
so the conversation is checkable from both ends without one — and `send`
now queues when there is no connection rather than dropping the line,
with `open` draining what was left over, because a line written to
nowhere should still be readable by the thing testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T09:49:15-07:00 Browse files
6214c7e parent: f912073
modified nim/src/frq/conn.nim +37 -3
@@ -16,6 +16,7 @@
1616 ## channels. See irc.nim's comment for why ORC makes that the sane choice.
1717
1818 import std/net
19+import std/posix as p ## for `shutdown(2)`; see `close`
1920 import frq/[trace]
2021
2122 type
@@ -115,18 +116,51 @@ proc open*(cfg: ConnConfig) =
115116 # the last one's backlog to the new one's callbacks.
116117 while inbound.tryRecv()[0]: discard
117118 while events.tryRecv()[0]: discard
119+ # Including anything written while there was nowhere to write it: a line
120+ # queued before this connection existed was meant for the last one.
121+ while outbound.tryRecv()[0]: discard
118122 running = true
119123 createThread(reader, readerBody, cfg)
120124
121125 proc send*(line: string) =
122- if running: outbound.send(line)
126+ ## Queued whether or not there is a connection.
127+ ##
128+ ## It used to drop the line when there was none, which made the whole of
129+ ## what this client says to a server unobservable from a test — nothing
130+ ## could ask "and what did it send?" without opening a socket. `open` drains
131+ ## the queue, so a line written while disconnected is never delivered to the
132+ ## next connection; it is simply readable in between.
133+ outbound.send(line)
134+
135+proc tryOutbound*(): (bool, string) = outbound.tryRecv()
136+ ## What is queued to go out, for a test that has no socket. The writer
137+ ## thread takes these when there is one.
138+
139+proc feed*(line: string) =
140+ ## Put a line in as though the server had sent it.
141+ ##
142+ ## The other half of `tryOutbound`, and the reason both exist: the reducer's
143+ ## answer to an incoming line was the largest untested thing in this program
144+ ## — reachable only through a real socket — and that is where a missing
145+ ## CHATHISTORY request sat unnoticed through the whole port. Together they
146+ ## make the conversation checkable from both ends without one.
147+ inbound.send(line)
123148
124149 proc close*() =
125150 if not running: return
126151 running = false
127- # The reader is blocked in recvLine; closing the socket is what wakes it.
152+ # The reader is blocked in recvLine, and waking it is this thread's job —
153+ # but closing the socket is NOT. `Socket.close()` on a TLS socket calls
154+ # SSL_shutdown and SSL_free, and the reader is inside SSL_read on that very
155+ # handle: freeing it here is a use-after-free in `uniRecv`, which arrives as
156+ # `SIGSEGV: Illegal storage access. (Attempt to read from nil?)` and takes
157+ # the whole app with it. Every disconnect had a chance of it.
158+ #
159+ # `shutdown(2)` on the descriptor instead. It frees nothing and owns
160+ # nothing; it makes the blocked read return, and the reader then closes the
161+ # socket in its own `finally` — the one thread that may.
128162 if not shared.isNil:
129- try: shared.close() except CatchableError: discard
163+ discard p.shutdown(shared.getFd(), SHUT_RDWR)
130164 outbound.send("")
131165 joinThread(reader)
132166 trace("conn", "closed")
@@ -16,6 +16,7 @@
16 ## channels. See irc.nim's comment for why ORC makes that the sane choice.16 ## channels. See irc.nim's comment for why ORC makes that the sane choice.
17 17
18 import std/net18 import std/net
19+import std/posix as p ## for `shutdown(2)`; see `close`
19 import frq/[trace]20 import frq/[trace]
20 21
21 type22 type
@@ -115,18 +116,51 @@ proc open*(cfg: ConnConfig) =
115 # the last one's backlog to the new one's callbacks.116 # the last one's backlog to the new one's callbacks.
116 while inbound.tryRecv()[0]: discard117 while inbound.tryRecv()[0]: discard
117 while events.tryRecv()[0]: discard118 while events.tryRecv()[0]: discard
119+ # Including anything written while there was nowhere to write it: a line
120+ # queued before this connection existed was meant for the last one.
121+ while outbound.tryRecv()[0]: discard
118 running = true122 running = true
119 createThread(reader, readerBody, cfg)123 createThread(reader, readerBody, cfg)
120 124
121 proc send*(line: string) =125 proc send*(line: string) =
122- if running: outbound.send(line)126+ ## Queued whether or not there is a connection.
127+ ##
128+ ## It used to drop the line when there was none, which made the whole of
129+ ## what this client says to a server unobservable from a test — nothing
130+ ## could ask "and what did it send?" without opening a socket. `open` drains
131+ ## the queue, so a line written while disconnected is never delivered to the
132+ ## next connection; it is simply readable in between.
133+ outbound.send(line)
134+
135+proc tryOutbound*(): (bool, string) = outbound.tryRecv()
136+ ## What is queued to go out, for a test that has no socket. The writer
137+ ## thread takes these when there is one.
138+
139+proc feed*(line: string) =
140+ ## Put a line in as though the server had sent it.
141+ ##
142+ ## The other half of `tryOutbound`, and the reason both exist: the reducer's
143+ ## answer to an incoming line was the largest untested thing in this program
144+ ## — reachable only through a real socket — and that is where a missing
145+ ## CHATHISTORY request sat unnoticed through the whole port. Together they
146+ ## make the conversation checkable from both ends without one.
147+ inbound.send(line)
123 148
124 proc close*() =149 proc close*() =
125 if not running: return150 if not running: return
126 running = false151 running = false
127- # The reader is blocked in recvLine; closing the socket is what wakes it.152+ # The reader is blocked in recvLine, and waking it is this thread's job —
153+ # but closing the socket is NOT. `Socket.close()` on a TLS socket calls
154+ # SSL_shutdown and SSL_free, and the reader is inside SSL_read on that very
155+ # handle: freeing it here is a use-after-free in `uniRecv`, which arrives as
156+ # `SIGSEGV: Illegal storage access. (Attempt to read from nil?)` and takes
157+ # the whole app with it. Every disconnect had a chance of it.
158+ #
159+ # `shutdown(2)` on the descriptor instead. It frees nothing and owns
160+ # nothing; it makes the blocked read return, and the reader then closes the
161+ # socket in its own `finally` — the one thread that may.
128 if not shared.isNil:162 if not shared.isNil:
129- try: shared.close() except CatchableError: discard163+ discard p.shutdown(shared.getFd(), SHUT_RDWR)
130 outbound.send("")164 outbound.send("")
131 joinThread(reader)165 joinThread(reader)
132 trace("conn", "closed")166 trace("conn", "closed")
modified nim/src/frq/reducer.nim +24 -1
@@ -11,7 +11,7 @@
1111 ## payload: the alternative is a second serialisation to define and version,
1212 ## for arguments that are always one string.
1313
14-import std/[json, options, strutils, tables]
14+import std/[json, options, sequtils, strutils, tables]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
1717 atproto, handshake, textruns, members, msgsig, profile, store]
@@ -35,6 +35,9 @@ proc setError(msg: string) =
3535 app.error = msg
3636 app.hasError = true
3737
38+const historyLimit = 100
39+ ## How many lines of backlog to ask a room for.
40+
3841 proc rememberRooms(force = false)
3942 ## Declared here because `openRoom` is above it and calls it — the file is
4043 ## ordered by what the reader does, not by what calls what.
@@ -718,6 +721,26 @@ proc drain*() =
718721 r.users = r.namesAcc
719722 r.namesAcc.clear()
720723 app.rooms[room] = r
724+ # And the only moment this client knows a room has fully arrived.
725+ #
726+ # freeq re-joins an authenticated user's channels at registration
727+ # and leaves the backlog for the client to ask for, so a room that
728+ # reaches here with an empty buffer has no history coming unless we
729+ # ask — which is why nothing but new lines ever appeared. It shows
730+ # up worst on a signed-in connection, which is the one that gets
731+ # re-joined into rooms it never sent a JOIN for.
732+ #
733+ # Only where there is no conversation yet: the replayed lines come
734+ # back as ordinary PRIVMSGs, and asking again for a room that
735+ # already has its history is a second copy of it crossing the wire
736+ # to be discarded by the marker.
737+ #
738+ # System lines do not count, and getting that wrong is what made
739+ # the first version of this do nothing at all: joining a room puts
740+ # "alice joined #freeq" in the buffer before 366 arrives, so a test
741+ # for an empty one is a test that never passes.
742+ if not r.messages.anyIt(not it.system):
743+ send("CHATHISTORY LATEST " & room & " * " & $historyLimit)
721744
722745 of "MODE":
723746 # A channel MODE, for the letters that change how someone is listed.
@@ -11,7 +11,7 @@
11 ## payload: the alternative is a second serialisation to define and version,11 ## payload: the alternative is a second serialisation to define and version,
12 ## for arguments that are always one string.12 ## for arguments that are always one string.
13 13
14-import std/[json, options, strutils, tables]14+import std/[json, options, sequtils, strutils, tables]
15 import std/sets15 import std/sets
16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17 atproto, handshake, textruns, members, msgsig, profile, store]17 atproto, handshake, textruns, members, msgsig, profile, store]
@@ -35,6 +35,9 @@ proc setError(msg: string) =
35 app.error = msg35 app.error = msg
36 app.hasError = true36 app.hasError = true
37 37
38+const historyLimit = 100
39+ ## How many lines of backlog to ask a room for.
40+
38 proc rememberRooms(force = false)41 proc rememberRooms(force = false)
39 ## Declared here because `openRoom` is above it and calls it — the file is42 ## Declared here because `openRoom` is above it and calls it — the file is
40 ## ordered by what the reader does, not by what calls what.43 ## ordered by what the reader does, not by what calls what.
@@ -718,6 +721,26 @@ proc drain*() =
718 r.users = r.namesAcc721 r.users = r.namesAcc
719 r.namesAcc.clear()722 r.namesAcc.clear()
720 app.rooms[room] = r723 app.rooms[room] = r
724+ # And the only moment this client knows a room has fully arrived.
725+ #
726+ # freeq re-joins an authenticated user's channels at registration
727+ # and leaves the backlog for the client to ask for, so a room that
728+ # reaches here with an empty buffer has no history coming unless we
729+ # ask — which is why nothing but new lines ever appeared. It shows
730+ # up worst on a signed-in connection, which is the one that gets
731+ # re-joined into rooms it never sent a JOIN for.
732+ #
733+ # Only where there is no conversation yet: the replayed lines come
734+ # back as ordinary PRIVMSGs, and asking again for a room that
735+ # already has its history is a second copy of it crossing the wire
736+ # to be discarded by the marker.
737+ #
738+ # System lines do not count, and getting that wrong is what made
739+ # the first version of this do nothing at all: joining a room puts
740+ # "alice joined #freeq" in the buffer before 366 arrives, so a test
741+ # for an empty one is a test that never passes.
742+ if not r.messages.anyIt(not it.system):
743+ send("CHATHISTORY LATEST " & room & " * " & $historyLimit)
721 744
722 of "MODE":745 of "MODE":
723 # A channel MODE, for the letters that change how someone is listed.746 # A channel MODE, for the letters that change how someone is listed.
added nim/tests/tsession.nim +106 -0
new file mode 100644
@@ -0,0 +1,106 @@
1+## What this client says back to a server, given what a server says to it.
2+##
3+## The reducer's answer to an incoming line was the largest untested thing in
4+## the program: reachable only through a real socket, so nothing checked it.
5+## That is where the missing CHATHISTORY request sat through the whole port —
6+## every room came back with no history and the only comment about it was in
7+## another module, describing behaviour that had never been ported.
8+##
9+## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`
10+## reads what went out. No socket at either end.
11+
12+import std/[sequtils, strutils, tables, unittest]
13+import frq/[cells, model, reducer, rooms]
14+import frq/conn as tr
15+
16+proc sent(): seq[string] =
17+ ## Everything queued to go out, drained.
18+ while true:
19+ let (ok, line) = tr.tryOutbound()
20+ if not ok: break
21+ result.add line
22+
23+proc say(lines: varargs[string]) =
24+ for l in lines: tr.feed(l)
25+ drain()
26+
27+proc reset() =
28+ app = initState()
29+ app.formNick = "alice"
30+ discard sent()
31+
32+proc joined(room: string) =
33+ ## The server putting us in a room, which is how every one of them starts —
34+ ## a JOIN we sent, or one freeq made for a signed-in account at
35+ ## registration. A 366 for a room this client has never heard of is not
36+ ## ours and is left alone.
37+ say(":alice!a@h JOIN " & room)
38+ discard sent()
39+
40+suite "asking for the backlog":
41+ setup: reset()
42+
43+ test "end of NAMES in an empty room asks for history":
44+ joined("#freeq")
45+ # freeq re-joins an authenticated user's channels at registration and
46+ # leaves the backlog for the client to ask for. A room that arrives this
47+ # way has no history coming unless we ask, which is why a signed-in
48+ # connection showed new lines and nothing else.
49+ say(":server 366 alice #freeq :End of /NAMES list")
50+ check app.rooms.hasKey("#freeq")
51+ check sent().anyIt(it.startsWith("CHATHISTORY LATEST #freeq * "))
52+
53+ test "and asks for a hundred lines of it":
54+ joined("#freeq")
55+ say(":server 366 alice #freeq :End of /NAMES list")
56+ check "CHATHISTORY LATEST #freeq * 100" in sent()
57+
58+ test "a room that already has lines is not asked twice":
59+ # The replay comes back as ordinary PRIVMSGs; asking again is a second
60+ # copy of the same history crossing the wire to be thrown away.
61+ joined("#freeq")
62+ say(":bob!b@h PRIVMSG #freeq :already here")
63+ discard sent()
64+ say(":server 366 alice #freeq :End of /NAMES list")
65+ check not sent().anyIt(it.startsWith("CHATHISTORY"))
66+
67+ test "a room this client was never put in is not asked about":
68+ # Not a room of ours: 366 for it arrives before any JOIN, and answering
69+ # it would ask a server for the history of somewhere we are not.
70+ say(":server 366 alice #nowhere :End of /NAMES list")
71+ check not app.rooms.hasKey("#nowhere")
72+ check not sent().anyIt(it.startsWith("CHATHISTORY"))
73+
74+suite "the rest of the conversation":
75+ setup: reset()
76+
77+ test "a PING is answered with its own token":
78+ say("PING :abc123")
79+ check "PONG :abc123" in sent()
80+
81+ test "our own JOIN marks the room joined; somebody else's adds a name":
82+ say(":alice!a@h JOIN #freeq")
83+ check app.rooms["#freeq"].joined
84+ say(":bob!b@h JOIN #freeq")
85+ check app.rooms["#freeq"].users.hasKey("bob")
86+
87+ test "NAMES arrives over several lines and lands in one go":
88+ joined("#freeq")
89+ say(":server 353 alice = #freeq :alice @bob",
90+ ":server 353 alice = #freeq :carol")
91+ # Still pending: replacing the list per line empties the panel and
92+ # refills it a name at a time.
93+ check app.rooms["#freeq"].users.len == 0
94+ say(":server 366 alice #freeq :End of /NAMES list")
95+ check app.rooms["#freeq"].users.len == 3
96+
97+ test "a replayed line lands in the room it names":
98+ joined("#freeq")
99+ say(":server 366 alice #freeq :End of /NAMES list",
100+ ":bob!b@h PRIVMSG #freeq :an old line")
101+ check app.rooms["#freeq"].messages.anyIt(it.text == "an old line")
102+
103+ test "a message to us is filed under whoever sent it":
104+ say(":bob!b@h PRIVMSG alice :a direct word")
105+ check app.rooms.hasKey("bob")
106+ check app.rooms["bob"].messages[^1].text == "a direct word"
new file mode 100644
@@ -0,0 +1,106 @@
1+## What this client says back to a server, given what a server says to it.
2+##
3+## The reducer's answer to an incoming line was the largest untested thing in
4+## the program: reachable only through a real socket, so nothing checked it.
5+## That is where the missing CHATHISTORY request sat through the whole port —
6+## every room came back with no history and the only comment about it was in
7+## another module, describing behaviour that had never been ported.
8+##
9+## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`
10+## reads what went out. No socket at either end.
11+
12+import std/[sequtils, strutils, tables, unittest]
13+import frq/[cells, model, reducer, rooms]
14+import frq/conn as tr
15+
16+proc sent(): seq[string] =
17+ ## Everything queued to go out, drained.
18+ while true:
19+ let (ok, line) = tr.tryOutbound()
20+ if not ok: break
21+ result.add line
22+
23+proc say(lines: varargs[string]) =
24+ for l in lines: tr.feed(l)
25+ drain()
26+
27+proc reset() =
28+ app = initState()
29+ app.formNick = "alice"
30+ discard sent()
31+
32+proc joined(room: string) =
33+ ## The server putting us in a room, which is how every one of them starts —
34+ ## a JOIN we sent, or one freeq made for a signed-in account at
35+ ## registration. A 366 for a room this client has never heard of is not
36+ ## ours and is left alone.
37+ say(":alice!a@h JOIN " & room)
38+ discard sent()
39+
40+suite "asking for the backlog":
41+ setup: reset()
42+
43+ test "end of NAMES in an empty room asks for history":
44+ joined("#freeq")
45+ # freeq re-joins an authenticated user's channels at registration and
46+ # leaves the backlog for the client to ask for. A room that arrives this
47+ # way has no history coming unless we ask, which is why a signed-in
48+ # connection showed new lines and nothing else.
49+ say(":server 366 alice #freeq :End of /NAMES list")
50+ check app.rooms.hasKey("#freeq")
51+ check sent().anyIt(it.startsWith("CHATHISTORY LATEST #freeq * "))
52+
53+ test "and asks for a hundred lines of it":
54+ joined("#freeq")
55+ say(":server 366 alice #freeq :End of /NAMES list")
56+ check "CHATHISTORY LATEST #freeq * 100" in sent()
57+
58+ test "a room that already has lines is not asked twice":
59+ # The replay comes back as ordinary PRIVMSGs; asking again is a second
60+ # copy of the same history crossing the wire to be thrown away.
61+ joined("#freeq")
62+ say(":bob!b@h PRIVMSG #freeq :already here")
63+ discard sent()
64+ say(":server 366 alice #freeq :End of /NAMES list")
65+ check not sent().anyIt(it.startsWith("CHATHISTORY"))
66+
67+ test "a room this client was never put in is not asked about":
68+ # Not a room of ours: 366 for it arrives before any JOIN, and answering
69+ # it would ask a server for the history of somewhere we are not.
70+ say(":server 366 alice #nowhere :End of /NAMES list")
71+ check not app.rooms.hasKey("#nowhere")
72+ check not sent().anyIt(it.startsWith("CHATHISTORY"))
73+
74+suite "the rest of the conversation":
75+ setup: reset()
76+
77+ test "a PING is answered with its own token":
78+ say("PING :abc123")
79+ check "PONG :abc123" in sent()
80+
81+ test "our own JOIN marks the room joined; somebody else's adds a name":
82+ say(":alice!a@h JOIN #freeq")
83+ check app.rooms["#freeq"].joined
84+ say(":bob!b@h JOIN #freeq")
85+ check app.rooms["#freeq"].users.hasKey("bob")
86+
87+ test "NAMES arrives over several lines and lands in one go":
88+ joined("#freeq")
89+ say(":server 353 alice = #freeq :alice @bob",
90+ ":server 353 alice = #freeq :carol")
91+ # Still pending: replacing the list per line empties the panel and
92+ # refills it a name at a time.
93+ check app.rooms["#freeq"].users.len == 0
94+ say(":server 366 alice #freeq :End of /NAMES list")
95+ check app.rooms["#freeq"].users.len == 3
96+
97+ test "a replayed line lands in the room it names":
98+ joined("#freeq")
99+ say(":server 366 alice #freeq :End of /NAMES list",
100+ ":bob!b@h PRIVMSG #freeq :an old line")
101+ check app.rooms["#freeq"].messages.anyIt(it.text == "an old line")
102+
103+ test "a message to us is filed under whoever sent it":
104+ say(":bob!b@h PRIVMSG alice :a direct word")
105+ check app.rooms.hasKey("bob")
106+ check app.rooms["bob"].messages[^1].text == "a direct word"