From ad5affe89f1c6879942629315a91bf6fa003aace Mon Sep 17 00:00:00 2001 From: Yogthos Date: Mon, 29 Jun 2026 14:09:05 -0400 Subject: [PATCH] nREPL: surface startup failures and close the listen socket on shutdown Two pre-existing issues in the nrepl command, exposed when #269 moved the accept loop into a future. jolt.nrepl/start was invoked inside (future ...), so binding the socket happened on a background thread. A bind failure such as the port already being in use was captured into a future that nothing derefs and silently swallowed, leaving the main thread parked in run-main-pump forever with no server and no error. start now binds the socket synchronously before returning, so that failure propagates to the caller and the process exits with a visible message. Only the blocking accept loop runs on a worker thread. There was also no shutdown path: the accept loop ran forever and the listen socket was never closed. start now returns a stop fn that breaks the accept loop, closes the socket to free the port, and removes .nrepl-port. main.clj runs run-main-pump and then calls the stop fn, so a stop-main-pump (from a glimmer app on quit, or from nrepl-evaluated code) shuts the server down cleanly and the process exits. --- jolt-core/jolt/main.clj | 19 ++++++++++++------- jolt-core/jolt/nrepl.clj | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/jolt-core/jolt/main.clj b/jolt-core/jolt/main.clj index f5e5739..392979c 100644 --- a/jolt-core/jolt/main.clj +++ b/jolt-core/jolt/main.clj @@ -188,13 +188,18 @@ (let [port (or (some-> (first (filter #(not (str/starts-with? % "-")) more)) parse-long) (parse-long (or (jolt.host/getenv "JOLT_NREPL_PORT") "7888")))] (require 'jolt.nrepl) - ;; Run the accept loop on a worker thread so the primordial (main) thread - ;; stays free to own the GUI main loop. glimmer's run marshals its startup - ;; onto this thread via jolt.host/call-on-main-thread — on macOS GTK quartz, - ;; g_application_run must run on the main thread or AppKit aborts when it - ;; sets the main menu. - (future ((resolve 'jolt.nrepl/start) port (:nrepl-middleware resolved))) - (jolt.host/run-main-pump)))) + ;; start binds the socket synchronously on this (primordial) thread, so a + ;; failure like the port already being in use surfaces here and exits rather + ;; than being swallowed by a background thread. It then runs the accept loop + ;; on a worker thread and returns a stop fn, leaving this thread free to own + ;; the GUI main loop: glimmer's run marshals its startup here via + ;; jolt.host/call-on-main-thread — on macOS GTK quartz, g_application_run + ;; must run on the main thread or AppKit aborts when it sets the main menu. + (let [stop ((resolve 'jolt.nrepl/start) port (:nrepl-middleware resolved))] + ;; park here until something calls jolt.host/stop-main-pump, then shut the + ;; server down cleanly (close the socket, remove .nrepl-port) and return. + (jolt.host/run-main-pump) + (when stop (stop)))))) (defn- usage [] (println "usage: jolt [args]") diff --git a/jolt-core/jolt/nrepl.clj b/jolt-core/jolt/nrepl.clj index f48731a..d886c16 100644 --- a/jolt-core/jolt/nrepl.clj +++ b/jolt-core/jolt/nrepl.clj @@ -17,6 +17,7 @@ Writes .nrepl-port in the project dir so editors auto-detect the port." (:require [clojure.string :as str] + [clojure.java.io :as io] [jolt.ffi :as ffi])) ;; --- sockets (loopback server) --------------------------------------------- @@ -222,18 +223,38 @@ (defn start "Start the nREPL server on `port` (a concrete port; loopback only). `middleware` is a vector of deps.edn :nrepl/middleware symbols to compose over the built-in - handler. Writes .nrepl-port. Blocks accepting connections." + handler. + + Binds the socket synchronously, so a startup failure (e.g. the port is already + in use) is thrown to the caller rather than swallowed by the accept thread, then + accepts connections on a background thread and returns immediately. Writes + .nrepl-port. Does NOT block — the caller keeps the process alive (jolt.main + parks the main thread in jolt.host/run-main-pump). + + Returns a zero-arg stop fn: it stops the accept loop, closes the listen socket + (freeing the port), and removes .nrepl-port. Calling it more than once is a + no-op." ([port] (start port nil)) ([port middleware] (let [handler (build-handler (resolve-middleware (or middleware []))) - fd (listen-socket port)] + fd (listen-socket port) ; throws on bind/listen failure + stopped (atom false)] (try (spit ".nrepl-port" (str port)) (catch :default _ nil)) (println (str "nREPL server started on port " port " (127.0.0.1) — .nrepl-port written")) (when (seq middleware) (println (str ";; middleware: " (str/join " " middleware)))) (println ";; connect your editor; ^C to stop") - (loop [] - (let [conn (c-accept fd ffi/null ffi/null)] - (when (>= conn 0) - (future (try (handle-conn conn handler) - (catch :default e (println "nrepl conn error:" (err-msg e)) (c-close conn))))) - (recur)))))) + (future + ;; A stop closes fd, which makes the blocking accept() return an error; the + ;; @stopped check then breaks the loop instead of spinning on the dead fd. + (loop [] + (let [conn (c-accept fd ffi/null ffi/null)] + (when-not @stopped + (when (>= conn 0) + (future (try (handle-conn conn handler) + (catch :default e (println "nrepl conn error:" (err-msg e)) (c-close conn))))) + (recur))))) + (fn stop [] + (when (compare-and-set! stopped false true) + (c-close fd) + (try (io/delete-file ".nrepl-port" true) (catch :default _ nil))) + nil))))