Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@

### Enhancements

* The client now reconnects automatically when the connection drops (page
reload, server restart, flaky network) using an exponential backoff. It is
on by default and tunable via the `:reconnect?`, `:reconnect-delay` and
`:max-reconnect-delay` options. Call `weasel.repl/disconnect` to close the
connection and stop reconnecting.
* Added an optional application-level heartbeat (`:ping`/`:pong`) that detects a
silently dead connection and triggers a reconnect. It is off by default and
enabled via the `:heartbeat-interval` option; it never disrupts a server that
doesn't answer pings.
* Ship a `deps.edn` so the library can be consumed via the Clojure CLI / tools.deps.
* Add a GitHub Actions CI pipeline and a basic test suite, including a Node
round-trip integration test that exercises the full eval cycle over a real
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,27 @@ You may optionally specify the following:
; or any variadic function to handle printing differently.
; defaults to :repl
:on-open, :on-error, :on-close ; fns for handling websocket lifecycle events.
; default for all is nil
; default for all is nil. :on-open and :on-close
; fire once per connection (not on every
; reconnect); :on-error receives the native
; WebSocket event.
:reconnect? ; boolean, whether to reconnect automatically when the
; connection drops. defaults to true
:reconnect-delay ; initial reconnect backoff in ms, defaults to 1000.
; the delay doubles after each failed attempt
:max-reconnect-delay ; ceiling for the backoff in ms, defaults to 30000
:heartbeat-interval ; ms between keepalive pings used to detect a silently
; dead connection. 0 disables it, which is the default.
; a few missed pongs are tolerated before the link is
; considered dead, and a server that never answers pings
; is left undisturbed
```

The connection survives page reloads and server restarts on its own, retrying
with the backoff until it gets back in (or the server rejects it outright, e.g.
when another client already holds the REPL). Call `(repl/disconnect)` to close
it for good and stop the reconnection attempts.

Connecting with options:
```clojure
(repl/connect "ws://localhost:9001"
Expand Down
117 changes: 80 additions & 37 deletions dev/weasel/integration.clj
Original file line number Diff line number Diff line change
@@ -1,59 +1,102 @@
(ns weasel.integration
"End-to-end check of the native-WebSocket transport.
"End-to-end checks of the native-WebSocket transport against a real server
driving the compiled Node client. Exercises, in one run:

Starts a real Weasel server, launches the compiled ClojureScript client
under Node and drives a single round-trip that exercises both the result
and the print paths: the evaluated snippet prints a string and returns a
value, and we assert both travel back over the socket. Run with:
* a round-trip that both prints and returns a value
* the heartbeat (the client keeps pinging, the server pongs, and the
connection is not torn down)
* auto-reconnect after the server is bounced

Run with:

lein cljsbuild once node
lein run -m weasel.integration"
(:require [clojure.edn :as edn]
[weasel.repl.server :as server]))

(def ^:private client-js "target/node/weasel_node_client.js")
(def ^:private port 9009)
(def ^:private heartbeat-ms 300)

;; the current round's signal promises, swapped out between rounds
(def ^:private signals (atom nil))
(def ^:private ping-count (atom 0))
(def ^:private ready-count (atom 0))

(defn- fresh-round! []
(reset! signals {:ready (promise) :printed (promise) :result (promise)}))

(defn- await!
"Derefs promise `p`, throwing if it does not resolve within 10 seconds."
[what p]
(defn- handle [data]
(let [msg (edn/read-string data)
{:keys [ready printed result]} @signals]
(case (:op msg)
:ready (do (swap! ready-count inc) (deliver ready true))
:print (deliver printed (:value msg))
:result (deliver result (:value msg))
:ping (do (swap! ping-count inc)
(server/send! (pr-str {:op :pong})))
nil)))

(defn- await! [what p]
(let [v (deref p 10000 ::timeout)]
(when (= v ::timeout)
(throw (ex-info (str "timed out waiting for " what) {})))
v))

(defn- wait-for-client! [what]
;; like server/wait-for-client, but bounded so a broken reconnect fails the
;; run instead of hanging forever
(when (= ::timeout (deref (server/channel) 10000 ::timeout))
(throw (ex-info (str "timed out waiting for " what) {}))))

(defn- eval! [code]
(server/send! (pr-str {:op :eval-js :code code})))

(defn -main [& _]
(let [ready (promise)
printed (promise)
result (promise)
port 9009
ok? (atom false)]
(server/start
(fn [data]
(let [msg (edn/read-string data)]
(case (:op msg)
:ready (deliver ready true)
:print (deliver printed (:value msg))
:result (deliver result (:value msg))
nil)))
:ip "127.0.0.1" :port port)
(let [^"[Ljava.lang.String;" cmd (into-array String ["node" client-js (str "ws://127.0.0.1:" port)])
(let [ok? (atom false)]
(fresh-round!)
(server/start handle :ip "127.0.0.1" :port port)
(let [^"[Ljava.lang.String;" cmd (into-array String
["node" client-js (str "ws://127.0.0.1:" port)
(str heartbeat-ms)])
proc (-> (ProcessBuilder. cmd) (.inheritIO) (.start))]
(try
(server/wait-for-client)
(await! ":ready" ready)
(server/send!
(pr-str {:op :eval-js
:code "(function () { weasel.repl.repl_print('hi from node'); return 40 + 2; })()"}))
(let [value (:value (await! ":result" result))
out (await! ":print" printed)]
(println "RESULT:" (pr-str value) " PRINT:" (pr-str out))
(reset! ok? (and (= value "42")
(= out (pr-str "hi from node")))))
(catch Exception e
;; round 1 - result and print travel back over the socket
(wait-for-client! "client to connect")
(await! ":ready" (:ready @signals))
(eval! "(function () { weasel.repl.repl_print('hi from node'); return 40 + 2; })()")
(let [value (:value (await! ":result" (:result @signals)))
out (await! ":print" (:printed @signals))]
(assert (= value "42") (str "eval result was " (pr-str value)))
(assert (= out (pr-str "hi from node")) (str "print was " (pr-str out))))

;; heartbeat - over several intervals the client keeps pinging (proving
;; it processes pongs and the cycle continues) and the healthy socket is
;; NOT torn down (no spurious reconnect, so still exactly one :ready)
(Thread/sleep (long (* 5 heartbeat-ms)))
(assert (>= @ping-count 2)
(str "expected repeated heartbeat pings, saw " @ping-count))
(assert (= 1 @ready-count)
(str "heartbeat tore down a healthy socket; :ready count = " @ready-count))

;; round 2 - bounce the server, the client reconnects on its own
(fresh-round!)
(server/stop)
(Thread/sleep 200)
(server/start handle :ip "127.0.0.1" :port port)
(wait-for-client! "client to reconnect")
(await! ":ready (after reconnect)" (:ready @signals))
(eval! "7 * 6")
(assert (= "42" (:value (await! ":result (after reconnect)" (:result @signals))))
"reconnected eval returned the wrong value")

(reset! ok? true)
(println (str "PASS - eval, print, heartbeat (" @ping-count
" pings) and reconnect all verified"))
(catch Throwable e
(println "FAIL:" (.getMessage e)))
(finally
(.destroy proc)
(server/stop))))
(if @ok?
(println "PASS")
(do (println "FAIL") (System/exit 1)))))
(when-not @ok?
(System/exit 1))))
9 changes: 9 additions & 0 deletions src/clj/weasel/repl/websocket.clj
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@
:ready
[_ _])

(defmethod process-message
:ping
[_ _]
(server/send! (pr-str {:op :pong})))

(defmethod process-message
:default
[_ _])

(defn- websocket-setup-env
[this opts]
(reset! repl-out *out*)
Expand Down
Loading
Loading