|
| 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))) |
0 commit comments