diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5fb107..191abb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: push: branches: - master - - feature/dockerfile + - fix/break-handling jobs: Config: @@ -67,14 +67,20 @@ jobs: bb: latest - name: Update VERSION run: echo "${{ needs.Config.outputs.version }}" > resources/VERSION + - name: Compile Java + run: bb java:compile - name: Zip it run: zip -r deps-try.zip . -x ".git/*" - name: Build uberjar run: | - rm -rf .git + # remove stuff not wanted in JAR + rm -rf .git .github mv deps-try.zip .. - bb uberjar deps-try-bb.jar -m eval.deps-try + + bb bb:uberjar + ls -lah deps-try-bb.jar jar -tf deps-try-bb.jar + mv ../deps-try.zip . - name: Testrun uberjar run: bb deps-try-bb.jar -h diff --git a/.gitignore b/.gitignore index d99d61a..eaae4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.clj-kondo .lsp /VERSION +*.class diff --git a/bb.edn b/bb.edn index 18eca27..4b0ef68 100644 --- a/bb.edn +++ b/bb.edn @@ -1,7 +1,11 @@ {:deps {io.github.eval/deps-try {:local/root "."}} :bbin/bin {deps-try {:main-opts ["-m" "eval.deps-try"]}} :tasks - {gen-manifest {:doc "Generate recipe-manifest and print to stdout" + {java:compile {:doc "Compile files under java/" + :task (shell {:dir "java"} "javac --release 11 dt/JvmtiAgent.java")} + bb:uberjar {:doc "Create the app's uberjar" + :task (shell "bb uberjar deps-try-bb.jar -m eval.deps-try")} + gen-manifest {:doc "Generate recipe-manifest and print to stdout" :requires ([babashka.fs :as fs]) :task (exec 'eval.deps-try.recipe/generate&print-manifest {:exec-args diff --git a/deps.edn b/deps.edn index c2e3b84..011d2db 100644 --- a/deps.edn +++ b/deps.edn @@ -1,11 +1,11 @@ -{:paths ["src" "resources" "."] +{:paths ["src" "resources" "." "java" "libdt/build"] :deps {borkdude/edamame {:mvn/version "1.4.24"} - cider/orchard {:mvn/version "0.22.0"} + cider/orchard {:mvn/version "0.30.0"} com.bhauman/rebel-readline {:local/root "./vendor/rebel-readline/rebel-readline"} deps-try/cli {:local/root "./vendor/deps-try.cli"} deps-try/http-client {:local/root "./vendor/deps-try.http-client"} io.github.eval/compliment {:git/sha "cd9706db27d456e8940dcd1118174c94effa9358"} - org.clojure/clojure {:mvn/version "1.12.0-alpha11"} + org.clojure/clojure {:mvn/version "1.12.0"} org.clojure/tools.gitlibs {:mvn/version "2.5.197"} ;; suppress logging org.slf4j/slf4j-nop {:mvn/version "2.0.5"}} diff --git a/java/dt/JvmtiAgent.java b/java/dt/JvmtiAgent.java new file mode 100644 index 0000000..5c026ce --- /dev/null +++ b/java/dt/JvmtiAgent.java @@ -0,0 +1,24 @@ +package dt; + +/** + * Java facade for the C side of the libnrepl JVMTI agent. Currently used to + * stop threads on JDK20+. + */ +public class JvmtiAgent { + + // This will bind to Java_nrepl_JvmtiAgent_stopThread function once the + // agent containing this function will be attached. + public static native void stopThread(Thread thread, Throwable throwable); + + /** + * Forcibly stop a given thread. + */ + public static void stopThread(Thread thread) { + // ThreadDeath is deprecated, but so it Thread.stop(). We can revisit + // this when JVM actually decides to remove this class. + @SuppressWarnings("deprecation") + Throwable throwable = new ThreadDeath(); + throwable.setStackTrace(new StackTraceElement[0]); + stopThread(thread, throwable); + } +} diff --git a/libdt/Makefile b/libdt/Makefile new file mode 100644 index 0000000..528fe3e --- /dev/null +++ b/libdt/Makefile @@ -0,0 +1,49 @@ +LIB=libdt-$(OS)-$(ARCH).so +CXXFLAGS=-O3 -fno-exceptions -fvisibility=hidden +INCLUDES=-I$(JAVA_HOME)/include +LIBS=-ldl +SOURCES := $(wildcard src/*.c) + +ifeq ($(JAVA_HOME),) + export JAVA_HOME:=$(shell java -cp . JavaHome) +endif + +ARCH:=$(shell uname -m) +ifeq ($(ARCH),x86_64) + ARCH=x64 +else + ifeq ($(findstring arm,$(ARCH)),arm) + ifeq ($(findstring 64,$(ARCH)),64) + ARCH=arm64 + else + ARCH=arm32 + endif + else ifeq ($(findstring aarch64,$(ARCH)),aarch64) + ARCH=arm64 + else + ARCH=x86 + endif +endif + +OS:=$(shell uname -s) +ifeq ($(OS),Darwin) + CXXFLAGS += -arch x86_64 -arch arm64 -mmacos-version-min=10.12 + INCLUDES += -I$(JAVA_HOME)/include/darwin + OS=macos + ARCH=universal +else + CXXFLAGS += -Wl,-z,defs + LIBS += -lrt + INCLUDES += -I$(JAVA_HOME)/include/linux + OS=linux + CC=gcc +endif + +all: build/$(LIB) + +clean: + $(RM) -r build + +build/$(LIB): $(SOURCES) + mkdir -p build + $(CC) $(CXXFLAGS) $(INCLUDES) -fPIC -shared -o $@ $(SOURCES) $(LIBS) diff --git a/libdt/build/libdt-linux-arm64.so b/libdt/build/libdt-linux-arm64.so new file mode 100755 index 0000000..a1fd9f9 Binary files /dev/null and b/libdt/build/libdt-linux-arm64.so differ diff --git a/libdt/build/libdt-linux-x64.so b/libdt/build/libdt-linux-x64.so new file mode 100755 index 0000000..c9370f1 Binary files /dev/null and b/libdt/build/libdt-linux-x64.so differ diff --git a/libdt/build/libdt-macos-universal.so b/libdt/build/libdt-macos-universal.so new file mode 100755 index 0000000..ef78fc3 Binary files /dev/null and b/libdt/build/libdt-macos-universal.so differ diff --git a/libdt/src/dt_agent.c b/libdt/src/dt_agent.c new file mode 100644 index 0000000..5c39411 --- /dev/null +++ b/libdt/src/dt_agent.c @@ -0,0 +1,46 @@ +// Native agent that nREPL uses for deeply buried JDK functionality. +// Currently it is used to bring back Thread.stop() that was lost in JDK20+. + +#include +#include + +static jvmtiEnv* jvmti_env; + +// https://docs.oracle.com/en/java/javase/21/docs/specs/jvmti.html#StopThread +JNIEXPORT void JNICALL Java_dt_JvmtiAgent_stopThread +(JNIEnv* env, jclass cls, jthread thread, jobject throwable) { + jvmtiError err; + jvmtiThreadInfo threadInfo; + + err = (*jvmti_env)->GetThreadInfo(jvmti_env, thread, &threadInfo); + if (err != JVMTI_ERROR_NONE) { + printf("Error getting thread info: %d\n", err); + return; + } + + //printf("Stopping thread \"%s\" using JVMTI...\n", threadInfo.name); + + err = (*jvmti_env)->StopThread(jvmti_env, thread, throwable); + if (err != JVMTI_ERROR_NONE) { + printf("Error stopping thread: %d\n", err); + return; + } +} + +JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char* options, void* _) { + //printf("nREPL native agent loaded\n"); + + // Initialize JVMTI environment. + jint res = (*vm)->GetEnv(vm, (void**)&jvmti_env, JVMTI_VERSION_1_2); + if (res != JNI_OK) { + fprintf(stderr, "Failed to get JVMTI environment\n"); + return JNI_ERR; + } + + // Request capabilities for StopThread + jvmtiCapabilities capabilities = {0}; + capabilities.can_signal_thread = 1; + (*jvmti_env)->AddCapabilities(jvmti_env, &capabilities); + + return JNI_OK; +} diff --git a/src/eval/deps_try.clj b/src/eval/deps_try.clj index 12d8458..e609f84 100644 --- a/src/eval/deps_try.clj +++ b/src/eval/deps_try.clj @@ -100,10 +100,23 @@ ;; TODO re-enable? ;; see https://github.com/clojure/brew-install/blob/1.11.3/CHANGELOG.md #_(warn-unless-minimum-clojure-cli-version "1.11.1.1273" tdeps-version) - (let [paths (into ["."] ;; needed for clojure.java.io/resource - (string/split init-cp #":")) + (let [cp-parts (string/split init-cp #":") + ;; When running from the packaged uberjar, its path is external to the + ;; project and tools.deps deprecates such :paths entries, so it's added as + ;; a :local/root dep instead. In dev the classpath is the project's own + ;; source dirs and resolved deps, which belong in :paths as before. + ;; "." is kept for clojure.java.io/resource. + {jars true, dirs false} (if dev? + {false cp-parts} + (group-by #(string/ends-with? % ".jar") cp-parts)) + paths (into ["."] dirs) + self-deps (into {} (map-indexed (fn [i jar] + [(symbol "deps-try.self" (str "cp" i)) + {:local/root jar}]) + jars)) deps (merge - {'org.clojure/clojure {:mvn/version "1.12.0-alpha11"}} + {'org.clojure/clojure {:mvn/version "1.12.0"}} + self-deps recipe-deps requested-deps) main-args (cond-> ["--version" version] @@ -111,8 +124,11 @@ ["--recipe-ns" recipe-location] ["--recipe" recipe-location])) prepare (conj "-P"))] - (apply run-repl "-Sdeps" (str {:paths paths - :deps deps}) + (apply run-repl + "-J-Djdk.attach.allowAttachSelf" + "-J-XX:+EnableDynamicAgentLoading" + "-Sdeps" (str {:paths paths + :deps deps}) "-M" "-m" "eval.deps-try.try" main-args))) (defn- recipe-manifest-contents [{:keys [refresh] :as _cli-opts}] diff --git a/src/eval/deps_try/deps.cljc b/src/eval/deps_try/deps.cljc index ddd5eff..9eaa67b 100644 --- a/src/eval/deps_try/deps.cljc +++ b/src/eval/deps_try/deps.cljc @@ -370,6 +370,9 @@ (defn deps-available [] (->> (clojure.java.basis/current-basis) :libs + ;; synthetic deps used to put the packaged uberjar on the classpath + ;; (see eval.deps-try/start-repl!) shouldn't show as user libraries + (remove (comp #{"deps-try.self"} namespace key)) (filter (comp #(every? empty? %) :parents val))))) #?(:bb nil diff --git a/src/eval/deps_try/try.clj b/src/eval/deps_try/try.clj index daf5535..f2d9e27 100644 --- a/src/eval/deps_try/try.clj +++ b/src/eval/deps_try/try.clj @@ -9,6 +9,7 @@ [eval.deps-try.history :as history] [eval.deps-try.recipe :as recipe] [eval.deps-try.rr-service :as rebel-service] + [eval.deps-try.util :as util] [rebel-readline.clojure.line-reader :as clj-line-reader] [rebel-readline.clojure.main :as rebel-main] [rebel-readline.commands :as rebel-readline] @@ -112,7 +113,11 @@ (defn- handle-sigint-form [] `(let [thread# (Thread/currentThread)] - (clj-repl/set-break-handler! (fn [_signal#] (.stop thread#))))) + (clj-repl/set-break-handler! + (fn [_signal#] + (if (<= util/java-version 19) + (.stop thread#) + ((requiring-resolve 'eval.deps-try.util.jvmti/stop-thread) thread#)))))) (defn- recipe-instructions [{:keys [ns-only]}] (str "Recipe" (when ns-only " namespace") " successfully loaded in the REPL-history. Press arrow-up to start with the first step.")) @@ -191,8 +196,12 @@ repl-opts (cond-> {:deps-try/version (:version opts) :deps-try/data-path data-path :caught (fn [ex] - (persist-just-caught ex) - (clojure.main/repl-caught ex)) + (let [break-handled? + (= 'java.lang.ThreadDeath + (get-in (Throwable->map ex) [:via 1 :type]))] + (when-not break-handled? + (persist-just-caught ex) + (clojure.main/repl-caught ex)))) :init (fn [] (load-slow-deps!) (apply require clojure.main/repl-requires) @@ -210,6 +219,6 @@ (comment - (recipe/parse "/Users/gert/projects/deps-try/deps-try-recipes/recipes/next-jdbc/postgresql.clj") + (recipe/parse "/Users/gert/projects/deps-try/deps-try/recipes/next_jdbc/postgresql.clj") #_:end) diff --git a/src/eval/deps_try/util.clj b/src/eval/deps_try/util.clj index d0745c4..e925857 100644 --- a/src/eval/deps_try/util.clj +++ b/src/eval/deps_try/util.clj @@ -4,6 +4,19 @@ [eval.deps-try.fs :as fs] [eval.deps-try.process :as p])) +(defn parse-java-version + "Parse Java version string according to JEP 223 and return version as a number." + [] + (try (let [s (System/getProperty "java.specification.version") + [major minor _] (string/split s #"\.") + major (Integer/parseInt major)] + (if (> major 1) + major + (Integer/parseInt minor))) + (catch Exception _ 8))) + +(def java-version "Current Java version number." (parse-java-version)) + (defn duration->millis [{:keys [seconds minutes hours days weeks] :or {seconds 0 minutes 0 hours 0 days 0 weeks 0}}] (let [days (+ days (* weeks 7)) diff --git a/src/eval/deps_try/util/jvmti.clj b/src/eval/deps_try/util/jvmti.clj new file mode 100644 index 0000000..ea00cb8 --- /dev/null +++ b/src/eval/deps_try/util/jvmti.clj @@ -0,0 +1,53 @@ +(ns eval.deps-try.util.jvmti + "This namespace contains code exclusive to JDK9+ and should not be attempted to + load with earlier JDKs." + (:require + [clojure.java.io :as io]) + (:import + (com.sun.tools.attach VirtualMachine) + (java.lang ProcessHandle) + (java.nio.file Files) + (java.nio.file.attribute FileAttribute) + (dt JvmtiAgent))) + +;;; Agent unpacking + +(defonce ^:private temp-directory + (.toFile (Files/createTempDirectory "deps_try" (into-array FileAttribute [])))) + +(defn- unpack-from-jar [resource-name] + (let [path (io/file temp-directory resource-name)] + (if-let [resource (io/resource resource-name)] + (io/copy (io/input-stream resource) path) + (throw (ex-info (str "Could not find " resource-name " in resources.") {}))) + (.getAbsolutePath path))) + +(defn- macos? [] + (re-find #"(?i)mac" (System/getProperty "os.name"))) + +(defn- aarch64? [] + (re-find #"(?i)aarch64" (System/getProperty "os.arch"))) + +(def ^:private libdt-path + (delay + (let [lib (cond (macos?) "libdt-macos-universal.so" + (aarch64?) "libdt-linux-arm64.so" + :else "libdt-linux-x64.so")] + (unpack-from-jar lib)))) + +;;; Agent loading + +(defn- attach-self ^VirtualMachine [] + (VirtualMachine/attach (str (.pid (ProcessHandle/current))))) + +(defn- load-libdt-agent [] + (.loadAgentPath (attach-self) @libdt-path)) + +(def ^:private agent-loaded (delay (load-libdt-agent))) + +(defn stop-thread + "Stop the given `thread` using JVMTI StopThread function. Risks state + corruption. Should not be used prior to JDK20." + [thread] + @agent-loaded + (JvmtiAgent/stopThread thread))