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; > >>> + } > >>> +} > >>>
