This is an automated email from the ASF dual-hosted git repository.

ashishvijaywargiya pushed a commit to branch trunk
in repository https://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;
+    }
+}

Reply via email to