Skip to content

Commit e5e8a80

Browse files
authored
Merge pull request #373 from ClojureCivitas/babqua-support
Babqua support
2 parents d9b5bf2 + 87af046 commit e5e8a80

10 files changed

Lines changed: 1729 additions & 0 deletions

File tree

.github/workflows/render-and-publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
steps:
1717
- name: Check out repository
1818
uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0 # full history so posts can mine `git log` (Babqua demos)
1921

2022
- name: Set up Python
2123
uses: actions/setup-python@v5

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ _site/
4747
.jank-nrepl-port
4848
.jank-repl.log
4949

50+
# Babqua nREPL lifecycle files (created in the Quarto rendering directory)
51+
.babqua-pid
52+
.babqua-nrepl-port
53+
.babqua-bb.log
54+
5055
# Edit at https://www.toptal.com/developers/gitignore?templates=osx
5156

5257
### OSX ###
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
title: Babqua
2+
author: scicloj
3+
version: 0.1.0
4+
contributes:
5+
filters:
6+
- babqua.lua
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env bb
2+
;; babqua-lifecycle.bb — manage the persistent bb nREPL process used by
3+
;; Babqua's preview mode.
4+
;;
5+
;; Usage:
6+
;; bb babqua-lifecycle.bb start — start nREPL if not running, print port
7+
;; bb babqua-lifecycle.bb stop — stop nREPL, clean up files
8+
;; bb babqua-lifecycle.bb status — print port if running, exit 1 if not
9+
;;
10+
;; State files live at the project root (resolved by walking up from cwd
11+
;; looking for an existing .babqua-pid, or BABQUA_PROJECT_ROOT, else cwd):
12+
;; .babqua-pid — PID of the spawned `bb nrepl-server`
13+
;; .babqua-nrepl-port — port the nREPL is listening on
14+
;; .babqua-bb.log — combined stdout+stderr of the spawned process
15+
;;
16+
;; Defensive choices: atomic file writes, PID validation before
17+
;; kill, cleanup-on-death monitor, project-root sense check.
18+
19+
(ns babqua.lifecycle
20+
(:require [babashka.fs :as fs]
21+
[babashka.process :as p]
22+
[clojure.string :as str]))
23+
24+
;; ----- project root resolution -----------------------------------------
25+
26+
(defn- resolve-project-root []
27+
(or (System/getenv "BABQUA_PROJECT_ROOT")
28+
(loop [d (fs/cwd)]
29+
(cond
30+
(nil? d) (str (fs/cwd))
31+
(fs/exists? (fs/path d ".babqua-pid")) (str d)
32+
(= (str d) "/") (str (fs/cwd))
33+
:else (recur (fs/parent d))))))
34+
35+
(def project-root (delay (resolve-project-root)))
36+
37+
(defn- guard-root []
38+
(when (or (= @project-root "/") (str/blank? @project-root))
39+
(binding [*out* *err*]
40+
(println (str "[babqua-lifecycle] ERROR: refusing to operate with "
41+
"project-root=" (pr-str @project-root))))
42+
(System/exit 1)))
43+
44+
(defn- pid-file [] (str (fs/path @project-root ".babqua-pid")))
45+
(defn- port-file [] (str (fs/path @project-root ".babqua-nrepl-port")))
46+
(defn- log-file [] (str (fs/path @project-root ".babqua-bb.log")))
47+
48+
;; ----- atomic IO -------------------------------------------------------
49+
50+
(defn- atomic-write! [path content]
51+
(let [tmp (str path ".tmp." (System/currentTimeMillis) "." (rand-int 1000000))]
52+
(spit tmp content)
53+
(fs/move tmp path {:replace-existing true})))
54+
55+
(defn- read-pid []
56+
(when (fs/exists? (pid-file))
57+
(try (Long/parseLong (str/trim (slurp (pid-file))))
58+
(catch Exception _
59+
(binding [*out* *err*]
60+
(println "[babqua-lifecycle] WARN: corrupt PID file, removing"))
61+
(fs/delete-if-exists (pid-file))
62+
(fs/delete-if-exists (port-file))
63+
nil))))
64+
65+
;; ----- process probing -------------------------------------------------
66+
67+
(defn- pid-alive? [pid]
68+
(try (zero? (:exit @(p/process ["kill" "-0" (str pid)]
69+
{:out :discard :err :discard})))
70+
(catch Exception _ false)))
71+
72+
;; A "babqua-managed" process is one whose argv contains "nrepl-server".
73+
;; We don't try to fingerprint further — pidfile + project-root anchoring
74+
;; already prevents most cross-talk. Defends only against the recycled-PID
75+
;; race where the OS assigned our old PID to an unrelated user process.
76+
(defn- babqua-process? [pid]
77+
(try (let [{:keys [exit out]}
78+
@(p/process ["ps" "-o" "args=" "-p" (str pid)]
79+
{:out :string :err :discard})]
80+
(and (zero? exit)
81+
(str/includes? (or out "") "nrepl-server")))
82+
(catch Exception _ false)))
83+
84+
;; ----- start -----------------------------------------------------------
85+
86+
(defn- already-running? []
87+
(when-let [pid (read-pid)]
88+
(and (pid-alive? pid) (babqua-process? pid))))
89+
90+
(defn- write-loud! [lines]
91+
(binding [*out* *err*]
92+
(let [sep (apply str (repeat 64 "="))]
93+
(println sep)
94+
(doseq [l lines] (println (str "[babqua-lifecycle] " l)))
95+
(println sep))))
96+
97+
(defn cmd-start []
98+
(guard-root)
99+
(when (already-running?)
100+
(let [port (when (fs/exists? (port-file))
101+
(str/trim (slurp (port-file))))]
102+
(binding [*out* *err*]
103+
(println (str "[babqua-lifecycle] Already running on port "
104+
(or port "?") " (PID " (read-pid) ")")))
105+
(when port (println port))
106+
(System/exit (if port 0 1))))
107+
108+
;; Stale files from a dead session?
109+
(when (read-pid)
110+
(fs/delete-if-exists (pid-file))
111+
(fs/delete-if-exists (port-file)))
112+
113+
(spit (log-file) "")
114+
(binding [*out* *err*]
115+
(println "[babqua-lifecycle] Starting bb nREPL..."))
116+
117+
(let [proc (p/process {:cmd ["bb" "nrepl-server" "localhost:0"]
118+
:out (java.io.File. (log-file))
119+
:err :out
120+
:dir @project-root})
121+
pid (.pid (:proc proc))]
122+
(atomic-write! (pid-file) (str pid))
123+
124+
;; Poll log file for the "Started nREPL server at host:port" line.
125+
;; Generous deadline because cold bb under load can take a moment.
126+
(let [deadline (+ (System/currentTimeMillis) 30000)
127+
port (loop []
128+
(cond
129+
(not (pid-alive? pid))
130+
(do (write-loud! ["bb died during startup. Check"
131+
(log-file)])
132+
(fs/delete-if-exists (pid-file))
133+
nil)
134+
135+
(> (System/currentTimeMillis) deadline)
136+
(do (write-loud! ["Timed out (30s) waiting for nREPL."
137+
(str "Check " (log-file))])
138+
(try (.destroy (:proc proc)) (catch Exception _))
139+
(fs/delete-if-exists (pid-file))
140+
nil)
141+
142+
:else
143+
(let [content (try (slurp (log-file))
144+
(catch Exception _ ""))]
145+
(if-let [m (re-find #"Started nREPL server at [^:]+:(\d+)"
146+
content)]
147+
(second m)
148+
(do (Thread/sleep 100) (recur))))))]
149+
(when-not port (System/exit 1))
150+
(atomic-write! (port-file) port)
151+
(write-loud!
152+
[(str "bb nREPL on port " port " (PID " pid ")")
153+
(str "Stop with: bb " (or (System/getenv "BABQUA_LIFECYCLE_PATH")
154+
"_extensions/bb/babqua-lifecycle.bb")
155+
" stop")])
156+
(println port))))
157+
158+
;; ----- stop ------------------------------------------------------------
159+
160+
(defn- escalate-after-sigterm [pid]
161+
(loop [i 0]
162+
(cond
163+
(not (pid-alive? pid)) :gone
164+
(>= i 10)
165+
(try @(p/process ["kill" "-9" (str pid)] {:out :discard :err :discard})
166+
(catch Exception _ nil))
167+
:else (do (Thread/sleep 100) (recur (inc i))))))
168+
169+
(defn cmd-stop []
170+
(guard-root)
171+
(let [pid (read-pid)]
172+
(cond
173+
(nil? pid)
174+
(binding [*out* *err*]
175+
(println (str "[babqua-lifecycle] No PID file at " (pid-file)
176+
" — nothing to stop."))
177+
(fs/delete-if-exists (port-file)))
178+
179+
(not (pid-alive? pid))
180+
(do (binding [*out* *err*]
181+
(println "[babqua-lifecycle] PID file references dead process; cleaning up."))
182+
(fs/delete-if-exists (pid-file))
183+
(fs/delete-if-exists (port-file)))
184+
185+
(not (babqua-process? pid))
186+
(binding [*out* *err*]
187+
(println (str "[babqua-lifecycle] WARN: PID " pid
188+
" is not an nrepl-server; refusing to kill."))
189+
(fs/delete-if-exists (pid-file))
190+
(fs/delete-if-exists (port-file)))
191+
192+
:else
193+
(do (binding [*out* *err*]
194+
(println (str "[babqua-lifecycle] Stopping bb nREPL (PID " pid ")")))
195+
(try @(p/process ["kill" (str pid)] {:out :discard :err :discard})
196+
(catch Exception _ nil))
197+
(escalate-after-sigterm pid)
198+
(fs/delete-if-exists (pid-file))
199+
(fs/delete-if-exists (port-file))
200+
(binding [*out* *err*]
201+
(println "[babqua-lifecycle] Stopped."))))))
202+
203+
;; ----- status ----------------------------------------------------------
204+
205+
(defn cmd-status []
206+
(guard-root)
207+
(let [pid (read-pid)]
208+
(cond
209+
(and pid (pid-alive? pid) (babqua-process? pid)
210+
(fs/exists? (port-file)))
211+
(do (println (str "running on port " (str/trim (slurp (port-file)))
212+
" (PID " pid ")"))
213+
(System/exit 0))
214+
215+
:else
216+
(do (println "not running")
217+
(System/exit 1)))))
218+
219+
;; ----- dispatch --------------------------------------------------------
220+
221+
(case (first *command-line-args*)
222+
"start" (cmd-start)
223+
"stop" (cmd-stop)
224+
"status" (cmd-status)
225+
(do (binding [*out* *err*]
226+
(println "Usage: babqua-lifecycle.bb {start|stop|status}"))
227+
(System/exit 2)))
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bb
2+
;; babqua-nrepl-client.bb — bencode client used by the Lua filter when a
3+
;; persistent bb nREPL is running. Connects to the port given on argv,
4+
;; forwards the Clojure code on stdin as a single eval message, and
5+
;; relays the resulting :out chunks to its own stdout (where the filter
6+
;; reads them with the same JSON-extraction logic as one-shot mode).
7+
;;
8+
;; Why a separate script rather than nREPL talk in Lua: pandoc's Lua
9+
;; doesn't ship luasocket, so direct TCP from the filter isn't portable.
10+
;; Babqua already needs bb anyway, and bb has bencode.core built in, so
11+
;; one bb spawn per render delegates the protocol work to a context
12+
;; that has the libraries.
13+
;;
14+
;; Usage:
15+
;; echo "(println :hi)" | bb babqua-nrepl-client.bb 12345
16+
17+
(ns babqua.nrepl-client
18+
(:require [bencode.core :as b])
19+
(:import [java.io PushbackInputStream]
20+
[java.net Socket SocketException]))
21+
22+
(defn- coerce
23+
"bencode.core hands back byte[] for strings and recursively-nested maps
24+
/ vectors. Convert to plain strings/maps so equality and lookup work."
25+
[v]
26+
(cond
27+
(bytes? v) (String. ^bytes v "UTF-8")
28+
(map? v) (into {} (map (fn [[k v]] [(coerce k) (coerce v)])) v)
29+
(sequential? v) (mapv coerce v)
30+
:else v))
31+
32+
(defn- exit-fatal! [msg]
33+
(binding [*out* *err*] (println (str "[babqua-nrepl-client] " msg)))
34+
(System/exit 1))
35+
36+
(defn -main [& args]
37+
(let [port (try (Integer/parseInt (first args))
38+
(catch Exception _ nil))
39+
_ (when-not port
40+
(exit-fatal! "Usage: babqua-nrepl-client.bb <port>"))
41+
code (slurp *in*)
42+
sock (try (Socket. "127.0.0.1" ^int port)
43+
(catch Exception e
44+
(exit-fatal! (str "Could not connect to nREPL on "
45+
port ": " (.getMessage e)))))
46+
out (.getOutputStream sock)
47+
in (PushbackInputStream. (.getInputStream sock))]
48+
(try
49+
(b/write-bencode out {"op" "eval" "code" code "id" "1"})
50+
(loop [chunks []]
51+
(let [resp (try (coerce (b/read-bencode in))
52+
(catch SocketException _ nil)
53+
(catch java.io.EOFException _ nil))
54+
status (set (or (get resp "status") []))]
55+
(cond
56+
(nil? resp)
57+
(exit-fatal! "nREPL connection closed unexpectedly")
58+
59+
(get resp "out")
60+
(recur (conj chunks (get resp "out")))
61+
62+
(get resp "err")
63+
(do (binding [*out* *err*] (print (get resp "err")) (flush))
64+
(recur chunks))
65+
66+
;; "ex" is set when eval threw at the top level; surface and
67+
;; let the filter's outer try/catch JSON path produce the
68+
;; babqua/fatal envelope from any captured :out.
69+
(or (contains? status "done")
70+
(contains? status "error"))
71+
(do (doseq [s chunks] (print s))
72+
(flush))
73+
74+
:else
75+
(recur chunks))))
76+
(finally
77+
(try (.close sock) (catch Exception _ nil))))))
78+
79+
(apply -main *command-line-args*)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* Distinguish Babqua output blocks from input code */
2+
.cell-output pre code.sourceCode {
3+
background-color: transparent;
4+
border-left: 3px solid #e0e0e0;
5+
padding-left: 0.8em;
6+
}
7+
8+
/* Cap stdout-block height so a noisy `(println …)` (e.g. a debug
9+
`dotimes 1000`) doesn't push everything below it off the page.
10+
Display blocks (charts, return values) and error blocks stay
11+
uncapped — those need to remain fully visible. */
12+
.cell-output-stdout pre {
13+
max-height: 30em;
14+
overflow-y: auto;
15+
}

0 commit comments

Comments
 (0)