Hi,
Nobody care about licences?
TIA
Jacques
Le 20/07/2026 à 11:51, Jacques Le Roux via dev a écrit :
Hi Ashish,
I Just had a look at
https://nightlies.apache.org/ofbiz/trunk/rat-output.html
Actually IMO there is more than gradle.lockfile.
I don't think a LICENSE file there is necessary .It's the only plugin
with it, since there is already one LICENSE file at plugins root. Same for
framework an applis.
For README.md there is no Apache license in top. It's not mandatory
since it's in rat-excludes.txt (tools repo). Some README.md have an Apache
license inside.
Though I added this linethat in rat-excludes.txt in 2017, I wonder now
if we should not remove this line and add the Apache license inside all
README.md.
What do you (and people at large) think?
TIA
Jacques
Le 10/07/2026 à 15:46, [email protected] a écrit :
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;
+ }
+}