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

Connect through libmoq_ffi, and read back what the far side raised

The first thing in frq.moq.* that does transport rather than describe it.
frq.moq.client is a MoqClient, a connect, and the session it answers; two
rules of UniFFI's object model shape all of it, and both were confirmed
against the generated bindings rather than assumed.

A method consumes a CLONE of the handle -- UniFFI's own bindings clone before
every call and let the callee own that clone. Passing the handle itself hands
ownership away and leaves the next call reading a freed object, which surfaces
in an allocator minutes later rather than at the call that caused it.

A connect is a future, so it is polled rather than awaited: connect! starts
one and poll-connect! answers nil until the far side settles. glimmer owns the
loop thread, and a blocking connect on it is a frozen window for as long as a
QUIC handshake takes -- thirty seconds, when the relay is not there. That is
the shape frq.av/pump! already has, kept on purpose.

Errors now decode instead of being reported as bytes. A lowered MoqError is a
variant index and a message, and UniFFI serialises BIG-ENDIAN -- which is not
this machine's order and does not fail loudly if you forget: a native read of
variant 1 is 16777216, no variant at all, and the message after it comes out
at the wrong offset.

frq.moq.smoke covers it end to end, and the connect half aims at a closed port
on purpose: what is under test is the future protocol and the error path,
which are the same whether the far side refuses or is simply absent, and a
real relay would make the test depend on somebody else's uptime. It costs
thirty seconds because QUIC is over UDP and a closed port produces no
connection-refused to notice -- there is only the handshake timeout. A
deadline under that reports a continuation that never fired when nothing has
gone wrong yet, which is exactly what it did before the number moved.

The three the runtime caught, none of them visible by reading: ffi/place takes
a layout and a path and resolves a member's TYPE, so freeing a by-value
aggregate needs the field's address instead; moqsession_cancel takes a u32
error code, and shutdown is cancel(0); and the deadline above.

Verified against the generated Python bindings driving the same object:
:connect / "connect: connect timed out after 30s", both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-09T23:49:23-07:00 Browse files
9e3ecaa parent: 9f081a1
added src/frq/moq/client.clj +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+(ns frq.moq.client
2+ "A MoQ client and the session a connect answers.
3+
4+ This is the first thing in `frq.moq.*` that does transport rather than
5+ describe it, and two rules from UniFFI's object model shape all of it.
6+
7+ **A method consumes a clone of the handle.** UniFFI's generated bindings
8+ clone before every call and let the callee own that clone — so `call` here
9+ does the same. Passing the handle itself would hand ownership away and leave
10+ the next call reading a freed object, which is the kind of bug that surfaces
11+ minutes later in an allocator rather than at the call that caused it.
12+
13+ **A connect is a future, not a call.** `connect!` starts one and answers
14+ immediately; `poll-connect!` is what a caller on the loop thread asks, and
15+ it answers nil until the far side is ready. That is the same shape
16+ `frq.av/pump!` already has, and it is deliberate — glimmer runs the loop
17+ thread, and a blocking connect on it is a frozen window for as long as a
18+ QUIC handshake takes, or for the full timeout when a relay is unreachable."
19+ (:require [frq.moq.uniffi :as uniffi]
20+ [frq.moq.raw :as raw]
21+ [jolt.ffi :as ffi]))
22+
23+;; --- handles -----------------------------------------------------------------
24+
25+(defn- clone-client [h] (uniffi/with-out-status #(raw/clone-moqclient h %)))
26+(defn- clone-session [h] (uniffi/with-out-status #(raw/clone-moqsession h %)))
27+
28+(defn free-client!
29+ "Release a client handle. The sessions it opened outlive it."
30+ [h]
31+ (uniffi/with-out-status #(raw/free-moqclient h %))
32+ nil)
33+
34+(defn free-session!
35+ "Release a session handle.
36+
37+ Not the same as closing the session — `shutdown!` ends the conversation,
38+ this only drops our reference to it."
39+ [h]
40+ (uniffi/with-out-status #(raw/free-moqsession h %))
41+ nil)
42+
43+;; --- the client --------------------------------------------------------------
44+
45+(defn new-client
46+ "A MoqClient with the object's own defaults: binds `[::]:0`, verifies against
47+ the system roots."
48+ []
49+ (uniffi/with-out-status #(raw/constructor-moqclient-new %)))
50+
51+(defn set-bind!
52+ "Set the local UDP bind address, e.g. \"0.0.0.0:0\"."
53+ [client addr]
54+ (ffi/with-arena [a]
55+ (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
56+ (uniffi/lower-string buf addr)
57+ (uniffi/with-out-status
58+ #(raw/method-moqclient-set-bind (clone-client client) buf %))))
59+ nil)
60+
61+(defn connect!
62+ "Begin connecting to `url`; answers a future to poll, not a session.
63+
64+ The lowered URL is NOT freed here. UniFFI's convention is that a lowered
65+ argument is handed over with its ownership — the callee frees it — so
66+ freeing it on this side would be the second free.
67+
68+ The future is a :u64 one because a MoqSession crosses as a handle, and a
69+ handle is a u64 whatever it points at."
70+ [client url]
71+ (ffi/with-arena [a]
72+ (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
73+ (uniffi/lower-string buf url)
74+ (-> (raw/method-moqclient-connect (clone-client client) buf)
75+ (uniffi/start-future :u64)))))
76+
77+(defn poll-connect!
78+ "Answer the session handle once the connect has settled, or nil while it has
79+ not. Raises what the far side raised if the connect failed.
80+
81+ Safe to call from the loop thread as often as a timer fires: when the future
82+ is not ready this issues another poll and returns, and nothing here blocks."
83+ [fut]
84+ (when (uniffi/settled? fut)
85+ (uniffi/complete! fut)))
86+
87+;; --- the session -------------------------------------------------------------
88+
89+(defn shutdown!
90+ "Graceful shutdown — equivalent to `(cancel! session 0)`.
91+
92+ Named as upstream names it: UniFFI's Kotlin generator already emits a
93+ `close()` that releases the FFI handle, so `close` would mean two different
94+ things depending on which side of the binding you were reading."
95+ [session]
96+ (uniffi/with-out-status
97+ #(raw/method-moqsession-shutdown (clone-session session) %))
98+ nil)
99+
100+(defn cancel!
101+ "Close the session with an error code.
102+
103+ Code 0 is \"no error\", which is what `shutdown!` sends — upstream documents
104+ it that way so a caller ending a call normally does not have to invent one."
105+ ([session] (cancel! session 0))
106+ ([session code]
107+ (uniffi/with-out-status
108+ #(raw/method-moqsession-cancel (clone-session session) code %))
109+ nil))
new file mode 100644
@@ -0,0 +1,109 @@
1+(ns frq.moq.client
2+ "A MoQ client and the session a connect answers.
3+
4+ This is the first thing in `frq.moq.*` that does transport rather than
5+ describe it, and two rules from UniFFI's object model shape all of it.
6+
7+ **A method consumes a clone of the handle.** UniFFI's generated bindings
8+ clone before every call and let the callee own that clone — so `call` here
9+ does the same. Passing the handle itself would hand ownership away and leave
10+ the next call reading a freed object, which is the kind of bug that surfaces
11+ minutes later in an allocator rather than at the call that caused it.
12+
13+ **A connect is a future, not a call.** `connect!` starts one and answers
14+ immediately; `poll-connect!` is what a caller on the loop thread asks, and
15+ it answers nil until the far side is ready. That is the same shape
16+ `frq.av/pump!` already has, and it is deliberate — glimmer runs the loop
17+ thread, and a blocking connect on it is a frozen window for as long as a
18+ QUIC handshake takes, or for the full timeout when a relay is unreachable."
19+ (:require [frq.moq.uniffi :as uniffi]
20+ [frq.moq.raw :as raw]
21+ [jolt.ffi :as ffi]))
22+
23+;; --- handles -----------------------------------------------------------------
24+
25+(defn- clone-client [h] (uniffi/with-out-status #(raw/clone-moqclient h %)))
26+(defn- clone-session [h] (uniffi/with-out-status #(raw/clone-moqsession h %)))
27+
28+(defn free-client!
29+ "Release a client handle. The sessions it opened outlive it."
30+ [h]
31+ (uniffi/with-out-status #(raw/free-moqclient h %))
32+ nil)
33+
34+(defn free-session!
35+ "Release a session handle.
36+
37+ Not the same as closing the session — `shutdown!` ends the conversation,
38+ this only drops our reference to it."
39+ [h]
40+ (uniffi/with-out-status #(raw/free-moqsession h %))
41+ nil)
42+
43+;; --- the client --------------------------------------------------------------
44+
45+(defn new-client
46+ "A MoqClient with the object's own defaults: binds `[::]:0`, verifies against
47+ the system roots."
48+ []
49+ (uniffi/with-out-status #(raw/constructor-moqclient-new %)))
50+
51+(defn set-bind!
52+ "Set the local UDP bind address, e.g. \"0.0.0.0:0\"."
53+ [client addr]
54+ (ffi/with-arena [a]
55+ (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
56+ (uniffi/lower-string buf addr)
57+ (uniffi/with-out-status
58+ #(raw/method-moqclient-set-bind (clone-client client) buf %))))
59+ nil)
60+
61+(defn connect!
62+ "Begin connecting to `url`; answers a future to poll, not a session.
63+
64+ The lowered URL is NOT freed here. UniFFI's convention is that a lowered
65+ argument is handed over with its ownership — the callee frees it — so
66+ freeing it on this side would be the second free.
67+
68+ The future is a :u64 one because a MoqSession crosses as a handle, and a
69+ handle is a u64 whatever it points at."
70+ [client url]
71+ (ffi/with-arena [a]
72+ (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
73+ (uniffi/lower-string buf url)
74+ (-> (raw/method-moqclient-connect (clone-client client) buf)
75+ (uniffi/start-future :u64)))))
76+
77+(defn poll-connect!
78+ "Answer the session handle once the connect has settled, or nil while it has
79+ not. Raises what the far side raised if the connect failed.
80+
81+ Safe to call from the loop thread as often as a timer fires: when the future
82+ is not ready this issues another poll and returns, and nothing here blocks."
83+ [fut]
84+ (when (uniffi/settled? fut)
85+ (uniffi/complete! fut)))
86+
87+;; --- the session -------------------------------------------------------------
88+
89+(defn shutdown!
90+ "Graceful shutdown — equivalent to `(cancel! session 0)`.
91+
92+ Named as upstream names it: UniFFI's Kotlin generator already emits a
93+ `close()` that releases the FFI handle, so `close` would mean two different
94+ things depending on which side of the binding you were reading."
95+ [session]
96+ (uniffi/with-out-status
97+ #(raw/method-moqsession-shutdown (clone-session session) %))
98+ nil)
99+
100+(defn cancel!
101+ "Close the session with an error code.
102+
103+ Code 0 is \"no error\", which is what `shutdown!` sends — upstream documents
104+ it that way so a caller ending a call normally does not have to invent one."
105+ ([session] (cancel! session 0))
106+ ([session code]
107+ (uniffi/with-out-status
108+ #(raw/method-moqsession-cancel (clone-session session) code %))
109+ nil))
modified src/frq/moq/smoke.clj +69 -3
@@ -14,12 +14,27 @@
1414 3. a string out and back, which is the RustBuffer layout and the
1515 alloc/free ownership rule.
1616
17- It deliberately does NOT connect. A connect is a future, a tokio worker and
18- a network, and none of those tell you anything if the layouts are wrong.
17+ 4. a connect, which is the only one of these that involves a tokio worker
18+ and therefore the only one that exercises the continuation callback
19+ the piece with the most ways to be quietly wrong, since it runs on a
20+ thread jolt never started.
21+
22+ The connect is aimed at a port nothing is listening on. That is on purpose:
23+ what is under test is the future protocol and the error path, and both of
24+ those are the same whether the far side refuses or is simply not there
25+ where a real relay would make this test depend on somebody else's uptime.
26+
27+ It takes THIRTY SECONDS, and that is not this code being slow. QUIC runs
28+ over UDP, so a closed port produces no connection-refused to notice: there
29+ is only the handshake timeout, which moq-native sets to 30s. The deadline
30+ below has to sit above it, and a test that gave up at 15s reported a
31+ continuation that had never fired when what had happened was that nothing
32+ had gone wrong yet.
1933
2034 just repl -m frq.moq.smoke"
2135 (:require [frq.moq.uniffi :as uniffi]
2236 [frq.moq.raw :as raw]
37+ [frq.moq.client :as client]
2338 [jolt.ffi :as ffi]))
2439
2540 (defn- check-contract []
@@ -63,11 +78,62 @@
6378 {:sent s :got got})))
6479 true)))))
6580
81+(defn- check-connect
82+ "Connect to a closed port and watch the future fail.
83+
84+ Everything interesting is in the loop: `poll-connect!` answers nil while the
85+ far side has not settled, and each nil has issued another poll. That the
86+ answer eventually changes at all is the continuation callback firing from a
87+ tokio thread and this thread seeing it which is the whole reason for the
88+ atom in `frq.moq.uniffi/continuation`.
89+
90+ A raise is the PASS here. What would be a failure is a hang (the
91+ continuation never fires) or a success (a connect to a closed port cannot
92+ succeed, so a session handle would mean the future protocol handed us
93+ something that is not a session)."
94+ []
95+ (let [c (client/new-client)]
96+ (try
97+ (let [fut (client/connect! c "https://127.0.0.1:1/smoke")
98+ deadline (+ (System/currentTimeMillis) 45000)]
99+ (println " connect started, polling")
100+ (loop [polls 0]
101+ (cond
102+ (> (System/currentTimeMillis) deadline)
103+ (throw (ex-info "connect future never settled the continuation did not fire"
104+ {:polls polls}))
105+
106+ :else
107+ (let [r (try
108+ {:ok (client/poll-connect! fut)}
109+ (catch clojure.lang.ExceptionInfo e {:err e}))]
110+ (cond
111+ (:err r)
112+ (let [d (ex-data (:err r))]
113+ (println " settled after" polls "polls")
114+ (println " variant:" (:variant d))
115+ (println " message:" (pr-str (:message d)))
116+ (when-not (:variant d)
117+ (throw (ex-info "error carried no MoqError variant the buffer did not decode"
118+ {:data d})))
119+ true)
120+
121+ (:ok r)
122+ (throw (ex-info "connect to a closed port answered a session"
123+ {:handle (:ok r)}))
124+
125+ :else
126+ (do (Thread/sleep 20) (recur (inc polls))))))))
127+ (finally
128+ (client/free-client! c)
129+ (println " free_moqclient ok")))))
130+
66131 (defn -main [& _]
67132 (println "libmoq_ffi smoke test")
68133 (let [steps [["contract" check-contract]
69134 ["handle" check-handle]
70- ["string" check-string]]]
135+ ["string" check-string]
136+ ["connect" check-connect]]]
71137 (doseq [[name f] steps]
72138 (println (str name ":"))
73139 (f))
@@ -14,12 +14,27 @@
14 3. a string out and back, which is the RustBuffer layout and the14 3. a string out and back, which is the RustBuffer layout and the
15 alloc/free ownership rule.15 alloc/free ownership rule.
16 16
17- It deliberately does NOT connect. A connect is a future, a tokio worker and17+ 4. a connect, which is the only one of these that involves a tokio worker
18- a network, and none of those tell you anything if the layouts are wrong.18+ and therefore the only one that exercises the continuation callback
19+ the piece with the most ways to be quietly wrong, since it runs on a
20+ thread jolt never started.
21+
22+ The connect is aimed at a port nothing is listening on. That is on purpose:
23+ what is under test is the future protocol and the error path, and both of
24+ those are the same whether the far side refuses or is simply not there
25+ where a real relay would make this test depend on somebody else's uptime.
26+
27+ It takes THIRTY SECONDS, and that is not this code being slow. QUIC runs
28+ over UDP, so a closed port produces no connection-refused to notice: there
29+ is only the handshake timeout, which moq-native sets to 30s. The deadline
30+ below has to sit above it, and a test that gave up at 15s reported a
31+ continuation that had never fired when what had happened was that nothing
32+ had gone wrong yet.
19 33
20 just repl -m frq.moq.smoke"34 just repl -m frq.moq.smoke"
21 (:require [frq.moq.uniffi :as uniffi]35 (:require [frq.moq.uniffi :as uniffi]
22 [frq.moq.raw :as raw]36 [frq.moq.raw :as raw]
37+ [frq.moq.client :as client]
23 [jolt.ffi :as ffi]))38 [jolt.ffi :as ffi]))
24 39
25 (defn- check-contract []40 (defn- check-contract []
@@ -63,11 +78,62 @@
63 {:sent s :got got})))78 {:sent s :got got})))
64 true)))))79 true)))))
65 80
81+(defn- check-connect
82+ "Connect to a closed port and watch the future fail.
83+
84+ Everything interesting is in the loop: `poll-connect!` answers nil while the
85+ far side has not settled, and each nil has issued another poll. That the
86+ answer eventually changes at all is the continuation callback firing from a
87+ tokio thread and this thread seeing it which is the whole reason for the
88+ atom in `frq.moq.uniffi/continuation`.
89+
90+ A raise is the PASS here. What would be a failure is a hang (the
91+ continuation never fires) or a success (a connect to a closed port cannot
92+ succeed, so a session handle would mean the future protocol handed us
93+ something that is not a session)."
94+ []
95+ (let [c (client/new-client)]
96+ (try
97+ (let [fut (client/connect! c "https://127.0.0.1:1/smoke")
98+ deadline (+ (System/currentTimeMillis) 45000)]
99+ (println " connect started, polling")
100+ (loop [polls 0]
101+ (cond
102+ (> (System/currentTimeMillis) deadline)
103+ (throw (ex-info "connect future never settled the continuation did not fire"
104+ {:polls polls}))
105+
106+ :else
107+ (let [r (try
108+ {:ok (client/poll-connect! fut)}
109+ (catch clojure.lang.ExceptionInfo e {:err e}))]
110+ (cond
111+ (:err r)
112+ (let [d (ex-data (:err r))]
113+ (println " settled after" polls "polls")
114+ (println " variant:" (:variant d))
115+ (println " message:" (pr-str (:message d)))
116+ (when-not (:variant d)
117+ (throw (ex-info "error carried no MoqError variant the buffer did not decode"
118+ {:data d})))
119+ true)
120+
121+ (:ok r)
122+ (throw (ex-info "connect to a closed port answered a session"
123+ {:handle (:ok r)}))
124+
125+ :else
126+ (do (Thread/sleep 20) (recur (inc polls))))))))
127+ (finally
128+ (client/free-client! c)
129+ (println " free_moqclient ok")))))
130+
66 (defn -main [& _]131 (defn -main [& _]
67 (println "libmoq_ffi smoke test")132 (println "libmoq_ffi smoke test")
68 (let [steps [["contract" check-contract]133 (let [steps [["contract" check-contract]
69 ["handle" check-handle]134 ["handle" check-handle]
70- ["string" check-string]]]135+ ["string" check-string]
136+ ["connect" check-connect]]]
71 (doseq [[name f] steps]137 (doseq [[name f] steps]
72 (println (str name ":"))138 (println (str name ":"))
73 (f))139 (f))
modified src/frq/moq/uniffi.clj +61 -12
@@ -93,32 +93,78 @@
9393 v#
9494 (throw-status! s#))))))
9595
96+(def ^:private moq-error-variants
97+ ;; MoqError, in the order UniFFI numbers it (1-based, as written by the
98+ ;; generated readers). Regenerated by hand when the pin moves — a variant
99+ ;; added upstream shows up here as :moq/unknown rather than as a wrong name.
100+ {1 :protocol 2 :media 3 :mux 4 :json-track 5 :url
101+ 6 :time-overflow 7 :log-level 8 :task 9 :json 10 :cancelled
102+ 11 :closed 12 :connect 13 :bind 14 :reject 15 :already-responded
103+ 16 :codec 17 :unauthorized 18 :forbidden 19 :not-found 20 :unsupported
104+ 21 :invalid-route 22 :log})
105+
106+;; UniFFI serialises into a RustBuffer BIG-ENDIAN, which is not this machine's
107+;; order — so these read byte by byte rather than through ffi/read, whose
108+;; integer types are native. Getting this wrong does not fail loudly: a
109+;; little-endian read of variant 1 is 16777216, which is simply no variant at
110+;; all, and the message after it would be read at the wrong offset.
111+(defn- be-u32 [p off]
112+ (let [b #(ffi/read (+ p off %) :uint8)]
113+ (+ (* (b 0) 16777216) (* (b 1) 65536) (* (b 2) 256) (b 3))))
114+
115+(defn- decode-error
116+ "Lift a lowered MoqError out of an error buffer.
117+
118+ The shape is UniFFI's for a flat error: an i32 variant index, then the
119+ message as an i32 byte length and that many UTF-8 bytes. Every MoqError
120+ variant carries exactly one string, which is what makes this one shape
121+ rather than a table of them.
122+
123+ A buffer too short to hold even the header is reported as-is rather than
124+ read past — an error path is the worst place to add a second fault."
125+ [data len]
126+ (if (< len 8)
127+ {:variant :moq/malformed :message nil}
128+ (let [variant (be-u32 data 0)
129+ n (be-u32 data 4)
130+ n (min n (- len 8))]
131+ {:variant (get moq-error-variants variant :moq/unknown)
132+ :message (when (pos? n) (ffi/read-bytes (+ data 8) n))})))
133+
96134 (defn- status-message
97135 "Read and RELEASE the lowered error sitting in a non-ok status.
98136
99137 Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the
100138 caller's to free — the difference is only what is inside. A panic's buffer is
101- a bare UTF-8 message; an error's is the lowered `MoqError`, whose first bytes
102- are a variant discriminant. Neither is decoded here: this namespace does not
103- know MoqError's shape, so it reports the bytes it can and leaves lifting the
104- variant to the binding that declared the type.
139+ a bare UTF-8 message; an error's is a lowered `MoqError`, and `decode-error`
140+ above lifts that into a variant keyword and its message. MoqError is the one
141+ error type every entry point in this object raises — UniFFI says so in as
142+ many words — so knowing its shape here costs no generality.
105143
106144 The free goes through a status of its own rather than `with-out-status`: this
107145 is already the error path, and a raise from freeing an error buffer would
108146 lose the error that got us here."
109147 [status-ptr]
110- (let [len (ffi/read-field status-ptr rust-call-status [:error-buf :len])
148+ (let [code (ffi/read-field status-ptr rust-call-status [:code])
149+ len (ffi/read-field status-ptr rust-call-status [:error-buf :len])
111150 data (ffi/read-field status-ptr rust-call-status [:error-buf :data])
112151 text (when (and (pos? len) (not (ffi/null? data)))
113- ;; Not ptr->string: the buffer is length-counted, not
114- ;; NUL-terminated, and a lowered error may hold an interior zero.
115- (ffi/read-bytes data len))]
152+ (if (= code status-panic)
153+ ;; A panic's buffer is a bare message, not a lowered value.
154+ ;; Not ptr->string: it is length-counted and may hold an
155+ ;; interior zero.
156+ {:variant :moq/panic :message (ffi/read-bytes data len)}
157+ (decode-error data len)))]
116158 (ffi/with-arena [a]
117159 (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))]
118160 (ffi/write free-status rust-call-status
119161 {:code status-ok
120162 :error-buf {:capacity 0 :len 0 :data ffi/null}})
121- (raw/rustbuffer-free (ffi/place status-ptr rust-call-status [:error-buf])
163+ ;; The address of the errorBuf member, not a `place`: a by-value
164+ ;; aggregate argument is a POINTER to the struct bytes, and `place`
165+ ;; resolves a member's type for read/write rather than its address.
166+ (raw/rustbuffer-free (+ status-ptr
167+ (ffi/field-offset rust-call-status [:error-buf]))
122168 free-status)))
123169 text))
124170
@@ -126,9 +172,12 @@
126172 (let [code (ffi/read-field status-ptr rust-call-status [:code])
127173 msg (status-message status-ptr)]
128174 (throw (ex-info (if (= code status-panic)
129- (str "libmoq_ffi panicked: " msg)
130- (str "libmoq_ffi call failed: " msg))
131- {:code code :message msg}))))
175+ (str "libmoq_ffi panicked: " (:message msg))
176+ (str "libmoq_ffi: " (name (:variant msg :moq/none))
177+ (when-let [m (:message msg)] (str " — " m))))
178+ {:code code
179+ :variant (:variant msg)
180+ :message (:message msg)}))))
132181
133182 ;; --- strings -----------------------------------------------------------------
134183
@@ -93,32 +93,78 @@
93 v#93 v#
94 (throw-status! s#))))))94 (throw-status! s#))))))
95 95
96+(def ^:private moq-error-variants
97+ ;; MoqError, in the order UniFFI numbers it (1-based, as written by the
98+ ;; generated readers). Regenerated by hand when the pin moves — a variant
99+ ;; added upstream shows up here as :moq/unknown rather than as a wrong name.
100+ {1 :protocol 2 :media 3 :mux 4 :json-track 5 :url
101+ 6 :time-overflow 7 :log-level 8 :task 9 :json 10 :cancelled
102+ 11 :closed 12 :connect 13 :bind 14 :reject 15 :already-responded
103+ 16 :codec 17 :unauthorized 18 :forbidden 19 :not-found 20 :unsupported
104+ 21 :invalid-route 22 :log})
105+
106+;; UniFFI serialises into a RustBuffer BIG-ENDIAN, which is not this machine's
107+;; order — so these read byte by byte rather than through ffi/read, whose
108+;; integer types are native. Getting this wrong does not fail loudly: a
109+;; little-endian read of variant 1 is 16777216, which is simply no variant at
110+;; all, and the message after it would be read at the wrong offset.
111+(defn- be-u32 [p off]
112+ (let [b #(ffi/read (+ p off %) :uint8)]
113+ (+ (* (b 0) 16777216) (* (b 1) 65536) (* (b 2) 256) (b 3))))
114+
115+(defn- decode-error
116+ "Lift a lowered MoqError out of an error buffer.
117+
118+ The shape is UniFFI's for a flat error: an i32 variant index, then the
119+ message as an i32 byte length and that many UTF-8 bytes. Every MoqError
120+ variant carries exactly one string, which is what makes this one shape
121+ rather than a table of them.
122+
123+ A buffer too short to hold even the header is reported as-is rather than
124+ read past — an error path is the worst place to add a second fault."
125+ [data len]
126+ (if (< len 8)
127+ {:variant :moq/malformed :message nil}
128+ (let [variant (be-u32 data 0)
129+ n (be-u32 data 4)
130+ n (min n (- len 8))]
131+ {:variant (get moq-error-variants variant :moq/unknown)
132+ :message (when (pos? n) (ffi/read-bytes (+ data 8) n))})))
133+
96 (defn- status-message134 (defn- status-message
97 "Read and RELEASE the lowered error sitting in a non-ok status.135 "Read and RELEASE the lowered error sitting in a non-ok status.
98 136
99 Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the137 Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the
100 caller's to free — the difference is only what is inside. A panic's buffer is138 caller's to free — the difference is only what is inside. A panic's buffer is
101- a bare UTF-8 message; an error's is the lowered `MoqError`, whose first bytes139+ a bare UTF-8 message; an error's is a lowered `MoqError`, and `decode-error`
102- are a variant discriminant. Neither is decoded here: this namespace does not140+ above lifts that into a variant keyword and its message. MoqError is the one
103- know MoqError's shape, so it reports the bytes it can and leaves lifting the141+ error type every entry point in this object raises — UniFFI says so in as
104- variant to the binding that declared the type.142+ many words — so knowing its shape here costs no generality.
105 143
106 The free goes through a status of its own rather than `with-out-status`: this144 The free goes through a status of its own rather than `with-out-status`: this
107 is already the error path, and a raise from freeing an error buffer would145 is already the error path, and a raise from freeing an error buffer would
108 lose the error that got us here."146 lose the error that got us here."
109 [status-ptr]147 [status-ptr]
110- (let [len (ffi/read-field status-ptr rust-call-status [:error-buf :len])148+ (let [code (ffi/read-field status-ptr rust-call-status [:code])
149+ len (ffi/read-field status-ptr rust-call-status [:error-buf :len])
111 data (ffi/read-field status-ptr rust-call-status [:error-buf :data])150 data (ffi/read-field status-ptr rust-call-status [:error-buf :data])
112 text (when (and (pos? len) (not (ffi/null? data)))151 text (when (and (pos? len) (not (ffi/null? data)))
113- ;; Not ptr->string: the buffer is length-counted, not152+ (if (= code status-panic)
114- ;; NUL-terminated, and a lowered error may hold an interior zero.153+ ;; A panic's buffer is a bare message, not a lowered value.
115- (ffi/read-bytes data len))]154+ ;; Not ptr->string: it is length-counted and may hold an
155+ ;; interior zero.
156+ {:variant :moq/panic :message (ffi/read-bytes data len)}
157+ (decode-error data len)))]
116 (ffi/with-arena [a]158 (ffi/with-arena [a]
117 (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))]159 (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))]
118 (ffi/write free-status rust-call-status160 (ffi/write free-status rust-call-status
119 {:code status-ok161 {:code status-ok
120 :error-buf {:capacity 0 :len 0 :data ffi/null}})162 :error-buf {:capacity 0 :len 0 :data ffi/null}})
121- (raw/rustbuffer-free (ffi/place status-ptr rust-call-status [:error-buf])163+ ;; The address of the errorBuf member, not a `place`: a by-value
164+ ;; aggregate argument is a POINTER to the struct bytes, and `place`
165+ ;; resolves a member's type for read/write rather than its address.
166+ (raw/rustbuffer-free (+ status-ptr
167+ (ffi/field-offset rust-call-status [:error-buf]))
122 free-status)))168 free-status)))
123 text))169 text))
124 170
@@ -126,9 +172,12 @@
126 (let [code (ffi/read-field status-ptr rust-call-status [:code])172 (let [code (ffi/read-field status-ptr rust-call-status [:code])
127 msg (status-message status-ptr)]173 msg (status-message status-ptr)]
128 (throw (ex-info (if (= code status-panic)174 (throw (ex-info (if (= code status-panic)
129- (str "libmoq_ffi panicked: " msg)175+ (str "libmoq_ffi panicked: " (:message msg))
130- (str "libmoq_ffi call failed: " msg))176+ (str "libmoq_ffi: " (name (:variant msg :moq/none))
131- {:code code :message msg}))))177+ (when-let [m (:message msg)] (str " — " m))))
178+ {:code code
179+ :variant (:variant msg)
180+ :message (:message msg)}))))
132 181
133 ;; --- strings -----------------------------------------------------------------182 ;; --- strings -----------------------------------------------------------------
134 183