You are welcome, Jacques.

Just to add a bit of context to my point, the ASF legal guidelines [*]
state that short informational text files do not require a license
header. However, our README files are often long, complex, and
well-structured documents that would not generally be considered
'short informational text files'.

Regards
Anahita

[*] https://www.apache.org/legal/src-headers.html#faq-exceptionsò

Il giorno mer 29 lug 2026 alle ore 18:16 Jacques Le Roux
<[email protected]> ha scritto:
>
> Thanks for your opinion Anahita
>
> Jacques
>
> Le 29/07/2026 à 11:47, Anahita Goljahani a écrit :
> > Hi Jacques,
> >
> > I'm in favour of removing the line from rat-excludes.txt and adding
> > the license headers to the README.md files.
> >
> > Thanks,
> > Anahita
> >
> > Il giorno lun 20 lug 2026 alle ore 11:52 Jacques Le Roux via dev
> > <[email protected]> ha scritto:
> >> 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;
> >>> +    }
> >>> +}
> >>>

Reply via email to