This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repositoryhttps://gitbox.apache.org/repos/asf/ofbiz-plugins.git
The following commit(s) were added to refs/heads/trunk by this push:
new fe1a266d6 Add devreload plugin inside plugins directory (#316)
fe1a266d6 is described below
commit fe1a266d60425b3b73469f185cb68bc27012495d
Author: Ashish Vijaywargiya<[email protected]>
AuthorDate: Fri Jul 10 19:16:19 2026 +0530
Add devreload plugin inside plugins directory (#316)
devreload plugin will give us option to work in Java files(events and
services) and services.xml files without restarting ofbiz.
---
devreload/LICENSE | 201 +++++
devreload/README.md | 56 ++
devreload/build.gradle | 187 +++++
devreload/gradle.lockfile | 4 +
devreload/ofbiz-component.xml | 30 +
.../apache/ofbiz/devreload/DevReloadContainer.java | 860 +++++++++++++++++++++
.../org/apache/ofbiz/devreload/HotSwapAgent.java | 109 +++
7 files changed, 1447 insertions(+)
diff --git a/devreload/LICENSE b/devreload/LICENSE
new file mode 100644
index 000000000..261eeb9e9
--- /dev/null
+++ b/devreload/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/devreload/README.md b/devreload/README.md
new file mode 100644
index 000000000..246d9495c
--- /dev/null
+++ b/devreload/README.md
@@ -0,0 +1,56 @@
+# devreload
+
+Development-only hot-reload for OFBiz. Edit a Java service/event method, a
+`services.xml` file, or add a brand-new method, and the change is live in
under a
+second — no restart, ever.
+
+The plugin is entirely self-contained: dropping this directory into a checkout
(or
+removing it) has zero effect on the rest of OFBiz either way.
+
+## Requirements
+
+This plugin needs a **DCEVM (Dynamic Code Evolution Virtual Machine)** JVM to
run.
+Set it up once, before running `./gradlew ofbizDev --no-watch-fs`:
+
+1. Get a DCEVM-patched JVM. Easiest source: JetBrains Runtime (JBR), bundled
with
+ IntelliJ IDEA under `<IDE install>/jbr` (`.../Contents/jbr` on macOS).
Standalone
+ DCEVM builds work too.
+2. Point at it — set the `DCEVM_HOME` env var (e.g. in your shell profile) so
every
+ future run picks it up automatically, or pass `-PdcevmHome=/path/to/jvm`
each time
+ instead.
+
+Add this to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) so it's always
set:
+
+```
+export DCEVM_HOME="/Applications/IntelliJ IDEA.app/Contents/jbr"
+```
+
+(adjust the path to wherever your DCEVM-patched JVM lives — on macOS this is
IntelliJ's
+bundled JetBrains Runtime by default). Reload your shell (or run `source
~/.zshrc`) and
+every future `./gradlew ofbizDev --no-watch-fs` picks it up automatically,
with nothing
+else to set.
+
+## Usage
+
+```
+./gradlew ofbizDev --no-watch-fs
+```
+
+This is the only supported command — always run it exactly like this. It boots
OFBiz
+and everything hot-swaps live, no restart: method-body edits, `services.xml`
changes,
+and structural changes (new/removed methods or fields, changed signatures)
alike.
+
+`--no-watch-fs` disables Gradle's own file-system watching, which otherwise
competes
+with this plugin's `WatchService` for the same macOS per-process
directory-watch
+ceiling on a full checkout — without it, some directories can silently go
unwatched.
+It's harmless to include even on a smaller/scoped checkout, so it's part of
the one
+command to remember.
+
+If DCEVM isn't set up, it fails immediately — before compiling or booting
anything —
+with the same setup steps as above.
+
+Scope to specific components for a faster startup:
+
+```
+./gradlew ofbizDev --no-watch-fs -Photreload.components=devreload,party
+```
diff --git a/devreload/build.gradle b/devreload/build.gradle
new file mode 100644
index 000000000..148fcb902
--- /dev/null
+++ b/devreload/build.gradle
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ *http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Optional -Photreload.components=compA,compB, forwarded as
+// -Dofbiz.hotreload.components to DevReloadContainer, which then only watches
those
+// components' servicedef/source directories instead of every component in the
checkout.
+// See DevReloadContainer's `allowedComponents` javadoc for why: macOS's
WatchService has
+// a practical per-process ceiling on watched directories well under a typical
ulimit,
+// and a checkout the size of OFBiz trunk (~50+ components) can exceed it.
+static List<String> hotreloadComponentArgs(Project project) {
+ String components = project.findProperty('hotreload.components')
+ return components ?
["-Dofbiz.hotreload.components=${components}".toString()] : []
+}
+
+// Optional -Photreload.watchBuildOutput=true, forwarded as
+// -Dofbiz.hotreload.watchBuildOutput to DevReloadContainer. Off by default:
watching
+// build/classes/java/main only matters for picking up externally-produced
.class files
+// (e.g. `./gradlew -t classes` running in a second terminal) -- in-process
compiles hot-swap
+// directly and never need it -- and unlike source/servicedef watching, that
tree isn't
+// narrowed by -Photreload.components, so on a full checkout it roughly
doubles the total
+// directories watched for no benefit to the common (in-process-only) workflow.
+static List<String> hotreloadWatchBuildOutputArgs(Project project) {
+ Object watchBuildOutput =
project.findProperty('hotreload.watchBuildOutput')
+ return watchBuildOutput ?
["-Dofbiz.hotreload.watchBuildOutput=${watchBuildOutput}".toString()] : []
+}
+
+// DevReloadContainer's own in-process-compile output directory. Deliberately
separate
+// from Gradle's own build/classes/java/main (see DevReloadContainer's
hotReloadOutputDir
+// field javadoc for why) and placed ahead of the normal runtime classpath
below, so a
+// class compiled here but never yet loaded in the running JVM picks up the
live version
+// on its first load too, not just already-loaded ones. Passed down to the JVM
as a system
+// property (see commonHotReloadJvmArgs) rather than hardcoded independently
on the Java
+// side too, so this path exists as a single source of truth.
+static File hotReloadOutputDir(Project project) {
+ return new File(project.rootDir, 'build/devreload/classes')
+}
+
+// Base JVM args for ofbizDev: the hot-reload activation flags, the
output-directory
+// location, and the optional component/watch scoping args.
+// -XX:+AllowEnhancedClassRedefinition is added separately by the caller, once
a
+// DCEVM-patched JVM has actually been resolved (required -- see below).
+static List<String> commonHotReloadJvmArgs(Project project) {
+ List<String> args = ['-Dofbiz.hotreload=true',
'-Djdk.attach.allowAttachSelf=true',
+
"-Dofbiz.hotreload.outputDir=${hotReloadOutputDir(project).absolutePath}".toString()]
+ args += hotreloadComponentArgs(project)
+ args += hotreloadWatchBuildOutputArgs(project)
+ return args
+}
+
+// Locates a DCEVM-patched `java` executable from an explicit -PdcevmHome=...
or
+// DCEVM_HOME env var -- deliberately not auto-detected by guessing per-OS IDE
install
+// paths (IntelliJ's JBR bundles DCEVM and is a convenient source of one, but
its
+// install location varies too much across OS/Toolbox/version to guess
reliably). One
+// explicit way in is easier to reason about, document, and keep working than
several
+// silent path guesses that can each go stale independently. `ofbizDev` below
requires
+// this to resolve to something -- it fails fast with setup instructions
otherwise.
+//
+// Two ways to set it once and forget it, so every future plain `./gradlew
ofbizDev`
+// just works with no flag: export DCEVM_HOME in a shell profile (picked up via
+// System.getenv() below), or add a `dcevmHome=/path/to/jvm` line to
+// `~/.gradle/gradle.properties` (GRADLE_USER_HOME -- picked up via
project.findProperty()
+// below, same as -PdcevmHome on the command line, just persisted instead of
retyped).
+// The latter needs the Gradle property name `dcevmHome`, not `DCEVM_HOME` --
gradle.properties
+// holds Gradle properties, not env vars, so the two names aren't
interchangeable there.
+def resolveDcevmJavaExecutable = {
+ String home = project.findProperty('dcevmHome') ?:
System.getenv('DCEVM_HOME')
+ if (!home) {
+ return null
+ }
+ String exeName =
org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'java.exe' :
'java'
+ File exe = new File(home, "bin/${exeName}")
+ if (!exe.exists()) {
+ // macOS JDK/JBR app bundles nest an extra Contents/Home -- e.g.
IntelliJ's bundled
+ // JetBrains Runtime lives at "<IDE
install>/Contents/jbr/Contents/Home/bin/java",
+ // one level deeper than the jbr folder itself (which is what
README/error-message
+ // guidance points DCEVM_HOME/-PdcevmHome at). Fall back to that
layout before
+ // giving up, so the documented "point at <IDE install>/Contents/jbr"
guidance
+ // actually resolves on macOS instead of silently finding nothing.
+ exe = new File(home, "Contents/Home/bin/${exeName}")
+ }
+ return exe.exists() ? exe.absolutePath : null
+}
+
+// Convenience task: `./gradlew ofbizDev` starts OFBiz with hot-reload enabled
+// (-Dofbiz.hotreload=true), plus the JVM flag DevReloadContainer's
self-attaching
+// HotSwapAgent needs (-Djdk.attach.allowAttachSelf=true). Registered on the
root
+// project, mirroring the classpath/mainClass wiring of the built-in `ofbiz`
command
+// task, so this plugin needs no change to the root build.gradle to be usable.
+//
+// A DCEVM-patched JVM is required, not optional: stock-JDK hot-reload used to
be a
+// supported fallback, but a stock JVM's redefineClasses can't hot-swap a
new/removed
+// method or field anyway (see DevReloadContainer's class-level javadoc), so
silently
+// running in that degraded mode just defers the surprise to whenever someone
adds a
+// method. Simple by design: if DCEVM_HOME/-PdcevmHome resolves to a real JVM,
this task
+// just runs -- no other flag, no mode to pick. If it doesn't resolve, running
`ofbizDev`
+// fails immediately with setup instructions, before OFBiz attempts to boot --
but the
+// failure has to be raised from a doFirst task action, not directly in this
+// configuration block: this block runs whenever the project is evaluated,
which in this
+// build happens on every `./gradlew <anything>` invocation, not just when
`ofbizDev`
+// itself is requested (confirmed empirically -- even `./gradlew help`
realizes this
+// task, most likely because the Develocity plugin applied in settings.gradle
touches
+// every registered task for build-scan data). Throwing here unconditionally
would break
+// every Gradle command whenever DCEVM isn't set up, which is exactly the
"zero effect on
+// anyone not using it" guarantee this plugin promises everywhere else (see
README).
+//
+// JavaExec's `executable` property is pointed at the resolved DCEVM JVM
binary during
+// task configuration -- no toolchain is configured anywhere in this project
for that to
+// conflict with. One non-obvious wrinkle: `executable` has to be set directly
in the
+// task's configuration block, not from a task action (e.g. inside doFirst).
Doing it
+// from a task action fails with "Toolchain from executable property does not
match
+// toolchain from javaLauncher property" -- by the time a task action runs,
javaLauncher's
+// convention has already been finalized for execution, and overriding
executable at that
+// point conflicts with it. Setting it during configuration, before that
convention
+// finalizes, works cleanly. That's also *why* the DCEVM lookup itself still
has to happen
+// eagerly in this block (only the throw is deferred to doFirst): `executable`
can't be
+// set any later.
+rootProject.tasks.register('ofbizDev', JavaExec) {
+ group = 'OFBiz Server Commands'
+ description = 'Start OFBiz with hot-reload (requires a DCEVM-patched JVM
-- see README). Java source, ' +
+ 'services.xml, and structural changes (new/removed methods or
fields, changed signatures) all ' +
+ 'reload live with no restart. Point -PdcevmHome=/path/to/jvm or
set DCEVM_HOME to enable; fails ' +
+ 'fast with setup instructions if neither resolves. Optionally
scope to specific components with ' +
+ '-Photreload.components=compA,compB, or watch
build/classes/java/main too (for externally-produced ' +
+ '.class files) with -Photreload.watchBuildOutput=true.'
+ classpath = rootProject.files(hotReloadOutputDir(project)) +
rootProject.sourceSets.main.runtimeClasspath
+ mainClass = rootProject.application.mainClass
+ List<String> jvmArgsList =
rootProject.application.applicationDefaultJvmArgs + commonHotReloadJvmArgs(project)
+ String dcevmJava = resolveDcevmJavaExecutable()
+ if (dcevmJava) {
+ // Only wired up when actually needed: compiling everything first just
to fail in
+ // doFirst afterwards would make the "no DCEVM" error slow to reach on
a cold build.
+ dependsOn rootProject.tasks.named('classes')
+ jvmArgsList = jvmArgsList + ['-XX:+AllowEnhancedClassRedefinition']
+ logger.lifecycle("ofbizDev: DCEVM-patched JVM resolved at
${dcevmJava}")
+ executable = dcevmJava
+ } else {
+ // Deferred to a task action (doFirst) instead of thrown directly
here: this
+ // configuration block runs whenever this project is evaluated, which
in this build
+ // happens for every `./gradlew <anything>` invocation, not just when
`ofbizDev`
+ // itself is requested (confirmed empirically -- even `./gradlew help`
realizes this
+ // task's configuration block, most likely due to the Develocity
plugin applied in
+ // settings.gradle touching every registered task for build-scan
data). Throwing here
+ // directly would break every Gradle command whenever DCEVM isn't set
up, violating
+ // this plugin's "zero effect on anyone not using it" guarantee (see
README). A
+ // doFirst block, by contrast, only actually runs when `ofbizDev` is
the task Gradle
+ // is executing.
+ String attemptedHome = project.findProperty('dcevmHome') ?:
System.getenv('DCEVM_HOME')
+ String detail = attemptedHome
+ ? "DCEVM_HOME/-PdcevmHome is set to '${attemptedHome}', but no
'bin/java' (or macOS " +
+ "'Contents/Home/bin/java') was found there -- double-check
the path."
+ : "Neither -PdcevmHome nor the DCEVM_HOME env var is set."
+ doFirst {
+ throw new GradleException(
+ "ofbizDev requires a DCEVM-patched JVM -- plain stock-JDK
hot-reload is not supported.\n\n" +
+ "${detail}\n\n" +
+ "To fix this:\n" +
+ " 1. Get a DCEVM-patched JVM. The easiest source is JetBrains
Runtime (JBR), which " +
+ "bundles DCEVM and ships with IntelliJ IDEA under '<IDE
install>/jbr' " +
+ "('.../Contents/jbr' on macOS). Standalone DCEVM builds work
too.\n" +
+ " 2. Point at it -- set the DCEVM_HOME env var once (e.g. in
your shell profile, so " +
+ "every future run picks it up automatically) or pass
-PdcevmHome=/path/to/jvm each " +
+ "time.\n\n" +
+ "See plugins/devreload/README.md for the full setup
guide.")
+ }
+ }
+ jvmArgs(jvmArgsList)
+}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/devreload/gradle.lockfile b/devreload/gradle.lockfile
new file mode 100644
index 000000000..b9b85588a
--- /dev/null
+++ b/devreload/gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/devreload/ofbiz-component.xml b/devreload/ofbiz-component.xml
new file mode 100644
index 000000000..c9ecab1fb
--- /dev/null
+++ b/devreload/ofbiz-component.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<ofbiz-component name="devreload" enabled="true"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/ofbiz-component.xsd">
+ <resource-loader name="main" type="component"/>
+
+ <!-- Development hot-reload container: a no-op unless started with
+ -Dofbiz.hotreload=true, so it is safe to leave enabled in every
+ environment. See README.md for usage. -->
+ <container name="dev-reload-container" loaders="main"
class="org.apache.ofbiz.devreload.DevReloadContainer"/>
+</ofbiz-component>
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
new file mode 100644
index 000000000..1f9539cb2
--- /dev/null
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
@@ -0,0 +1,860 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ *http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.devreload;
+
+import java.io.IOException;
+import java.lang.instrument.ClassDefinition;
+import java.lang.instrument.Instrumentation;
+import java.lang.management.ManagementFactory;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.ClosedWatchServiceException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import javax.tools.JavaCompiler;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.ofbiz.base.component.ComponentConfig;
+import org.apache.ofbiz.base.config.GenericConfigException;
+import org.apache.ofbiz.base.container.Container;
+import org.apache.ofbiz.base.container.ContainerException;
+import org.apache.ofbiz.base.start.StartupCommand;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.cache.UtilCache;
+
+/**
+ * Development-only container that watches Java sources/classes and {@code
services.xml}
+ * files and applies changes to a running OFBiz instance without a restart.
+ *
+ * <h2>Activation</h2>
+ * Add {@code -Dofbiz.hotreload=true -Djdk.attach.allowAttachSelf=true} to
your JVM
+ * arguments, then start OFBiz normally (or run {@code ./gradlew ofbizDev},
provided by
+ * this plugin, which sets both automatically — the only supported command).
That Gradle
+ * task requires a DCEVM-patched JVM and fails fast with setup instructions if
none is
+ * resolvable; see "Structural changes" below for why.
+ *
+ * <h2>How Java hot-reload works</h2>
+ * <ol>
+ * <li>At startup this container self-attaches {@link HotSwapAgent} to the
current JVM
+ * via the Attach API, obtaining a live {@link Instrumentation} instance
— no
+ * {@code -javaagent} flag needed.</li>
+ * <li>A {@link WatchService} thread monitors every component's {@code
src/main/java}
+ * directory. On save, changed {@code .java} files are compiled
in-process (a JDK,
+ * not just a JRE, is required) into {@code build/devreload/classes/} —
a directory
+ * private to this plugin, deliberately <em>not</em> Gradle's own
+ * {@code build/classes/java/main/}. Writing into Gradle's managed
output would
+ * leave its incremental-build cache unaware of the change: if a source
file is
+ * later reverted to content Gradle already has a snapshot for (e.g. via
+ * {@code git checkout}) while no hot-reload session is watching,
Gradle's next
+ * {@code compileJava} would see matching input and skip recompiling,
silently
+ * leaving the stale hot-swapped {@code .class} file in place. This
plugin's output
+ * directory is cleared and recreated on every {@link #init}, so a fresh
run never
+ * inherits a previous session's bytecode, and is placed ahead of
Gradle's own
+ * output on the classpath (see {@code build.gradle}) so it always wins
when a class
+ * exists in both places. Running {@code ./gradlew -t classes}
externally in a
+ * second terminal still works — the same WatchService also monitors
Gradle's own
+ * output directory directly for externally-produced {@code .class}
files, when
+ * {@code -Dofbiz.hotreload.watchBuildOutput=true}.</li>
+ * <li>Changes are debounced for 300 ms so a single compile run is handled
as one batch.
+ * Each changed class already loaded in the JVM is updated in place via
+ * {@link Instrumentation#redefineClasses}, the same mechanism an IDE
debugger uses
+ * for HotSwap. Because the {@link Class} object's identity never
changes, every
+ * existing reference to it — including caches inside {@code
JavaEventHandler} and
+ * {@code StandardJavaEngine} — automatically executes the new method
bodies on the
+ * next call. No framework code needs to know this plugin exists.</li>
+ * <li>Brand-new classes need no special handling at all: they simply get
loaded
+ * normally, from this plugin's build output directory, the first time
something
+ * references them.</li>
+ * </ol>
+ *
+ * <h2>Structural changes</h2>
+ * On a stock JVM, {@code redefineClasses} can only replace method bodies and
static
+ * initializers of a class that is already loaded — adding/removing methods or
fields,
+ * changing a method signature, or changing the class hierarchy still requires
a restart.
+ * This is a hard JVM limitation, not something this plugin can work around on
a stock
+ * JVM. Running on a DCEVM-patched JVM with {@code
-XX:+AllowEnhancedClassRedefinition}
+ * lifts that restriction transparently: this class calls the exact same
+ * {@code redefineClasses} API either way, so structural changes just work
when that flag
+ * is present. Because a stock JVM would silently defer that surprise to
whenever someone
+ * adds a method, {@code ./gradlew ofbizDev}'s build.gradle task treats a
DCEVM-patched
+ * JVM as required, not optional: it resolves one via {@code -PdcevmHome}/
+ * {@code DCEVM_HOME}, launches with that flag automatically, and fails fast
with setup
+ * instructions instead of starting OFBiz at all if none is resolvable. Plain
stock-JDK
+ * operation is not a supported outcome of that command.
+ *
+ * <h2>How services.xml changes are handled</h2>
+ * Every component's {@code servicedef/} directory is also watched; on change,
the
+ * {@code service.ModelServiceMapByModel} {@link UtilCache} entry is cleared
directly, so
+ * the new/edited definition is re-read on the next service call.
+ *
+ * <h2>Directory watch limits</h2>
+ * The OS may refuse to watch a directory once a process-wide ceiling is
reached (most
+ * commonly hit on macOS on a full checkout). This container does not try to
work around
+ * that itself: a directory whose registration fails is simply left unwatched,
with a
+ * warning naming it, rather than silently falling back to some slower
alternate
+ * mechanism. Narrow the set of directories with {@code
-Dofbiz.hotreload.components}
+ * (or {@code -Photreload.components=compA,compB} with the {@code ofbizDev}
Gradle task)
+ * to fit under the ceiling — see this component's README for the full
guidance.
+ *
+ * <h2>Scope</h2>
+ * This container is intentionally dev-only. It has no effect when the system
property is
+ * absent, so it is safe to leave the registration in this component's
+ * {@code ofbiz-component.xml} for all environments.
+ */
+public class DevReloadContainer implements Container {
+
+ private static final String MODULE = DevReloadContainer.class.getName();
+ private static final String SERVICE_MODEL_CACHE_NAME =
"service.ModelServiceMapByModel";
+
+ private String name;
+ private WatchService watchService;
+ private Thread watchThread;
+ private ScheduledExecutorService debounceExecutor;
+ private Instrumentation instrumentation;
+
+ private final Debouncer<String> classReloadDebouncer = new
Debouncer<>(this::applyReload);
+ private final Debouncer<Path> xmlReloadDebouncer = new
Debouncer<>(this::applyServiceXmlReload);
+ private final Debouncer<Path> compileDebouncer = new
Debouncer<>(this::applyCompile);
+
+ // Counts across
registerServicedefDirs()/registerSourceDirs()/registerAll(), so
+ // start() can emit one aggregated warning instead of leaving individual
failures
+ // scattered in the log where they're easy to miss.
+ private int watchDirsAttempted = 0;
+ private int watchDirsFailed = 0;
+
+ /** Gradle's own compiled-output directory, {@code
build/classes/java/main}. Read-only
+ * from this class's perspective: only ever watched (when {@code
watchBuildOutput} is
+ * on) for externally-produced {@code .class} files, never written to
directly. */
+ private Path classesDir;
+
+ /**
+ * This plugin's own compiled-output directory, {@code
build/devreload/classes},
+ * deliberately separate from Gradle's {@link #classesDir}. In-process
compiles
+ * (see {@link #applyCompile}) write here instead of into Gradle's managed
output, so
+ * Gradle's incremental {@code compileJava} up-to-date check is never
confused by
+ * writes it didn't make itself. Cleared and recreated fresh on every
{@link #init},
+ * and placed ahead of Gradle's output on the runtime classpath (see
+ * {@code plugins/devreload/build.gradle}) — so when a class exists in both
+ * directories, this one always wins (see {@link #resolveClassFile}).
+ */
+ private Path hotReloadOutputDir;
+
+ // Populated in start() before the watch thread launches; read-only after
that.
+ private final Set<Path> servicedefDirs = new HashSet<>();
+ private final Set<Path> sourceRootDirs = new HashSet<>();
+
+ /**
+ * Component names to watch, from {@code -Dofbiz.hotreload.components};
{@code null}
+ * means watch every component. Set this property to a comma-separated
list of
+ * component names to keep the total number of watched directories under
the OS's
+ * per-process ceiling on a large checkout.
+ */
+ private Set<String> allowedComponents;
+
+ /**
+ * Whether {@link #classesDir} ({@code build/classes/java/main}) itself is
watched, from
+ * {@code -Dofbiz.hotreload.watchBuildOutput}; defaults to {@code false}.
This tree mirrors
+ * every component's source tree and, unlike {@link #sourceRootDirs}, is
<em>not</em>
+ * narrowed by {@link #allowedComponents} (compiled output isn't organized
per component),
+ * so on a full checkout it roughly doubles the total directories watched.
It only exists to
+ * pick up externally-produced {@code .class} files (e.g. running {@code
./gradlew -t
+ * classes} in a second terminal); in-process compiles hot-swap directly
and never need it.
+ * Off by default so unscoped runs need meaningfully fewer real watch
registrations.
+ */
+ private boolean watchBuildOutput;
+
+ @Override
+ public void init(List<StartupCommand> ofbizCommands, String name, String
configFile) throws ContainerException {
+ this.name = name;
+
+ if (!"true".equalsIgnoreCase(System.getProperty("ofbiz.hotreload"))) {
+ Debug.logInfo("DevReloadContainer is disabled. Use
-Dofbiz.hotreload=true to enable.", MODULE);
+ return;
+ }
+
+ parseHotReloadProperties();
+ attachHotSwapAgent();
+
+ if (!prepareClassesDir()) {
+ return;
+ }
+ if (!prepareHotReloadOutputDir()) {
+ return;
+ }
+ createWatchService();
+ registerBuildOutputWatchIfEnabled();
+ startDebounceExecutor();
+
+ Debug.logInfo("DevReloadContainer ready — compiled output at " +
hotReloadOutputDir.toAbsolutePath(), MODULE);
+ }
+
+ /**
+ * Reads {@code -Dofbiz.hotreload.components} and {@code
-Dofbiz.hotreload.watchBuildOutput},
+ * populating {@link #allowedComponents} and {@link #watchBuildOutput}.
+ */
+ private void parseHotReloadProperties() {
+ String componentsProperty =
System.getProperty("ofbiz.hotreload.components");
+ if (componentsProperty != null && !componentsProperty.isBlank()) {
+ allowedComponents = Arrays.stream(componentsProperty.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toSet());
+ Debug.logInfo("Hot-reload: scoped to components " +
allowedComponents
+ + " (set via -Dofbiz.hotreload.components)", MODULE);
+ }
+
+ watchBuildOutput =
"true".equalsIgnoreCase(System.getProperty("ofbiz.hotreload.watchBuildOutput"));
+ }
+
+ /**
+ * Self-attaches {@link HotSwapAgent} so Java class redefinition is
available. Failure
+ * here is non-fatal and never aborts {@link #init}: Java hot-swap is
simply disabled
+ * while {@code services.xml} auto-reload still works.
+ */
+ private void attachHotSwapAgent() {
+ try {
+ instrumentation = HotSwapAgent.install();
+ Debug.logInfo("Hot-reload: self-attached HotSwapAgent — Java class
redefinition is available.", MODULE);
+ if (enhancedRedefinitionRequested()) {
+ Debug.logInfo("Hot-reload: -XX:+AllowEnhancedClassRedefinition
detected — running on a JVM "
+ + "(e.g. JetBrains Runtime) that can also hot-swap
structural changes (added/removed "
+ + "methods or fields, changed signatures), not just method
bodies.", MODULE);
+ } else {
+ Debug.logWarning("Hot-reload: structural changes (added/removed
methods or fields, changed "
+ + "signatures) will require a restart on this JVM.
'./gradlew ofbizDev' requires a "
+ + "DCEVM-patched JVM and refuses to start without one, so
you're most likely seeing "
+ + "this because hot-reload was activated some other way on
a stock JDK. See "
+ + "plugins/devreload/README.md to set up DCEVM_HOME.",
MODULE);
+ }
+ } catch (Exception e) {
+ Debug.logWarning("Hot-reload: could not self-attach HotSwapAgent
(" + e.getMessage()
+ + "). Add -Djdk.attach.allowAttachSelf=true to JVM args. "
+ + "Java class changes will require a restart; services.xml
auto-reload still works.", MODULE);
+ }
+ }
+
+ /** Sets {@link #classesDir}. Returns {@code false} (after logging) if it
doesn't exist yet. */
+ private boolean prepareClassesDir() {
+ classesDir = Paths.get("build/classes/java/main");
+ if (!Files.exists(classesDir)) {
+ Debug.logWarning("Hot-reload: classes directory not found at " +
classesDir.toAbsolutePath()
+ + ". Run './gradlew classes' first, then restart.",
MODULE);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Sets {@link #hotReloadOutputDir} and clears/recreates it. Returns
{@code false} (after
+ * logging) if the directory could not be prepared -- without a writable
output directory,
+ * in-process compilation cannot work at all, so the whole container is
disabled up front
+ * instead of continuing to a misleading "ready" log.
+ */
+ private boolean prepareHotReloadOutputDir() {
+ // build.gradle passes this down as -Dofbiz.hotreload.outputDir,
computed from the
+ // same value it prepends to the classpath, so the path exists in
exactly one place
+ // rather than being hardcoded independently here too. The literal
default below is
+ // only a fallback for the (unsupported) case of starting this
container without
+ // going through the ofbizDev Gradle task.
+ hotReloadOutputDir = Paths.get(System.getProperty("ofbiz.hotreload.outputDir",
"build/devreload/classes"));
+ try {
+ FileUtils.deleteDirectory(hotReloadOutputDir.toFile());
+ Files.createDirectories(hotReloadOutputDir);
+ return true;
+ } catch (IOException e) {
+ Debug.logWarning("Hot-reload: could not prepare this plugin's own
output directory "
+ + hotReloadOutputDir.toAbsolutePath() + ": " +
e.getMessage(), MODULE);
+ return false;
+ }
+ }
+
+ /** Creates {@link #watchService} on {@link #classesDir}'s filesystem. */
+ private void createWatchService() throws ContainerException {
+ try {
+ watchService = classesDir.getFileSystem().newWatchService();
+ } catch (IOException e) {
+ throw new ContainerException("DevReloadContainer: failed to initialise
WatchService", e);
+ }
+ }
+
+ /**
+ * Registers {@link #classesDir} with the WatchService when {@link
#watchBuildOutput} is
+ * on, so externally-produced {@code .class} files (e.g. {@code ./gradlew
-t classes} in
+ * a second terminal) are picked up too.
+ */
+ private void registerBuildOutputWatchIfEnabled() {
+ if (watchBuildOutput) {
+ try {
+ registerAll(classesDir);
+ } catch (IOException e) {
+ // registerAll() already logs a warning and skips individual
directories
+ // that fail to register; reaching here means something more
fundamental
+ // broke walking the tree at all (e.g. can't even list
classesDir).
+ Debug.logWarning("Hot-reload: could not fully walk " + classesDir +
": " + e.getMessage(), MODULE);
+ }
+ Debug.logInfo("Hot-reload: watching compiled-output directory " +
classesDir.toAbsolutePath()
+ + " for externally-produced .class files (set via
-Dofbiz.hotreload.watchBuildOutput=true).",
+ MODULE);
+ } else {
+ Debug.logInfo("Hot-reload: not watching " + classesDir.toAbsolutePath() +
" (this tree isn't "
+ + "narrowed by -Dofbiz.hotreload.components and roughly doubles
the total directories "
+ + "watched). In-process edits still hot-swap normally; running
'./gradlew -t classes' in a "
+ + "second terminal will not be picked up unless you set "
+ + "-Dofbiz.hotreload.watchBuildOutput=true (or
-Photreload.watchBuildOutput=true with the "
+ + "ofbizDev Gradle task).", MODULE);
+ }
+ }
+
+ /** Starts the daemon executor backing every {@link Debouncer}. */
+ private void startDebounceExecutor() {
+ debounceExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "ofbiz-hot-reload-debouncer");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ /**
+ * Picks whichever of this plugin's own compiled output ({@code overlay},
under
+ * {@link #hotReloadOutputDir}) or Gradle's ({@code fallback}, under
{@link #classesDir})
+ * is the right one to redefine from. {@code overlay} always wins when it
exists,
+ * matching its position ahead of {@code fallback} on the runtime
classpath (see
+ * {@code build.gradle}) — a class compiled here is always the one a
caller would load.
+ * Returns {@code null} if neither exists.
+ */
+ private Path resolveClassFile(Path overlay, Path fallback) {
+ if (Files.exists(overlay)) {
+ return overlay;
+ }
+ if (Files.exists(fallback)) {
+ return fallback;
+ }
+ return null;
+ }
+
+ @Override
+ public boolean start() throws ContainerException {
+ if (watchService == null) {
+ return true; // disabled
+ }
+ registerServicedefDirs();
+ registerSourceDirs();
+ if (watchDirsFailed > 0) {
+ Debug.logWarning("Hot-reload: " + watchDirsFailed + " of " +
watchDirsAttempted + " directory watch "
+ + "registrations hit file-descriptor/watch exhaustion (see
warnings above for which ones) "
+ + "and are NOT being watched — changes there will not
hot-reload until you restart. Scope "
+ + "hot-reload to just the components you're working on with
"
+ + "-Dofbiz.hotreload.components=compA,compB (or
-Photreload.components=compA,compB with "
+ + "the ofbizDev Gradle task) to fit under the OS watch
limit.", MODULE);
+ }
+ watchThread = new Thread(this::watchLoop, "ofbiz-hot-reload-watcher");
+ watchThread.setDaemon(true);
+ watchThread.start();
+ Debug.logInfo("DevReloadContainer started. Edit any Java or services.xml
file and changes go live without a restart.", MODULE);
+ return true;
+ }
+
+ /**
+ * Registers every directory that contains a component service-definition
XML file
+ * (type="model") with the WatchService so that edits to those files are
detected.
+ * Called once from {@link #start()}, before the watch thread launches.
+ */
+ private void registerServicedefDirs() {
+ for (ComponentConfig.ServiceResourceInfo sri :
ComponentConfig.getAllServiceResourceInfos("model")) {
+ if (allowedComponents != null &&
!allowedComponents.contains(sri.getComponentConfig().getComponentName())) {
+ continue;
+ }
+ try {
+ URL url = sri.createResourceHandler().getURL();
+ if (!"file".equals(url.getProtocol())) {
+ continue; // skip non-filesystem resources (classpath
jars, etc.)
+ }
+ Path dir = Paths.get(new URI(url.toString())).getParent();
+ if (dir != null && Files.isDirectory(dir) &&
servicedefDirs.add(dir)) {
+ watchDirsAttempted++;
+ try {
+ dir.register(watchService,
+ StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_MODIFY);
+ Debug.logInfo("Hot-reload: watching servicedef directory
" + dir, MODULE);
+ } catch (IOException e) {
+ warnUnwatched(dir, e);
+ }
+ }
+ } catch (GenericConfigException | URISyntaxException e) {
+ Debug.logWarning("Hot-reload: could not register servicedef dir for
"
+ + sri.getLocation() + ": " + e.getMessage(), MODULE);
+ } catch (Throwable t) {
+ // Defensive: a single component's servicedef registration
must not be able
+ // to abort the loop and leave every subsequent component's
servicedef
+ // directory unwatched.
+ Debug.logError(t, "Hot-reload: unexpected error registering
servicedef dir for "
+ + sri.getLocation(), MODULE);
+ }
+ }
+ }
+
+ /**
+ * Registers every component's {@code src/main/java} directory with the
WatchService
+ * so that saving a {@code .java} file triggers in-process compilation via
+ * {@link ToolProvider#getSystemJavaCompiler()}. Falls back gracefully
when running
+ * on a JRE (compiler unavailable) — source watching is simply skipped.
+ */
+ private void registerSourceDirs() {
+ if (ToolProvider.getSystemJavaCompiler() == null) {
+ Debug.logWarning("Hot-reload: javax.tools.JavaCompiler not available
(JRE, not JDK?). "
+ + "Java source auto-compilation disabled — use './gradlew -t
classes' in a second terminal.", MODULE);
+ return;
+ }
+ for (ComponentConfig cc : ComponentConfig.getAllComponents()) {
+ if (cc.rootLocation() == null) {
+ continue;
+ }
+ if (allowedComponents != null &&
!allowedComponents.contains(cc.getComponentName())) {
+ continue;
+ }
+ Path srcDir = cc.rootLocation().resolve("src/main/java");
+ if (Files.isDirectory(srcDir) && sourceRootDirs.add(srcDir)) {
+ try {
+ registerAll(srcDir);
+ Debug.logInfo("Hot-reload: watching source directory " +
srcDir, MODULE);
+ } catch (IOException e) {
+ // registerAll() already logs a warning and skips
individual directories
+ // that fail to register; reaching here means something
more fundamental
+ // broke walking the tree at all (e.g. can't list srcDir).
+ Debug.logWarning("Hot-reload: could not walk source dir " + srcDir +
": " + e.getMessage(), MODULE);
+ }
+ }
+ }
+ if (!sourceRootDirs.isEmpty()) {
+ Debug.logInfo("Hot-reload: Java source auto-compilation active — save a
.java file and it reloads automatically.", MODULE);
+ }
+ }
+
+ @Override
+ public void stop() throws ContainerException {
+ if (debounceExecutor != null) {
+ debounceExecutor.shutdownNow();
+ }
+ if (watchService != null) {
+ try {
+ watchService.close();
+ } catch (IOException e) {
+ Debug.logError(e, "DevReloadContainer: error closing
WatchService", MODULE);
+ }
+ }
+ if (watchThread != null) {
+ watchThread.interrupt();
+ }
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ //
-------------------------------------------------------------------------
+ // Watch loop
+ //
-------------------------------------------------------------------------
+
+ private void watchLoop() {
+ while (!Thread.currentThread().isInterrupted()) {
+ WatchKey key;
+ try {
+ key = watchService.take();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (ClosedWatchServiceException e) {
+ break;
+ }
+
+ Path dir = (Path) key.watchable();
+ for (WatchEvent<?> event : key.pollEvents()) {
+ WatchEvent.Kind<?> kind = event.kind();
+ if (kind == StandardWatchEventKinds.OVERFLOW) {
+ continue;
+ }
+
+ @SuppressWarnings("unchecked")
+ Path changed = dir.resolve(((WatchEvent<Path>)
event).context());
+
+ if (kind == StandardWatchEventKinds.ENTRY_CREATE &&
Files.isDirectory(changed)) {
+ // New package directory created during compilation —
register it.
+ try {
+ registerAll(changed);
+ } catch (IOException e) {
+ Debug.logError(e, "DevReloadContainer: failed to register
new directory: " + changed, MODULE);
+ }
+ } else if ((kind == StandardWatchEventKinds.ENTRY_CREATE ||
kind == StandardWatchEventKinds.ENTRY_MODIFY)
+ && changed.toString().endsWith(".class")) {
+ // Only react to written/updated class files. Ignore
ENTRY_DELETE so
+ // that removing a source file (and its .class output)
does not cause
+ // a redefinition attempt against a now-missing file.
+ reloadClassFile(classesDir, changed);
+ } else if ((kind == StandardWatchEventKinds.ENTRY_CREATE ||
kind == StandardWatchEventKinds.ENTRY_MODIFY)
+ && changed.toString().endsWith(".xml")
+ && servicedefDirs.stream().anyMatch(dir::startsWith)) {
+ xmlReloadDebouncer.add(changed);
+ } else if ((kind == StandardWatchEventKinds.ENTRY_CREATE ||
kind == StandardWatchEventKinds.ENTRY_MODIFY)
+ && changed.toString().endsWith(".java")
+ && sourceRootDirs.stream().anyMatch(dir::startsWith)) {
+ compileDebouncer.add(changed);
+ }
+ }
+ if (!key.reset()) {
+ Debug.logWarning("Hot-reload: watch key became invalid (directory
deleted?): "
+ + key.watchable() + ". WatchService will no longer detect
changes in that directory.", MODULE);
+ }
+ }
+ }
+
+ //
-------------------------------------------------------------------------
+ // Debounced batching
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Coalesces rapid-fire change notifications into one action, so a single
compile run
+ * that touches many files (e.g. one with inner/anonymous classes, or a
Gradle build
+ * writing several {@code .class} files at once) is handled as a single
batch instead
+ * of one action per file. Shared by all three change pipelines (class
reload,
+ * {@code services.xml} reload, Java compile) instead of each hand-rolling
its own
+ * pending-set/cancel/reschedule bookkeeping.
+ */
+ private final class Debouncer<T> {
+ private final Set<T> pending = new HashSet<>();
+ private final Consumer<Set<T>> action;
+ private ScheduledFuture<?> scheduled;
+
+ Debouncer(Consumer<Set<T>> action) {
+ this.action = action;
+ }
+
+ synchronized void add(T item) {
+ pending.add(item);
+ if (scheduled != null) {
+ scheduled.cancel(false);
+ }
+ try {
+ // Wait 300 ms after the last change so a burst of related
changes (e.g. a
+ // single Gradle compile run writing multiple .class files) is
handled as
+ // one batch instead of one action per file.
+ scheduled = debounceExecutor.schedule(this::fire, 300,
TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException e) {
+ // Container is shutting down; pending changes will not be
applied.
+ }
+ }
+
+ private synchronized void fire() {
+ if (pending.isEmpty()) {
+ return;
+ }
+ Set<T> batch = new HashSet<>(pending);
+ pending.clear();
+ action.accept(batch);
+ }
+ }
+
+ /** Resolves {@code changed} to a class name relative to {@code baseDir}
and, if valid, queues it for reload. */
+ private void reloadClassFile(Path baseDir, Path changed) {
+ String className = toClassName(baseDir, changed);
+ if (className != null) {
+ classReloadDebouncer.add(className);
+ }
+ }
+
+ private void applyReload(Set<String> batch) {
+ Debug.logInfo("Hot-reload: detected changes in " + batch, MODULE);
+
+ if (instrumentation == null) {
+ Debug.logWarning("Hot-reload: HotSwapAgent not attached — " + batch
+ + " compiled but not applied to the running JVM. Restart to
pick it up.", MODULE);
+ return;
+ }
+
+ List<ClassDefinition> defs = new ArrayList<>();
+ for (String className : batch) {
+ Path relative = Paths.get(className.replace('.', '/') + ".class");
+ Path overlayFile = hotReloadOutputDir.resolve(relative);
+ Path gradleFile = classesDir.resolve(relative);
+ Path classFile = resolveClassFile(overlayFile, gradleFile);
+ if (classFile == null) {
+ Debug.logWarning("Hot-reload: detected a change for " +
className
+ + " but could not find its compiled output in either "
+ + hotReloadOutputDir.toAbsolutePath() + " or " +
classesDir.toAbsolutePath(), MODULE);
+ continue;
+ }
+ try {
+ Class<?> loaded = findLoadedClass(className);
+ if (loaded == null) {
+ // Never loaded yet in this JVM — nothing to redefine. It
will simply
+ // load fresh, with the new bytecode, the first time
something
+ // references it, from whichever directory
resolveClassFile() would
+ // pick (overlay first on the classpath too, see
build.gradle).
+ continue;
+ }
+ defs.add(new ClassDefinition(loaded,
Files.readAllBytes(classFile)));
+ } catch (IOException e) {
+ Debug.logError(e, "Hot-reload: failed to read class file for "
+ className, MODULE);
+ }
+ }
+
+ if (defs.isEmpty()) {
+ Debug.logInfo("Hot-reload: nothing already loaded to redefine for
" + batch, MODULE);
+ return;
+ }
+
+ try {
+ instrumentation.redefineClasses(defs.toArray(new
ClassDefinition[0]));
+ // Clear service definition cache so newly added service methods
are discovered.
+ // We deliberately do NOT clear webapp.Controller caches here —
controller.xml
+ // has not changed, only .class files have, and clearing those
caches triggers
+ // Groovy re-compilation of screen expressions which can fail
unexpectedly.
+ UtilCache.clearCache(SERVICE_MODEL_CACHE_NAME);
+ Debug.logInfo("Hot-reload complete for: " + batch, MODULE);
+ } catch (UnsupportedOperationException e) {
+ // ./gradlew ofbizDev only ever runs on a DCEVM-patched JVM (see
build.gradle),
+ // which already lifts the plain-JVM restriction to method bodies
only, so
+ // add/remove method-or-field and signature changes normally
succeed here. This
+ // still fires for the narrower set of changes DCEVM itself can't
apply either
+ // (e.g. a changed class hierarchy) -- the same remaining limit an
IDE debugger's
+ // HotSwap has even on a capable JVM.
+ Debug.logWarning("Hot-reload: " + batch + " contains a structural
change (added/removed "
+ + "method or field, changed signature, changed hierarchy) that
the JVM cannot "
+ + "hot-swap. Restart OFBiz to pick it up. (" + e.getMessage() +
")", MODULE);
+ } catch (Throwable e) {
+ Debug.logError(e, "Hot-reload failed for " + batch, MODULE);
+ }
+ }
+
+ /**
+ * Best-effort detection of whether this JVM was launched with
+ * {@code -XX:+AllowEnhancedClassRedefinition} (e.g. a JetBrains Runtime),
which is
+ * what allows {@link Instrumentation#redefineClasses} to also apply
structural
+ * changes instead of just method bodies. Purely informational — the actual
+ * capability is exercised (and, if absent, reported) when a redefinition
is
+ * attempted in {@link #applyReload}.
+ */
+ private static boolean enhancedRedefinitionRequested() {
+ return
ManagementFactory.getRuntimeMXBean().getInputArguments().stream()
+ .anyMatch(arg ->
arg.contains("AllowEnhancedClassRedefinition"));
+ }
+
+ /** Searches classes already loaded in the JVM for one matching {@code
className}. */
+ private Class<?> findLoadedClass(String className) {
+ for (Class<?> c : instrumentation.getAllLoadedClasses()) {
+ if (c.getName().equals(className)) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ private void applyServiceXmlReload(Set<Path> batch) {
+ Debug.logInfo("Hot-reload: service XML changed " + batch + " — clearing
service model cache", MODULE);
+ try {
+ UtilCache.clearCache(SERVICE_MODEL_CACHE_NAME);
+ Debug.logInfo("Hot-reload: service model cache cleared; definitions
will be re-read on next service call", MODULE);
+ } catch (Throwable e) {
+ Debug.logError(e, "Hot-reload: failed to clear service model
cache", MODULE);
+ }
+ }
+
+ private void applyCompile(Set<Path> batch) {
+ Debug.logInfo("Hot-reload: compiling " + batch, MODULE);
+ try {
+ JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+ if (compiler == null) {
+ return;
+ }
+ try (StandardJavaFileManager fm =
compiler.getStandardFileManager(null, null, null)) {
+ fm.setLocation(StandardLocation.CLASS_OUTPUT,
+ List.of(hotReloadOutputDir.toAbsolutePath().toFile()));
+ // Reuse the running JVM's classpath — it already contains all
OFBiz jars.
+ List<String> options = Arrays.asList("-cp",
System.getProperty("java.class.path"), "-proc:none");
+ var units = fm.getJavaFileObjectsFromPaths(batch);
+ boolean ok = compiler.getTask(null, fm, null, options, null,
units).call();
+ if (ok) {
+ Debug.logInfo("Hot-reload: compilation successful",
MODULE);
+
+ // Collect all .class files produced by this compilation
round.
+ // Each source file can produce multiple .class files when
it contains
+ // inner or anonymous classes (e.g. Foo$Bar.class,
Foo$1.class).
+ // All of them must be redefined too — otherwise the inner
class still
+ // resolves through its stale, previously-loaded bytecode.
+ for (Path src : batch) {
+ Path cf = sourceToClassFile(src); // relative path for
the outer class
+ if (cf == null) {
+ continue;
+ }
+ String outerName = cf.getFileName().toString().replace(".class",
"");
+ Path absOutputDir = cf.toAbsolutePath().getParent();
+ try (var dirStream = Files.list(absOutputDir)) {
+ dirStream.filter(absFile -> {
+ String fn = absFile.getFileName().toString();
+ // Match Foo.class and Foo$Inner.class /
Foo$1.class
+ return fn.endsWith(".class")
+ && (fn.equals(outerName + ".class")
+ || fn.startsWith(outerName +
"$"));
+ }).forEach(absFile -> {
+ // Convert absolute output path back to a
relative path that
+ // is rooted at CWD (same type as
hotReloadOutputDir) so that
+ // toClassName(hotReloadOutputDir, relPath) —
which calls
+ // relativize — does not throw
IllegalArgumentException.
+ Path rel = hotReloadOutputDir.resolve(
+
hotReloadOutputDir.toAbsolutePath().relativize(absFile));
+ reloadClassFile(hotReloadOutputDir, rel);
+ });
+ } catch (IOException e) {
+ // Output dir unreadable; fall back to the outer
class only.
+ reloadClassFile(hotReloadOutputDir, cf);
+ }
+ }
+
+ // Re-register class directories so external compilations
(./gradlew classes
+ // run by a developer in a separate terminal) still reach
the class watcher.
+ // Only relevant if that watch is enabled in the first
place (watchBuildOutput);
+ // otherwise there is nothing registered under classesDir
to refresh.
+ // Catch Exception (not just IOException) because
ClosedWatchServiceException
+ // extends IllegalStateException, which is a
RuntimeException — it can be
+ // thrown here if OFBiz is shutting down while a compile
finishes.
+ if (watchBuildOutput) {
+ try {
+ registerAll(classesDir);
+ } catch (Exception e) {
+ Debug.logWarning("Hot-reload: could not re-register
class dirs: " + e.getMessage(), MODULE);
+ }
+ }
+ } else {
+ Debug.logWarning("Hot-reload: compilation failed — fix the
error and save again", MODULE);
+ }
+ }
+ } catch (Throwable e) {
+ Debug.logError(e, "Hot-reload: compilation error", MODULE);
+ }
+ }
+
+ /**
+ * Maps a {@code .java} source file to the corresponding {@code .class}
output file
+ * under {@link #hotReloadOutputDir}. Returns {@code null} if the source
file is not
+ * under any registered source root.
+ */
+ private Path sourceToClassFile(Path sourceFile) {
+ for (Path srcRoot : sourceRootDirs) {
+ if (sourceFile.startsWith(srcRoot)) {
+ Path relative = srcRoot.relativize(sourceFile);
+ String name = relative.toString();
+ if (name.endsWith(".java")) {
+ String classRelative = name.substring(0, name.length() -
".java".length()) + ".class";
+ return hotReloadOutputDir.resolve(classRelative);
+ }
+ }
+ }
+ return null;
+ }
+
+ //
-------------------------------------------------------------------------
+ // Helpers
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Recursively registers every directory under {@code start} with the
WatchService.
+ * A directory whose registration fails (e.g. watch/descriptor exhaustion)
is logged
+ * and left unwatched via {@link #warnUnwatched} instead of aborting the
whole walk,
+ * so one overloaded directory never leaves the rest of the tree unwatched.
+ */
+ private void registerAll(Path start) throws IOException {
+ Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) {
+ watchDirsAttempted++;
+ try {
+ dir.register(watchService,
+ StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_MODIFY);
+ } catch (IOException e) {
+ warnUnwatched(dir, e);
+ } catch (Throwable t) {
+ // register() itself should only throw IOException, but
nothing here is
+ // worth crashing the whole startup over.
+ Debug.logError(t, "Hot-reload: unexpected error registering
watch for " + dir
+ + " -- this directory will not be watched.",
MODULE);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
+ /**
+ * Records that {@code dir} could not get a WatchService registration
(most commonly
+ * the OS's per-process watch ceiling, e.g. macOS's kqueue-per-directory
cost) and
+ * logs why. The directory is simply left unwatched: changes there require
a restart
+ * (or {@code -Dofbiz.hotreload.components}/{@code -Photreload.components}
to narrow
+ * the watched set below the ceiling) rather than falling back to some
slower
+ * alternate mechanism.
+ */
+ private void warnUnwatched(Path dir, IOException cause) {
+ watchDirsFailed++;
+ Debug.logWarning("Hot-reload: could not watch " + dir + " (" +
cause.getMessage() + ") -- changes "
+ + "there will not be picked up until OFBiz is restarted. Narrow the
watched set with "
+ + "-Dofbiz.hotreload.components=compA,compB (or
-Photreload.components=compA,compB with the "
+ + "ofbizDev Gradle task) to fit under the OS watch limit.",
MODULE);
+ }
+
+ /**
+ * Converts a {@code .class} file path relative to {@code baseDir} into a
+ * binary class name.
+ *
+ * <p>Example: {@code com/example/Foo.class} → {@code com.example.Foo}
+ */
+ private static String toClassName(Path baseDir, Path classFile) {
+ Path relative = baseDir.relativize(classFile);
+ String s = relative.toString();
+ if (!s.endsWith(".class")) {
+ return null;
+ }
+ // Normalise path separator to '.' and strip the '.class' suffix
+ return s.substring(0, s.length() - ".class".length())
+ .replace(classFile.getFileSystem().getSeparator(), ".");
+ }
+}
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/HotSwapAgent.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/HotSwapAgent.java
new file mode 100644
index 000000000..f3542a5bf
--- /dev/null
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/HotSwapAgent.java
@@ -0,0 +1,109 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ *http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.devreload;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.instrument.Instrumentation;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.jar.Attributes;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+
+import com.sun.tools.attach.VirtualMachine;
+
+/**
+ * Self-attaching Java agent that gives {@link DevReloadContainer} access to
+ * {@link Instrumentation#redefineClasses}, the same JVM mechanism an IDE
debugger uses
+ * for HotSwap — it replaces the bytecode of an already-loaded {@link Class}
object in
+ * place, so every existing reference to it (including cached {@code Class}
objects in
+ * framework classes like {@code JavaEventHandler}) picks up the new method
bodies on the
+ * very next call.
+ *
+ * <p>The agent jar is built on the fly from this class's own compiled
bytecode and
+ * attached to the current process via the JDK Attach API — no {@code
-javaagent} JVM
+ * flag, and no framework code changes. This class and {@link
DevReloadContainer} are the
+ * only two moving parts.
+ *
+ * <p>Requires the JVM flag {@code -Djdk.attach.allowAttachSelf=true} (a JDK
9+ safeguard
+ * against a process attaching to itself). The {@code ofbizDev} Gradle task
provided by
+ * this plugin sets it automatically.
+ */
+public final class HotSwapAgent {
+
+ private static volatile Instrumentation instrumentation;
+
+ private HotSwapAgent() { }
+
+ /** Invoked by the JVM once {@link VirtualMachine#loadAgent} loads this
class as an agent. */
+ public static void agentmain(String agentArgs, Instrumentation inst) {
+ instrumentation = inst;
+ }
+
+ /**
+ * Builds a minimal agent jar wrapping this class and self-attaches it to
the current
+ * JVM, returning the resulting {@link Instrumentation}. Safe to call more
than once;
+ * later calls return the instance obtained by the first successful attach.
+ */
+ static synchronized Instrumentation install() throws Exception {
+ if (instrumentation != null) {
+ return instrumentation;
+ }
+ Path agentJar = buildAgentJar();
+ try {
+ String pid = String.valueOf(ProcessHandle.current().pid());
+ VirtualMachine vm = VirtualMachine.attach(pid);
+ try {
+ vm.loadAgent(agentJar.toAbsolutePath().toString());
+ } finally {
+ vm.detach();
+ }
+ } finally {
+ Files.deleteIfExists(agentJar);
+ }
+ return instrumentation;
+ }
+
+ private static Path buildAgentJar() throws IOException {
+ String resource = HotSwapAgent.class.getName().replace('.', '/') +
".class";
+ Path jar = Files.createTempFile("devreload-hotswap-agent", ".jar");
+
+ Manifest manifest = new Manifest();
+ Attributes attrs = manifest.getMainAttributes();
+ attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0");
+ attrs.put(new Attributes.Name("Agent-Class"),
HotSwapAgent.class.getName());
+ attrs.put(new Attributes.Name("Can-Redefine-Classes"), "true");
+ attrs.put(new Attributes.Name("Can-Retransform-Classes"), "true");
+
+ try (InputStream in =
HotSwapAgent.class.getClassLoader().getResourceAsStream(resource);
+ OutputStream fos = Files.newOutputStream(jar);
+ JarOutputStream jos = new JarOutputStream(fos, manifest)) {
+ if (in == null) {
+ throw new IOException("Could not locate compiled class on
classpath: " + resource);
+ }
+ jos.putNextEntry(new ZipEntry(resource));
+ in.transferTo(jos);
+ jos.closeEntry();
+ }
+ return jar;
+ }
+}