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>
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 @@ | ||
| 14 | 14 | 3. a string out and back, which is the RustBuffer layout and the |
| 15 | 15 | alloc/free ownership rule. |
| 16 | 16 | |
| 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. | |
| 19 | 33 | |
| 20 | 34 | just repl -m frq.moq.smoke" |
| 21 | 35 | (:require [frq.moq.uniffi :as uniffi] |
| 22 | 36 | [frq.moq.raw :as raw] |
| 37 | + [frq.moq.client :as client] | |
| 23 | 38 | [jolt.ffi :as ffi])) |
| 24 | 39 | |
| 25 | 40 | (defn- check-contract [] |
| @@ -63,11 +78,62 @@ | ||
| 63 | 78 | {:sent s :got got}))) |
| 64 | 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 | 131 | (defn -main [& _] |
| 67 | 132 | (println "libmoq_ffi smoke test") |
| 68 | 133 | (let [steps [["contract" check-contract] |
| 69 | 134 | ["handle" check-handle] |
| 70 | - ["string" check-string]]] | |
| 135 | + ["string" check-string] | |
| 136 | + ["connect" check-connect]]] | |
| 71 | 137 | (doseq [[name f] steps] |
| 72 | 138 | (println (str name ":")) |
| 73 | 139 | (f)) |
| @@ -14,12 +14,27 @@ | |||
| 14 | 3. a string out and back, which is the RustBuffer layout and the | 14 | 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 and | 17 | + 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 @@ | ||
| 93 | 93 | v# |
| 94 | 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 | 134 | (defn- status-message |
| 97 | 135 | "Read and RELEASE the lowered error sitting in a non-ok status. |
| 98 | 136 | |
| 99 | 137 | Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the |
| 100 | 138 | 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. | |
| 105 | 143 | |
| 106 | 144 | The free goes through a status of its own rather than `with-out-status`: this |
| 107 | 145 | is already the error path, and a raise from freeing an error buffer would |
| 108 | 146 | lose the error that got us here." |
| 109 | 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 | 150 | data (ffi/read-field status-ptr rust-call-status [:error-buf :data]) |
| 112 | 151 | 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)))] | |
| 116 | 158 | (ffi/with-arena [a] |
| 117 | 159 | (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))] |
| 118 | 160 | (ffi/write free-status rust-call-status |
| 119 | 161 | {:code status-ok |
| 120 | 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 | 168 | free-status))) |
| 123 | 169 | text)) |
| 124 | 170 | |
| @@ -126,9 +172,12 @@ | ||
| 126 | 172 | (let [code (ffi/read-field status-ptr rust-call-status [:code]) |
| 127 | 173 | msg (status-message status-ptr)] |
| 128 | 174 | (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)})))) | |
| 132 | 181 | |
| 133 | 182 | ;; --- strings ----------------------------------------------------------------- |
| 134 | 183 | |
| @@ -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-message | 134 | (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 the | 137 | 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 is | 138 | 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 | 139 | + 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 not | 140 | + 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 the | 141 | + 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`: this | 144 | 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 would | 145 | 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, not | 152 | + (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-status | 160 | (ffi/write free-status rust-call-status |
| 119 | {:code status-ok | 161 | {: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 | ||