diff --git a/Makefile b/Makefile index 1c8d6f0..95b4260 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ # build step. `make test` is the full gate. `make remint` rebuilds the seed after a # source change. -.PHONY: test ci values corpus unit smoke buildsmoke selfhost sci certify ffi transient infer wp devirt fieldread numwp fieldnum protoret narrow directlink numeric inline shakesmoke remint joltc joltc-release joltc-debug joltcsmoke +.PHONY: test ci values corpus unit smoke buildsmoke staticnativesmoke selfhost sci certify ffi transient infer wp devirt fieldread numwp fieldnum protoret narrow directlink numeric inline shakesmoke remint joltc joltc-release joltc-debug joltcsmoke # Full gate (dev machine). Includes the self-host byte-fixpoint, which only holds # on the same Chez that minted the seed. @@ -15,7 +15,7 @@ test: selfhost ci # lockfile) — it RUNS correctly on any Chez, but `selfhost` rebuilds it and a # different Chez version may emit byte-different (gensym/order) output, so the # byte-fixpoint is a dev-machine check, not a CI one (jolt-8479). -ci: values corpus unit smoke buildsmoke sci ffi transient infer wp devirt fieldread numwp fieldnum protoret narrow directlink numeric inline certify +ci: values corpus unit smoke buildsmoke staticnativesmoke sci ffi transient infer wp devirt fieldread numwp fieldnum protoret narrow directlink numeric inline certify @echo "OK: CI gates passed" # Self-host fixpoint: bootstrap.ss rebuild == checked-in seed. @@ -42,6 +42,11 @@ smoke: buildsmoke: @sh host/chez/build-smoke.sh +# `jolt build` cc-links a :jolt/native :static archive into the binary (the +# default), and --dynamic keeps the runtime load-shared-object path. +staticnativesmoke: + @sh host/chez/static-native-smoke.sh + # Build joltc as a self-contained native binary into target//joltc. The # binary bundles the runtime, compiler, jolt-core + stdlib source, the Chez boots, # and a launcher stub, so it runs AND compiles jolt apps with no Chez or cc on the diff --git a/docs/tools-deps.md b/docs/tools-deps.md index a5501ed..45165e5 100644 --- a/docs/tools-deps.md +++ b/docs/tools-deps.md @@ -72,11 +72,41 @@ bindings resolve. Each entry is a map — `{:name "sqlite3" :darwin the running process's own symbols, e.g. libc sockets, no external file). A project inherits its dependencies' `:jolt/native`. +### Static vs dynamic linking + +When you `joltc build`, a native lib is **statically linked** into the binary by +default if the spec carries a `:static` archive — so the executable calls the C +code with no shared object present at runtime. Add `:static` alongside the runtime +candidates: + +```clojure +{:name "sqlite3" + :static {:archive "/opt/homebrew/lib/libsqlite3.a"} ; or {:lib "sqlite3" :libdir "/usr/lib"} + :darwin ["libsqlite3.0.dylib"] ; still used by `run`/`repl` and by --dynamic + :linux ["libsqlite3.so.0"]} +``` + +`:static {:archive PATH}` force-loads the whole `.a` and is the reliable +cross-platform form. `:static {:lib NAME :libdir DIR}` links `-lNAME` (with a +`-Bstatic` preference on Linux); on macOS, which has no `-Bstatic`, prefer the +archive form. A spec with no `:static` (or a build passed `--dynamic`, or +`:jolt/build {:dynamic-natives true}`) keeps the old behavior — the shared object +is loaded at startup via `load-shared-object`. + +Static linking needs a C compiler (`cc`) on `PATH` at build time (plus the C libs +the Chez kernel links — lz4, zlib, ncurses). The distributed `joltc` bundles the +Chez kernel, so it re-links the launcher stub with the archive baked in — no +external Chez, just `cc`. Without a `cc`, a `:static` lib fails with a message +pointing you to install one or pass `--dynamic`. Keep a `:darwin`/`:linux` +candidate on any `:static` spec so `run`/`repl` (which have no static binary) can +still load it. + ## Standalone binaries `joltc build -m NS` compiles the app and every library into one executable (the -runtime + compiler are baked in). It loads the resolved `:jolt/native` libs at -startup, so an FFI app — sockets, SQLite — runs with no jolt or Chez on the path. +runtime + compiler are baked in). Resolved `:jolt/native` libs are statically +linked in (or loaded at startup — see [Native libraries](#native-libraries)), so +an FFI app — sockets, SQLite — runs with no jolt or Chez on the path. Output goes under the project's `target/`, cargo-style: `target/release/` by default and with `--opt`, `target/debug/` with `--dev` (the diff --git a/host/chez/build-joltc.ss b/host/chez/build-joltc.ss index b8c5cca..1574588 100644 --- a/host/chez/build-joltc.ss +++ b/host/chez/build-joltc.ss @@ -101,7 +101,10 @@ (register-embedded-bytes! (car spec) bv))) '((\"csv/petite.boot\" \"jolt_petite_boot\" \"jolt_petite_boot_len\") (\"csv/scheme.boot\" \"jolt_scheme_boot\" \"jolt_scheme_boot_len\") - (\"stub/launcher\" \"jolt_stub\" \"jolt_stub_len\"))))) + (\"stub/launcher\" \"jolt_stub\" \"jolt_stub_len\") + (\"csv/scheme.h\" \"jolt_scheme_h\" \"jolt_scheme_h_len\") + (\"csv/libkernel.a\" \"jolt_libkernel_a\" \"jolt_libkernel_a_len\") + (\"stub/launcher.c\" \"jolt_launcher_c\" \"jolt_launcher_c_len\"))))) (suppress-greeting #t) (scheme-start @@ -182,6 +185,13 @@ (jb-c-array (string-append bld-csv-dir "/petite.boot") (string-append jb-build "/petite_data.h") "jolt_petite_boot") (jb-c-array (string-append bld-csv-dir "/scheme.boot") (string-append jb-build "/scheme_data.h") "jolt_scheme_boot") (jb-c-array jb-stub (string-append jb-build "/stub_data.h") "jolt_stub") +;; Also bundle the Chez kernel (libkernel.a + scheme.h) and the launcher source, +;; so a `build` with :static native libs can re-link a custom stub with those +;; archives baked in — the appended-stub path can't add object code to a prebuilt +;; stub, so it relinks (build.ss bld-relink-stub). Needs the system cc at build. +(jb-c-array (string-append bld-csv-dir "/scheme.h") (string-append jb-build "/schemeh_data.h") "jolt_scheme_h") +(jb-c-array (string-append bld-csv-dir "/libkernel.a") (string-append jb-build "/libkernel_data.h") "jolt_libkernel_a") +(jb-c-array "host/chez/stub/launcher.c" (string-append jb-build "/launcherc_data.h") "jolt_launcher_c") (define jb-main-c (string-append jb-build "/main.c")) (let ((mc (open-output-file jb-main-c 'replace))) @@ -192,6 +202,9 @@ "#include \"petite_data.h\"\n" "#include \"scheme_data.h\"\n" "#include \"stub_data.h\"\n" + "#include \"schemeh_data.h\"\n" + "#include \"libkernel_data.h\"\n" + "#include \"launcherc_data.h\"\n" "int main(int argc, char *argv[]) {\n" " Sscheme_init(0);\n" " Sregister_boot_file_bytes(\"jolt\", jolt_boot, jolt_boot_len);\n" diff --git a/host/chez/build.ss b/host/chez/build.ss index 8b1b290..b4f7934 100644 --- a/host/chez/build.ss +++ b/host/chez/build.ss @@ -80,6 +80,9 @@ (cand (string-append bindir "/../lib/csv" bld-version "/" bld-machine))) cand)))) +(define (bld-have-cc?) + (> (string-length (bld-sh-capture "command -v cc")) 0)) + (define (bld-check-toolchain) (for-each (lambda (f) @@ -271,21 +274,24 @@ (define (bld-strs x) (map jolt-str-render-one (seq->list x))) ;; Emit native-library loads. `natives` is the encoded jolt seq jolt.main/ -;; encode-natives produced: each entry is ["process"] | ["req" cand…] | ["opt" cand…]. -;; `which` selects 'required (process + req) or 'optional. Required + process loads -;; are emitted before the app forms (the app's defcfn foreign-procedures resolve -;; their symbols at top-level eval during startup, so the libs must be loaded -;; first); a load-shared-object failure there is fatal — correct for a required -;; lib. Optional loads run in the scheme-start launcher, where guard catches a -;; missing lib (an optional lib's namespace is only present when the app requires -;; it, so its foreign-procedures aren't among the baked top-level forms). +;; encode-natives produced: each entry is ["process"] | ["static" form…] | +;; ["req" cand…] | ["opt" cand…]. `which` selects 'required (process + static + +;; req) or 'optional. Required loads are emitted before the app forms (the app's +;; defcfn foreign-procedures resolve their symbols at top-level eval during +;; startup, so the libs must be loaded first); a load-shared-object failure there +;; is fatal — correct for a required lib. A "static" lib is cc-linked into the +;; binary (see bld-native-link-flags), so its symbols are already in the process: +;; it loads them the same way a "process" lib does. Optional loads run in the +;; scheme-start launcher, where guard catches a missing lib (an optional lib's +;; namespace is only present when the app requires it, so its foreign-procedures +;; aren't among the baked top-level forms). (define (bld-emit-natives out natives which) (for-each (lambda (entry) (let* ((parts (bld-strs entry)) (kind (car parts)) (cands (cdr parts)) (cand-lits (fold-left (lambda (s c) (string-append s (ei-str-lit c) " ")) "" cands))) (cond - ((and (eq? which 'required) (string=? kind "process")) + ((and (eq? which 'required) (or (string=? kind "process") (string=? kind "static"))) (put-string out "(jolt-build-load-native '() #f #t)\n")) ((and (eq? which 'required) (string=? kind "req")) (put-string out (string-append "(jolt-build-load-native (list " cand-lits ") #f #f)\n"))) @@ -293,6 +299,66 @@ (put-string out (string-append "(jolt-build-load-native (list " cand-lits ") #t #f)\n")))))) (seq->list natives))) +;; The cc link fragment for the "static" natives: each archive must be FORCE-loaded +;; (the linker would otherwise drop an archive member main.c never references) and, +;; on Linux, the executable's symbols exported into the dynamic table so the +;; startup (load-shared-object #f) + foreign-procedure can resolve them (-rdynamic, +;; added by build-with-cc when this fragment is non-empty). Returns "" when no lib +;; is statically linked. Entry forms: ["static" "archive" path] | ["static" "lib" +;; name libdir]. +(define (bld-native-link-flags natives) + (fold-left + (lambda (acc entry) + (let ((parts (bld-strs entry))) + (if (string=? (car parts) "static") + (string-append acc " " (bld-one-static-link (cdr parts))) + acc))) + "" (seq->list natives))) + +;; A statically-linked native is only in the OUTPUT binary, but build step 1 +;; evaluates the app's `foreign-procedure` forms in THIS process (to register its +;; macros/vars), and Chez resolves a foreign entry eagerly. So make the archive's +;; symbols resolvable here: build a throwaway shared object from it (force-loading +;; every member) and load it. The output binary still cc-links the static archive; +;; this temp .so is build-time only. Only the "archive" form is preloaded — the +;; "lib" form names a system library the OS loader already finds by soname. +(define (bld-preload-static-natives! natives builddir) + (let ((n 0)) + (for-each + (lambda (entry) + (let ((parts (bld-strs entry))) + (when (and (string=? (car parts) "static") (string=? (cadr parts) "archive")) + (let* ((archive (caddr parts)) + (so (string-append builddir "/native-" (number->string n) + (if bld-osx? ".dylib" ".so")))) + (set! n (+ n 1)) + (bld-system + (if bld-osx? + (string-append "cc -dynamiclib -undefined dynamic_lookup -Wl,-all_load '" + archive "' -o '" so "'") + (string-append "cc -shared -Wl,--whole-archive '" archive + "' -Wl,--no-whole-archive -Wl,--unresolved-symbols=ignore-all -o '" so "'"))) + (load-shared-object so))))) + (seq->list natives)))) + +(define (bld-one-static-link form) + (let ((kind (car form))) + (cond + ((string=? kind "archive") + (let ((path (cadr form))) + (if bld-osx? + (string-append "-Wl,-force_load," path) + (string-append "-Wl,--whole-archive " path " -Wl,--no-whole-archive")))) + ((string=? kind "lib") + (let* ((lib (cadr form)) (dir (caddr form)) + (L (if (> (string-length dir) 0) (string-append "-L" dir " ") ""))) + ;; -Bstatic forces the .a over a .so of the same -l name (GNU ld). macOS's + ;; ld64 has no -Bstatic; there an :archive path is the reliable form. + (if bld-osx? + (string-append L "-l" lib) + (string-append L "-Wl,-Bstatic -l" lib " -Wl,-Bdynamic")))) + (else "")))) + ;; Walk an embed root recursively; return (resource-name . abspath) pairs, where ;; resource-name is the "/"-joined path under the root (what io/resource is asked for). (define (bld-walk-files root rel acc) @@ -338,6 +404,19 @@ ;; The self-contained path (jolt-embedded-bytes "stub/launcher") needs no csv ;; kernel files, no Chez, no cc — only the legacy cc path does. (unless (jolt-embedded-bytes "stub/launcher") (bld-check-toolchain)) + (when (> (string-length (bld-native-link-flags natives)) 0) + ;; :static natives are cc-linked into the binary, so a C compiler must be on + ;; PATH — the self-contained joltc bundles the Chez kernel (libkernel.a + + ;; scheme.h) and relinks a custom stub (see build-self-contained), but still + ;; needs the system cc for that link. Fail early (before the app's foreign- + ;; procedure forms eval below) with an actionable message. + (unless (bld-have-cc?) + (error 'jolt-build + "static native linking needs a C compiler (cc) on PATH; install one, or pass --dynamic to load the library at runtime.")) + ;; Preload static archives' symbols into this process so step 1's foreign- + ;; procedure evals resolve; the .build dir must exist first. + (bld-mkdir-p (string-append out-path ".build")) + (bld-preload-static-natives! natives (string-append out-path ".build"))) ;; 1. record app namespaces in dependency order as they finish loading. (let ((app-order '())) (set-ns-loaded-hook! @@ -460,8 +539,10 @@ ;; make-boot-file, then xxd the boot into a C array and cc-link against ;; libkernel.a. Kept so `make buildsmoke` still exercises the cc path. (if (jolt-embedded-bytes "stub/launcher") - (build-self-contained entry-ns out-path mode builddir flat-ss flat-so boot) - (build-with-cc entry-ns out-path mode builddir flat-ss flat-so boot boot-h main-c))))))) + (build-self-contained entry-ns out-path mode builddir flat-ss flat-so boot + (bld-native-link-flags natives)) + (build-with-cc entry-ns out-path mode builddir flat-ss flat-so boot boot-h main-c + (bld-native-link-flags natives)))))))) ;; --- self-contained link (in-process compile + append the boot to the stub) --- ;; compile-file runs with a FRESH scheme-environment as the interaction @@ -470,7 +551,7 @@ ;; runs) must bind to the kernel primitive — a clean env gives exactly that, the ;; same as the legacy path compiling in a fresh Chez process. Without it the boot ;; dies at startup with "variable error is not bound". -(define (build-self-contained entry-ns out-path mode builddir flat-ss flat-so boot) +(define (build-self-contained entry-ns out-path mode builddir flat-ss flat-so boot native-link) (let ((petite (string-append builddir "/petite.boot")) (scheme (string-append builddir "/scheme.boot"))) (jolt-spill-embedded! "csv/petite.boot" petite) @@ -479,8 +560,14 @@ (parameterize ((interaction-environment (copy-environment (scheme-environment)))) (compile-file flat-ss flat-so) (make-boot-file boot '() petite scheme flat-so)) + ;; The stub is the native launcher the boot is appended to. With no :static + ;; natives it's the prebuilt one bundled in joltc (no cc needed); with :static + ;; natives it's re-linked here from the bundled kernel + launcher source so the + ;; archives are baked in and their symbols resolve in the running binary. + (if (> (string-length native-link) 0) + (bld-relink-stub builddir native-link out-path) + (jolt-spill-embedded! "stub/launcher" out-path)) ;; link: stub bytes ++ boot ++ frame, then make it executable. - (jolt-spill-embedded! "stub/launcher" out-path) (jolt-append-payload! out-path (read-file-bytes boot)) (jolt-chmod-755 out-path) (display (string-append "jolt build: wrote " out-path "\n")) @@ -489,8 +576,27 @@ "jolt build: note — on macOS this binary is unsigned; to share it,\n" " `xattr -d com.apple.quarantine " out-path "` on the target, or sign it.\n"))))) +;; Re-link the launcher stub with the app's static native archives baked in, to +;; OUT-PATH. The self-contained joltc bundles the Chez kernel (libkernel.a), +;; header, and launcher source; spill them and drive the system cc — the same link +;; build-joltc.ss ran once at joltc-build time, plus the force-load archive flags +;; (native-link) and, on Linux, -rdynamic so the baked-in symbols stay dlsym- +;; visible for (load-shared-object #f) + foreign-procedure at startup. +(define (bld-relink-stub builddir native-link out-path) + (let ((h (string-append builddir "/scheme.h")) + (lk (string-append builddir "/libkernel.a")) + (lc (string-append builddir "/launcher.c"))) + (jolt-spill-embedded! "csv/scheme.h" h) + (jolt-spill-embedded! "csv/libkernel.a" lk) + (jolt-spill-embedded! "stub/launcher.c" lc) + (display "jolt build: relinking launcher stub with static native libraries\n") + (bld-system (string-append + "cc -O2 " (if bld-osx? "" "-rdynamic ") + "-I'" builddir "' '" lc "' '" lk "' -o '" out-path "' " + (bld-link-libs) native-link)))) + ;; --- legacy cc link (dev bin/joltc): fresh Chez compile + xxd + cc ------------ -(define (build-with-cc entry-ns out-path mode builddir flat-ss flat-so boot boot-h main-c) +(define (build-with-cc entry-ns out-path mode builddir flat-ss flat-so boot boot-h main-c native-link) (display (string-append "jolt build: compiling " entry-ns " (" mode " mode)\n")) (let ((cs (string-append builddir "/compile.ss"))) (let ((p (open-output-file cs 'replace))) @@ -520,9 +626,13 @@ " int status = Sscheme_start(argc, (const char **)argv);\n" " Sscheme_deinit();\n return status;\n}\n")) (close-port mc)) + ;; -rdynamic (Linux) exports the executable's symbols into the dynamic table so + ;; a statically-linked native lib's symbols resolve via (load-shared-object #f) + ;; at startup. macOS keeps unstripped executable symbols dlsym-visible already. (bld-system (string-append - "cc -O2 -I'" bld-csv-dir "' '" main-c "' '" bld-csv-dir "/libkernel.a' " - "-o '" out-path "' " (bld-link-libs))) + "cc -O2 " (if (and (not bld-osx?) (> (string-length native-link) 0)) "-rdynamic " "") + "-I'" bld-csv-dir "' '" main-c "' '" bld-csv-dir "/libkernel.a' " + "-o '" out-path "' " (bld-link-libs) native-link)) (display (string-append "jolt build: wrote " out-path "\n"))) (def-var! "jolt.host" "build-binary" diff --git a/host/chez/joltc-selfbuild-smoke.sh b/host/chez/joltc-selfbuild-smoke.sh index 7681e10..5fae720 100644 --- a/host/chez/joltc-selfbuild-smoke.sh +++ b/host/chez/joltc-selfbuild-smoke.sh @@ -73,4 +73,36 @@ if [ "$got" != "$want" ]; then echo "--- got ----"; echo "$got" exit 1 fi -echo "joltc self-build smoke: passed (joltc runs + builds a working app with no external toolchain)" + +# 5. Static native linking through the distributed joltc: it bundles the Chez +# kernel, so with the system cc (but still no external Chez) it re-links a stub +# that bakes a :jolt/native :static archive into the app. The app then calls the +# C function with the archive removed from disk. Uses the normal PATH so cc — and +# the kernel's link deps (lz4/…) — are found, but Chez stays out of the build. +napp="$(mktemp -d)/native-app" +mkdir -p "$napp/src/app" +printf 'int jolt_static_answer(void){return 42;}\n' > "$napp/greet.c" +cc -c "$napp/greet.c" -o "$napp/greet.o" && ar rcs "$napp/libgreet.a" "$napp/greet.o" +cat > "$napp/src/app/core.clj" <<'EOF' +(ns app.core (:require [jolt.ffi :as ffi])) +(ffi/defcfn answer "jolt_static_answer" [] :int) +(defn -main [& _] (println "answer:" (answer))) +EOF +cat > "$napp/deps.edn" </dev/null 2>&1; then + echo " FAIL: static native build via distributed joltc exited non-zero" + rm -rf "$(dirname "$napp")"; exit 1 +fi +rm -f "$napp/libgreet.a" "$napp/greet.o" # nothing to load at runtime +got_n="$(cd / && "$nout" 2>&1)" +rm -rf "$(dirname "$napp")" +if [ "$got_n" != "answer: 42" ]; then + echo " FAIL: static-linked app (via distributed joltc) output mismatch" + echo "--- got ----"; echo "$got_n"; exit 1 +fi +echo "joltc self-build smoke: passed (joltc runs + builds a working app with no external toolchain, incl. static native linking)" diff --git a/host/chez/static-native-smoke.sh b/host/chez/static-native-smoke.sh new file mode 100644 index 0000000..c8aa019 --- /dev/null +++ b/host/chez/static-native-smoke.sh @@ -0,0 +1,115 @@ +#!/bin/sh +# static-native smoke: a project's :jolt/native lib with a :static archive is +# LINKED INTO the built binary (the default), so the binary calls the C function +# with no shared object on disk at runtime. --dynamic keeps the old behavior — +# load a shared object at runtime. +root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$root" + +# Preflight: needs cc (to build the test libs AND to cc-link the app) + Chez's +# kernel dev files, same as build-smoke. Skip otherwise (CI on a distro package). +csv="$JOLT_CHEZ_CSV" +if [ -z "$csv" ]; then + chez_bin="$(command -v chez || command -v scheme || command -v petite || true)" + if [ -n "$chez_bin" ]; then + base="$(cd "$(dirname "$chez_bin")/.." 2>/dev/null && pwd)" + for d in "$base"/lib/csv*/*/; do + [ -f "${d}libkernel.a" ] && csv="${d%/}" && break + done + fi +fi +if ! command -v cc >/dev/null 2>&1 || [ -z "$csv" ] || [ ! -f "$csv/scheme.h" ] || [ ! -f "$csv/libkernel.a" ]; then + echo "static-native smoke: skipped (Chez kernel dev files or C compiler not available)" + exit 0 +fi +export JOLT_CHEZ_CSV="$csv" + +case "$(uname -s)" in + Darwin) plat=":darwin"; soext="dylib"; shared="-dynamiclib" ;; + *) plat=":linux"; soext="so"; shared="-shared" ;; +esac + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +app="$work/app" +mkdir -p "$app/src/app" + +# 1. a trivial C library, built BOTH as a static archive and a shared object. +cat > "$work/greet.c" <<'EOF' +int jolt_static_answer(void) { return 42; } +EOF +cc -c "$work/greet.c" -o "$work/greet.o" +ar rcs "$work/libgreet.a" "$work/greet.o" +cc $shared "$work/greet.c" -o "$work/libgreet.$soext" + +# 2. an app that binds that symbol via FFI. +cat > "$app/src/app/core.clj" <<'EOF' +(ns app.core + (:require [jolt.ffi :as ffi])) +(ffi/defcfn answer "jolt_static_answer" [] :int) +(defn -main [& _] + (println "answer:" (answer))) +EOF + +out="$work/app-bin" + +# --- default: static link --------------------------------------------------- +# A static-only spec (no runtime candidate): the build resolves the symbol by +# preloading the archive, and the binary links it in — nothing to load at runtime. +cat > "$app/deps.edn" <"$work/build.log" 2>&1; then + echo " FAIL: jolt build (static) exited non-zero"; cat "$work/build.log"; exit 1 +fi +[ -x "$out" ] || { echo " FAIL: no executable produced"; exit 1; } +# A static lib emits a process-symbol load (its archive is in-process), not a +# dlopen of the shared object. +if ! grep -q "jolt-build-load-native '() #f #t" "$out.build/flat.ss"; then + echo " FAIL: static native did not emit a process-symbol load"; exit 1 +fi +if grep -q "libgreet.$soext" "$out.build/flat.ss"; then + echo " FAIL: static native baked a runtime shared-object load"; exit 1 +fi +# Remove BOTH libs: a static-linked symbol lives in the binary, nothing to load. +rm -f "$work/libgreet.a" "$work/libgreet.$soext" "$work/greet.o" +got="$(cd / && "$out" 2>&1)" +if [ "$got" != "answer: 42" ]; then + echo " FAIL: static-linked binary output mismatch" + echo "--- want ---"; echo "answer: 42"; echo "--- got ----"; echo "$got"; exit 1 +fi + +# --- --dynamic: runtime load ------------------------------------------------ +# Rebuild the shared object (static phase deleted it) and give the spec a runtime +# candidate; --dynamic loads it at startup instead of linking the archive. +cc $shared "$work/greet.c" -o "$work/libgreet.$soext" +cat > "$app/deps.edn" <"$work/build.log" 2>&1; then + echo " FAIL: jolt build --dynamic exited non-zero"; cat "$work/build.log"; exit 1 +fi +# --dynamic loads the shared object at runtime. +if ! grep -q "libgreet.$soext" "$out.build/flat.ss"; then + echo " FAIL: --dynamic did not emit a runtime shared-object load"; exit 1 +fi +got="$(cd / && "$out" 2>&1)" +if [ "$got" != "answer: 42" ]; then + echo " FAIL: --dynamic binary output mismatch (shared object present)" + echo "--- got ----"; echo "$got"; exit 1 +fi +# With the shared object gone, a --dynamic binary must FAIL — proving the symbol +# was loaded at runtime, not baked in. +rm -f "$work/libgreet.$soext" +rc=0; { (cd / && exec "$out"); } >/dev/null 2>&1 || rc=$? +if [ "$rc" -eq 0 ]; then + echo " FAIL: --dynamic binary still ran with its shared object removed"; exit 1 +fi + +echo "static-native smoke: passed (static default + --dynamic runtime load)" diff --git a/jolt-core/jolt/main.clj b/jolt-core/jolt/main.clj index 6aa3542..a12f81c 100644 --- a/jolt-core/jolt/main.clj +++ b/jolt-core/jolt/main.clj @@ -28,7 +28,11 @@ (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))) + ;; A :static spec has no runtime shared object (it's linked into a + ;; built binary), so an interpreted `run`/`repl` has nothing to load — + ;; skip it rather than fail. Its foreign calls only resolve in a static + ;; build; document a dynamic candidate too to use it under `run`. + (when (and (nil? hit) (not (:optional spec)) (not (:static spec))) (throw (ex-info (str "required native library " (or (:name spec) (first cands) "?") " not found — tried " (pr-str cands) " for " (name plat)) @@ -174,18 +178,38 @@ ;; --direct-link (or deps.edn :jolt/build {:direct-link true}) opts into closed-world ;; direct-linking: app->app calls bind directly, giving up runtime redefinition of ;; those vars and eval/load-string. Off by default — release stays dynamically linked. +;; The static-link description of a :jolt/native spec for this platform, or nil. +;; :static may be flat ({:archive "…"} / {:lib "z" :libdir "…"}) or per-platform +;; ({:darwin {…} :linux {…}}). Returns a vector build.ss reads and wraps in the +;; platform's force-load flags: ["archive" abspath] or ["lib" name libdir]. +(defn- static-link-spec [spec plat] + (when-let [s (:static spec)] + (let [p (get s plat) + s (if (map? p) p s)] + (cond + (:archive s) ["archive" (:archive s)] + (:lib s) ["lib" (:lib s) (or (:libdir s) "")] + :else nil)))) + ;; Encode a deps.edn :jolt/native spec for the build launcher, resolving the ;; current platform's candidate list now (the binary runs on this OS). Each entry -;; becomes a vector the launcher (build.ss) reads: ["process"] for the running -;; binary's own symbols, else ["req"|"opt" cand…] to try in turn. -(defn- encode-natives [natives] +;; becomes a vector the launcher (build.ss) reads: +;; ["process"] — the running binary's own symbols (libc) +;; ["static" form …] — the lib's archive, cc-linked into the binary; its +;; symbols load from the process (default when :static +;; is present and --dynamic wasn't passed) +;; ["req"|"opt" cand…] — load a shared object at runtime, trying each in turn +;; dynamic? forces the runtime path for every lib (the --dynamic build flag). +(defn- encode-natives [natives dynamic?] (let [plat (current-platform)] (vec (for [spec natives] - (if (:process spec) - ["process"] - (let [c (get spec plat) - cands (if (string? c) [c] (vec c))] - (into [(if (:optional spec) "opt" "req")] cands))))))) + (let [static (and (not dynamic?) (static-link-spec spec plat))] + (cond + (:process spec) ["process"] + static (into ["static"] static) + :else (let [c (get spec plat) + cands (if (string? c) [c] (vec c))] + (into [(if (:optional spec) "opt" "req")] cands)))))))) (defn- cmd-build [more] (let [{:keys [project-paths embed-dirs build] :as resolved} @@ -219,7 +243,11 @@ (nil? o) (str pdir "/target/" (if (= mode "dev") "debug" "release") "/" proj) (str/starts-with? o "/") o :else (str pdir "/" o))) - natives (encode-natives (:natives resolved)) + ;; :jolt/native libs with a :static archive are cc-linked into the + ;; binary by default; --dynamic (or deps.edn :jolt/build {:dynamic-natives + ;; true}) keeps the old behavior — load a shared object at runtime. + dynamic-natives? (boolean (or (some #{"--dynamic"} more) (:dynamic-natives build))) + natives (encode-natives (:natives resolved) dynamic-natives?) ;; closed-world direct-linking is opt-in: the --direct-link flag or a ;; deps.edn :jolt/build {:direct-link true}. Off otherwise. direct-link? (boolean (or (some #{"--direct-link"} more) (:direct-link build))) @@ -269,7 +297,7 @@ (println "usage: jolt [args]") (println " run -m NS [args] resolve deps.edn, load NS, call its -main") (println " run FILE load a Clojure file") - (println " build -m NS [-o OUT] [--opt|--dev] [--direct-link] [--tree-shake] compile a standalone binary") + (println " build -m NS [-o OUT] [--opt|--dev] [--direct-link] [--tree-shake] [--dynamic] compile a standalone binary") (println " -M:alias [args] run the alias's :main-opts") (println " -A:alias [args] add the alias's paths/deps") (println " repl start a line REPL")