deps.edn :jolt/native — declare a library's native shared libraries

An FFI library declares the system shared objects it binds in its deps.edn
(:jolt/native), with per-platform candidate sonames, :optional for feature-gated
deps, and :process for libraries that use the running process's own symbols
(libc sockets). jolt.deps collects them transitively; jolt loads them before the
library's namespaces are required, so foreign-fn bindings resolve — and a missing
required lib fails early with a clear message instead of a cryptic symbol error.
Replaces hardcoded soname-probing inside library .clj files.
This commit is contained in:
Yogthos 2026-06-22 12:37:18 -04:00
parent b7bd144321
commit 21fbc50014
2 changed files with 55 additions and 28 deletions

View file

@ -78,33 +78,37 @@
(map #(abspath root %) paths))) (map #(abspath root %) paths)))
(defn- resolve-deps (defn- resolve-deps
"Breadth-first walk of a deps map; returns an ordered, de-duplicated list of "Breadth-first walk of a deps map; returns {:roots [...] :natives [...]} — the
source-root directories. `base-dir` resolves :local/root and is replaced by a ordered, de-duplicated source-root directories and the collected :jolt/native
dep's own root as the walk descends." declarations from every dep's deps.edn. `base-dir` resolves :local/root and is
replaced by a dep's own root as the walk descends."
[deps base-dir] [deps base-dir]
(loop [queue (mapv (fn [[c s]] [c s base-dir]) (seq deps)) (loop [queue (mapv (fn [[c s]] [c s base-dir]) (seq deps))
seen #{} seen #{}
roots []] roots []
natives []]
(if (empty? queue) (if (empty? queue)
(distinct roots) {:roots (distinct roots) :natives natives}
(let [[coord spec bd] (first queue) (let [[coord spec bd] (first queue)
queue (subvec (vec queue) 1)] queue (subvec (vec queue) 1)]
(if (contains? seen coord) (if (contains? seen coord)
(recur queue seen roots) (recur queue seen roots natives)
(let [root (coord-root coord spec bd)] (let [root (coord-root coord spec bd)]
(if (nil? root) (if (nil? root)
(recur queue (conj seen coord) roots) (recur queue (conj seen coord) roots natives)
(let [edn (read-edn (str root "/deps.edn")) (let [edn (read-edn (str root "/deps.edn"))
child (mapv (fn [[c s]] [c s root]) (seq (:deps edn)))] child (mapv (fn [[c s]] [c s root]) (seq (:deps edn)))]
(recur (into queue child) (recur (into queue child)
(conj seen coord) (conj seen coord)
(into roots (dep-source-roots root))))))))))) (into roots (dep-source-roots root))
(into natives (:jolt/native edn)))))))))))
;; --- public ----------------------------------------------------------------- ;; --- public -----------------------------------------------------------------
(defn resolve-project (defn resolve-project
"Resolve `project-dir`'s deps.edn with the selected alias keywords. Returns "Resolve `project-dir`'s deps.edn with the selected alias keywords. Returns
{:roots [...] :main-opts [...] :tasks {...}}; :main-opts is the last selected {:roots [...] :main-opts [...] :tasks {...} :natives [...]}; :main-opts is the
alias's, else nil." last selected alias's, else nil; :natives are the project's + deps' :jolt/native
shared-library declarations."
([project-dir] (resolve-project project-dir [])) ([project-dir] (resolve-project project-dir []))
([project-dir alias-kws] ([project-dir alias-kws]
(let [edn (read-edn (str project-dir "/deps.edn")) (let [edn (read-edn (str project-dir "/deps.edn"))
@ -116,10 +120,11 @@
project-paths (concat (or (:paths edn) ["src"]) extra-paths) project-paths (concat (or (:paths edn) ["src"]) extra-paths)
project-roots (map #(abspath project-dir %) project-paths) project-roots (map #(abspath project-dir %) project-paths)
all-deps (merge (:deps edn) extra-deps) all-deps (merge (:deps edn) extra-deps)
dep-roots (resolve-deps all-deps project-dir)] {dep-roots :roots dep-natives :natives} (resolve-deps all-deps project-dir)]
{:roots (vec (distinct (concat project-roots dep-roots))) {:roots (vec (distinct (concat project-roots dep-roots)))
:main-opts main-opts :main-opts main-opts
:tasks (:tasks edn)}))) :tasks (:tasks edn)
:natives (vec (distinct (concat (:jolt/native edn) dep-natives)))})))
(defn has-deps-edn? [project-dir] (defn has-deps-edn? [project-dir]
(file-exists? (str project-dir "/deps.edn"))) (file-exists? (str project-dir "/deps.edn")))

View file

@ -8,10 +8,34 @@
(defn- project-dir [] (or (jolt.host/getenv "JOLT_PWD") ".")) (defn- project-dir [] (or (jolt.host/getenv "JOLT_PWD") "."))
;; Load a library's declared native shared objects (deps.edn :jolt/native) before
;; its Clojure is required, so its foreign-fn bindings resolve. Each entry is a
;; map: {:name "sqlite3" :darwin ["libsqlite3.0.dylib" ...] :linux ["libsqlite3.so.0" ...]}
;; with optional :optional (missing is fine — a feature-gated dep) and :process
;; (use the running process's symbols, e.g. libc sockets — no external file).
(defn- load-natives! [natives]
(when (seq natives)
(let [os (str/lower-case (or (System/getProperty "os.name") ""))
plat (cond (str/includes? os "mac") :darwin
(str/includes? os "win") :windows
:else :linux)]
(doseq [spec natives]
(if (:process spec)
(jolt.ffi/load-library)
(let [c (get spec plat)
cands (if (string? c) [c] (vec c))
hit (some #(when (jolt.ffi/loaded? %) %) cands)]
(when (and (nil? hit) (not (:optional spec)))
(throw (ex-info (str "required native library "
(or (:name spec) (first cands) "?")
" not found — tried " (pr-str cands) " for " (name plat))
{:native spec})))))))))
;; Apply a resolved project's roots on top of the current (jolt-core) roots so app ;; Apply a resolved project's roots on top of the current (jolt-core) roots so app
;; namespaces resolve while jolt.* stays loadable. ;; namespaces resolve while jolt.* stays loadable, then load its native deps.
(defn- apply-roots! [roots] (defn- apply-project! [{:keys [roots natives]}]
(jolt.host/set-source-roots! (vec (distinct (concat roots (jolt.host/source-roots)))))) (jolt.host/set-source-roots! (vec (distinct (concat roots (jolt.host/source-roots)))))
(load-natives! natives))
(defn- run-ns (defn- run-ns
"Require ns-name and invoke its -main with the string app args." "Require ns-name and invoke its -main with the string app args."
@ -36,27 +60,25 @@
;; run [-m NS args… | FILE] ;; run [-m NS args… | FILE]
(defn- cmd-run [more] (defn- cmd-run [more]
(let [{:keys [roots]} (deps/resolve-project (project-dir))] (apply-project! (deps/resolve-project (project-dir)))
(apply-roots! roots) (cond
(cond (= "-m" (first more)) (run-ns (second more) (drop 2 more))
(= "-m" (first more)) (run-ns (second more) (drop 2 more)) (seq more) (do (load-file (first more)) nil)
(seq more) (do (load-file (first more)) nil) :else (throw (ex-info "run needs -m NS or a FILE" {}))))
:else (throw (ex-info "run needs -m NS or a FILE" {})))))
;; -M:alias… — resolve with the aliases, run their :main-opts ;; -M:alias… — resolve with the aliases, run their :main-opts
(defn- cmd-M [arg more] (defn- cmd-M [arg more]
(let [aliases (parse-aliases arg) (let [aliases (parse-aliases arg)
{:keys [roots main-opts]} (deps/resolve-project (project-dir) aliases)] {:keys [main-opts] :as resolved} (deps/resolve-project (project-dir) aliases)]
(apply-roots! roots) (apply-project! resolved)
(if main-opts (if main-opts
(apply-main-opts main-opts more) (apply-main-opts main-opts more)
(throw (ex-info (str "alias(es) " (pr-str aliases) " have no :main-opts") {}))))) (throw (ex-info (str "alias(es) " (pr-str aliases) " have no :main-opts") {})))))
;; -A:alias… — add the aliases' paths/deps, then run the remaining argv as a command ;; -A:alias… — add the aliases' paths/deps, then run the remaining argv as a command
(defn- cmd-A [arg more] (defn- cmd-A [arg more]
(let [aliases (parse-aliases arg) (let [aliases (parse-aliases arg)]
{:keys [roots]} (deps/resolve-project (project-dir) aliases)] (apply-project! (deps/resolve-project (project-dir) aliases))
(apply-roots! roots)
(when (seq more) (run-ns (second more) (drop 2 more))))) (when (seq more) (run-ns (second more) (drop 2 more)))))
(defn- cmd-path [] (defn- cmd-path []
@ -75,12 +97,12 @@
;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}. ;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}.
(defn- run-task [name more] (defn- run-task [name more]
(let [{:keys [roots tasks]} (deps/resolve-project (project-dir)) (let [{:keys [tasks] :as resolved} (deps/resolve-project (project-dir))
task (get tasks (symbol name))] task (get tasks (symbol name))]
(cond (cond
(nil? task) (throw (ex-info (str "unknown command or task: " name) {:name name})) (nil? task) (throw (ex-info (str "unknown command or task: " name) {:name name}))
(string? task) (jolt.host/sh task) (string? task) (jolt.host/sh task)
(map? task) (do (apply-roots! roots) (apply-main-opts (:main-opts task) more)) (map? task) (do (apply-project! resolved) (apply-main-opts (:main-opts task) more))
:else (throw (ex-info (str "bad task " name) {}))))) :else (throw (ex-info (str "bad task " name) {})))))
(defn- usage [] (defn- usage []