This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-plugins.git
The following commit(s) were added to refs/heads/trunk by this push:
new a8f8539ae devreload dev notes, optmisation (#326)
a8f8539ae is described below
commit a8f8539ae40abf7708719516966931436603a181
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sat Jul 11 15:51:39 2026 +0530
devreload dev notes, optmisation (#326)
Adding dev notes in the bottom part of the README.md files.
Done code optimisation.
Split the code into multiple files.
---
devreload/README.md | 240 ++++++++++++
.../java/org/apache/ofbiz/devreload/Debouncer.java | 77 ++++
.../apache/ofbiz/devreload/DevReloadContainer.java | 409 ++++++++++++---------
.../ofbiz/devreload/RecordingFileManager.java | 54 +++
4 files changed, 604 insertions(+), 176 deletions(-)
diff --git a/devreload/README.md b/devreload/README.md
index 246d9495c..9df8d5243 100644
--- a/devreload/README.md
+++ b/devreload/README.md
@@ -54,3 +54,243 @@ Scope to specific components for a faster startup:
```
./gradlew ofbizDev --no-watch-fs -Photreload.components=devreload,party
```
+
+---
+# devreload — Dev Notes
+
+`devreload` is a development-only OFBiz plugin that removes the restart step
+from the edit → test loop. Save a `.java` file or a `services.xml` file and
+the change is live in well under a second, in the already-running OFBiz
+process.
+
+It lives at `plugins/devreload` and is completely self-contained: dropping the
+folder into a checkout adds the feature, deleting it removes the feature,
+with zero effect on the rest of OFBiz either way.
+
+---
+
+## 1. The problem it solves
+
+Without `devreload`, changing one line of Java meant:
+
+```
+edit .java → ./gradlew classes → kill OFBiz → wait 30-60s → restart → log in
again → test
+```
+
+With `devreload` running (`./gradlew ofbizDev --no-watch-fs`):
+
+```
+edit .java → save → compiled automatically → live in < 1s → test
+edit *services.xml → save → picked up automatically → live in < 1s → test
+```
+
+No restart, no re-login, no second terminal.
+
+---
+
+## 2. Who it's for / non-technical summary
+
+| Question | Answer |
+|---|---|
+| Who benefits? | Any developer actively writing/debugging OFBiz Java
services, event handlers, or `services.xml` files |
+| Does it affect production? | No. It is completely inert unless started with
a special flag (`-Dofbiz.hotreload=true`). Safe to have installed anywhere |
+| Does it change OFBiz core code? | No. The current design needs zero changes
to `ofbiz-framework` — it's a plugin only |
+| What do I run? | One command: `./gradlew ofbizDev --no-watch-fs` |
+| What do I need installed? | A DCEVM-patched JVM (e.g. JetBrains Runtime,
bundled with IntelliJ IDEA) |
+
+---
+
+## 3. How it works (technical overview)
+
+Four small classes do all the work:
+
+| Class | Role |
+|---|---|
+| `DevReloadContainer` | Watches source/config directories, compiles changed
Java in-process, and triggers reload |
+| `HotSwapAgent` | A tiny self-attaching Java agent that gives
`DevReloadContainer` access to `Instrumentation.redefineClasses` — the same
mechanism an IDE debugger uses for HotSwap |
+| `Debouncer<T>` | Coalesces rapid-fire change notifications (a burst of
file-system events, or one compile producing several `.class` files) into a
single batched action; shared by all three watch paths below |
+| `RecordingFileManager` | Wraps the in-process compiler's file manager to
record exactly which `.class` files it wrote, so the reload step hot-swaps
precisely what was compiled instead of guessing from the source file's name |
+
+At startup, `DevReloadContainer` self-attaches `HotSwapAgent` to the running
+JVM (via the Attach API — no `-javaagent` flag needed). This grants access to
+`Instrumentation`, which can replace the bytecode of an already-loaded class
+in place. Because the `Class` object's identity never changes, every existing
+reference to it (including OFBiz's own internal caches) automatically starts
+running the new code on the very next call — no framework code needs to know
+this plugin exists.
+
+### The three watch paths
+
+| Path | Watches | On change | Result |
+|---|---|---|---|
+| 1. Java source | Every component's `src/main/java` | Compiles the file
in-process, then hot-swaps the resulting class(es) | New method bodies live in
< 1s |
+| 2. `services.xml` | Every component's `servicedef/` directory | Clears the
`service.ModelServiceMapByModel` cache | OFBiz re-reads service definitions on
the next call |
+| 3. Build output (opt-in) | `build/classes/java/main` | Same hot-swap as Path
1 | Picks up `./gradlew -t classes` run in a second terminal |
+
+Changes are debounced for 300ms so a burst of related file writes (e.g. one
+compile producing several `.class` files) is handled as a single batch.
+
+### Method-body edits vs. structural changes
+
+| Change type | Stock JVM | DCEVM-patched JVM |
+|---|---|---|
+| Method body edit | ✅ Hot-swaps live | ✅ Hot-swaps live |
+| New/removed method or field, changed signature ("structural change") | ❌
Requires restart | ✅ Hot-swaps live |
+| Changed class hierarchy (superclass/interfaces) | ❌ Requires restart | ⚠️
Often hot-swaps live for classes with no existing instances (e.g. static-only
service/event classes) — support varies by DCEVM build and isn't guaranteed;
when the JVM can't apply it, that one class is rejected (see below), not
silently broken |
+
+Because structural changes are common during real development, `./gradlew
+ofbizDev` **requires** a DCEVM-patched JVM and refuses to start without one —
+rather than silently running in a degraded mode that "mostly" works.
+
+Whether a given hierarchy change hot-swaps depends on the specific DCEVM build
and
+on whether instances of the class already exist — OFBiz's service/event
classes are
+static-only with no live instances, which is the case DCEVM handles best. When
a
+class's redefinition genuinely can't be applied, only that class is rejected
(logged
+by name); every other valid change saved in the same batch still applies. A
rejected
+class stays "stuck" against that diff until OFBiz restarts.
+
+---
+
+## 4. What does and doesn't hot-reload
+
+| Works without restart | Needs a restart |
+|---|---|
+| Java method body changes | New OFBiz components (discovered at startup only)
|
+| New services / parameter changes in `services.xml` | `*UiLabels.xml` (loaded
at startup) |
+| New/removed methods, fields, changed signatures (DCEVM only) | `web.xml`,
`*.properties` files |
+| `controller.xml` (re-read every ~10s automatically — not tied to devreload
at all) | `entitymodel*.xml` changes |
+| | Changed class hierarchy (unreliable — often works for static-only classes
with no instances, but isn't guaranteed) |
+
+---
+
+## 5. Usage
+
+| Command | Effect |
+|---|---|
+| `./gradlew ofbizDev --no-watch-fs` | Start OFBiz with hot-reload enabled
(the only supported way to run it) |
+| `-Photreload.components=compA,compB` | Only watch these components — useful
on a large checkout to stay under the OS's directory-watch limit |
+| `-Photreload.watchBuildOutput=true` | Also watch Gradle's own build output
for externally-compiled classes |
+| `-PdcevmHome=/path/to/jvm` or `DCEVM_HOME` env var | Points at the
DCEVM-patched JVM (not auto-detected, by design) |
+
+`--no-watch-fs` disables Gradle's own file-watching, which otherwise competes
+with `devreload`'s own watcher for the same OS-level directory-watch budget
+(most noticeable on macOS on a full checkout).
+
+---
+
+## 6. Requirements
+
+| Requirement | Why |
+|---|---|
+| A JDK, not just a JRE | In-process compilation uses
`javax.tools.JavaCompiler` (`ToolProvider.getSystemJavaCompiler()`), which is
only present in a full JDK. Running on a JRE disables Java auto-compilation (a
warning is logged); `services.xml` reload still works |
+| A DCEVM-patched JVM | Required by the `ofbizDev` Gradle task specifically
(not by `DevReloadContainer` itself) so that structural changes (new/removed
methods/fields, changed signatures) hot-swap instead of silently requiring an
unannounced restart. Easiest source: JetBrains Runtime (JBR), bundled with
IntelliJ IDEA under `<IDE install>/jbr` (`.../Contents/jbr` on macOS) |
+| `-Djdk.attach.allowAttachSelf=true` | A JDK 9+ safeguard against a process
attaching to itself; required for `HotSwapAgent` to self-attach. Set
automatically by `./gradlew ofbizDev` |
+
+---
+
+## 7. System properties, Gradle properties, and paths
+
+Everything `devreload` reads or writes, in one place.
+
+| Property / path | Set by | Default | Purpose |
+|---|---|---|---|
+| `-Dofbiz.hotreload=true` | `ofbizDev` task (automatic) | unset (disabled) |
Master on/off switch. Absent or not `"true"` → `DevReloadContainer.init()`
returns immediately, fully inert |
+| `-Djdk.attach.allowAttachSelf=true` | `ofbizDev` task (automatic) | unset |
Lets `HotSwapAgent` self-attach via the Attach API |
+| `-Dofbiz.hotreload.components` | `-Photreload.components=compA,compB` |
unset (watch every component) | Comma-separated component names to scope
watching to, so the total watched-directory count fits under the OS's
per-process ceiling |
+| `-Dofbiz.hotreload.watchBuildOutput` | `-Photreload.watchBuildOutput=true` |
`false` | Whether `build/classes/java/main` is also watched, to pick up
externally-produced `.class` files (e.g. `./gradlew -t classes` in a second
terminal) |
+| `-Dofbiz.hotreload.outputDir` | `ofbizDev` task (automatic) |
`build/devreload/classes` | This plugin's own compiled-output directory.
Cleared and recreated on every start; placed ahead of Gradle's own output on
the runtime classpath |
+| `-XX:+AllowEnhancedClassRedefinition` | `ofbizDev` task, only once a DCEVM
JVM is resolved | not set on a stock JVM | Lifts the stock-JVM restriction so
`redefineClasses` also accepts structural changes |
+| `-PdcevmHome=/path/to/jvm` | manual, per invocation | unset | One-off
override pointing at a DCEVM-patched JVM's home directory |
+| `DCEVM_HOME` (env var) | manual, shell profile | unset | Same as
`-PdcevmHome`, but persisted so every future `./gradlew ofbizDev` picks it up
automatically |
+| `dcevmHome` (in `~/.gradle/gradle.properties`) | manual, one-time | unset |
Same idea as `DCEVM_HOME` but as a Gradle property instead of an env var — note
the different casing/name, the two are not interchangeable |
+| `build/classes/java/main` | Gradle (read-only from `devreload`'s
perspective) | — | Gradle's normal compiled-output directory; only ever
watched, never written to by this plugin |
+| `build/devreload/classes` | `devreload` itself | — | This plugin's private
compiled-output overlay; always wins over `build/classes/java/main` when a
class exists in both |
+
+---
+
+## 8. Repository layout
+
+| Repo | Contains | Why |
+|---|---|---|
+| `plugins/devreload` (a separate repo, dropped into a local checkout) |
`DevReloadContainer.java`, `HotSwapAgent.java`, `Debouncer.java`,
`RecordingFileManager.java`, `ofbiz-component.xml`, `build.gradle` (the
`ofbizDev` task), `README.md` — everything | OFBiz plugins are conventionally
distributed as separate repos; `ofbiz-framework`'s `.gitignore` excludes
`/plugins/` for exactly this reason |
+| `ofbiz-framework` | Nothing — no files, no diffs | The current design
redefines already-loaded `Class` objects in place, so no framework code ever
needs to ask "is a fresher class available." (An earlier prototype *did* need a
small reflective bridge into `framework/base` — see the historical entries in
the bug-fix table below for why that approach was replaced.) |
+
+To use it in a checkout: `git clone <devreload-repo-url> plugins/devreload`,
+then run `./gradlew ofbizDev --no-watch-fs`. Deleting `plugins/devreload/` at
+any time removes the feature completely, with zero effect on the rest of
+OFBiz.
+
+---
+
+## 9. Key design decisions
+
+| Decision | Why |
+|---|---|
+| `Instrumentation`-based class redefinition instead of a custom classloader |
Mutates the existing `Class` object in place — no second classloader to track,
no cache invalidation, no framework code changes needed |
+| Own output directory (`build/devreload/classes`), separate from Gradle's |
Writing into Gradle's managed output could confuse its incremental-build cache
and leave stale bytecode behind after a later `git checkout` |
+| Overlay directory always wins over Gradle's output when both exist | Simple,
predictable rule; matches its position on the runtime classpath |
+| One shared `Debouncer` class for all three watch paths | Avoids three
hand-rolled copies of the same concurrency logic that could drift out of sync |
+| Explicit `-PdcevmHome`/`DCEVM_HOME` only, no auto-detection | Guessing IDE
install paths breaks silently whenever a vendor changes packaging; one explicit
input is easier to keep working |
+| Failed directory watch = log a warning and skip it | No hidden fallback
(like polling); a clear, actionable warning instead of silent degraded behavior
|
+
+---
+
+## 10. Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| A directory logs a "could not watch" warning, unscoped on a full checkout |
OS directory-watch ceiling (e.g. macOS kqueue), possibly competing with
Gradle's own file watching | `./gradlew ofbizDev --no-watch-fs`, and/or
`-Photreload.components=compA,compB` to narrow the watched set |
+| Log: "DevReloadContainer is disabled" | Missing `-Dofbiz.hotreload=true` |
Use `./gradlew ofbizDev` (not `./gradlew ofbiz`) |
+| `ofbizDev` task doesn't exist | `plugins/devreload` not present, or missing
its `build.gradle` | `git clone` the plugin repo into `plugins/devreload` |
+| Log: "classes directory not found" | `./gradlew classes` not run yet |
`ofbizDev` runs `classes` automatically via `dependsOn` |
+| Log: "JavaCompiler not available" | Running on a JRE, not a JDK | Install a
JDK; use `./gradlew -t classes` in a second terminal as a fallback |
+| Log: "compilation failed — fix the error and save again" | Syntax/type error
in the saved `.java` file | The log line is followed by the real compiler
diagnostics (`file:line: message`); fix the reported error and save again — the
reload fires automatically |
+| Log: "could not self-attach HotSwapAgent" | Missing
`-Djdk.attach.allowAttachSelf=true` | Set automatically by `./gradlew
ofbizDev`; add it explicitly if starting OFBiz another way |
+| `services.xml` change not picked up | OFBiz restarted without going through
`ofbizDev` | Only works when `-Dofbiz.hotreload=true` is set |
+| A "just a method body" change still asks for a restart |
Structural-change-only edit (new/removed method or field, changed signature)
running on a stock JDK | Restart, or run on a DCEVM-patched JVM |
+| Could not find a DCEVM-patched JVM | Neither `-PdcevmHome` nor `DCEVM_HOME`
is set | Point at a JetBrains Runtime (`<IDE install>/jbr`) or standalone DCEVM
build explicitly — not auto-detected |
+| Same class keeps reporting a structural-change warning even after a pure
method-body edit | Once a stock JVM rejects one structural change on a class,
that class stays "stuck" until restart — every subsequent diff against its
still-loaded old bytecode still includes the pending change | Expected JVM
`redefineClasses` behavior, not a bug; restart to clear it |
+| Log: "batch redefinition rejected (...) -- retrying each class
individually", but only some classes in the save show "Hot-reload complete" |
One class in a debounced batch has a change the JVM can't apply; the others
were only rejected as part of the same all-or-nothing batch call | Expected
fallback behavior, not a bug — every class the JVM *can* apply still succeeds
individually; only the named, rejected class needs a restart |
+| `<attribute>`/`<override>` schema warning (`cvc-complex-type.2.4.a`) repeats
on every reload cycle | `<attribute>` elements must come before `<override>`
per `services.xsd` | Reorder the elements in the edited `services.xml` |
+
+---
+
+## 11. Top 25 bug fixes
+
+This component went through an earlier prototype design (a custom classloader
+approach, living directly in `framework/base`) before settling on the current
+`Instrumentation`-based design (a self-contained plugin). Fixes 1-14 are part
+of the current, shipped design. Fixes 15-20 were lessons learned in the
+earlier prototype; most of the mechanisms they touched no longer exist in the
+current design (noted per row), but the underlying lesson is worth keeping.
+Fixes 21-25 were found via later code-review passes on the current, shipped
+design, after real-world use surfaced edge cases the original test scenarios
+didn't happen to exercise.
+
+| # | Bug | Impact if unfixed | Fix |
+|---|---|---|---|
+| 1 | Directory-watch failures on a full checkout crashed the entire OFBiz
startup (`NoClassDefFoundError` from an unguarded fallback path) | OFBiz
wouldn't start at all on a large, unscoped checkout | Catch failures
per-directory and log a warning (`warnUnwatched`) instead of letting one bad
directory abort the whole watch setup |
+| 2 | Gradle's own file-system watcher competed with `devreload`'s watcher for
the same OS directory-watch ceiling (macOS kqueue limit) | Directories silently
went unwatched on a full checkout, even after fix #1 | Documented and defaulted
to `--no-watch-fs` for the `ofbizDev` command |
+| 3 | *(superseded by fix #23)* Inner and anonymous classes (e.g.
`Foo$1.class`) weren't included when hot-reloading their outer class |
Lambdas/anonymous classes kept running stale logic after a save | Originally
fixed by scanning the compiled output for every `Outer.class` and
`Outer$*.class` file, derived from the source file's own base name; superseded
by fix #23's more general approach |
+| 4 | Deleting a source file could trigger a reload attempt against a
now-missing class | Confusing errors / crash on file deletion | `ENTRY_DELETE`
events are explicitly ignored everywhere in the watch pipeline |
+| 5 | A cache-clear step after class redefinition could throw an `Error` (not
just an `Exception`), leaving the class updated but the service cache stale |
Inconsistent state: new bytecode active, but old service definitions still
cached | Widened the catch block to `catch (Throwable)` |
+| 6 | `IllegalArgumentException: 'other' is different type of Path` when
comparing an absolute path against a relative one | Every successful compile
failed to trigger a reload | Kept all paths consistently relative throughout
the compile/reload pipeline |
+| 7 | Watch keys became invalid after the in-process compiler wrote new class
files | Hot-reload would stop working after the very first compile |
Re-register the compiled-output directory at the end of every compile |
+| 8 | Shutting down while a compile finished could throw
`ClosedWatchServiceException`, logged as a scary "compilation error" |
Confusing noise in the logs on a completely normal shutdown | Catch `Exception`
broadly (this exception isn't an `IOException`) around that step |
+| 9 | Stopping the container while the watch thread was mid-event could throw
`RejectedExecutionException` when scheduling a reload | Confusing stack trace
during shutdown | Wrapped the scheduling call in a try/catch for this specific
case |
+| 10 | A single macOS `WatchService` event storm (one compiled file triggering
change events for the *entire* class tree) caused hundreds of unrelated classes
to be reloaded on every save | Noticeable slowdown on every save on macOS |
Unified all reload triggers through one shared debouncer, so a storm just
causes some harmless, bytecode-identical redundant reloads instead of a
performance hit |
+| 11 | A component with a `null` root location (no `rootLocation()`) threw a
`NullPointerException` while resolving its `src/main/java` path | Startup crash
— no source directories registered at all | Added a null guard before resolving
the source path in `registerSourceDirs()` |
+| 12 | An invalid/deleted watch key (`key.reset()` returning `false`) was
silently ignored | WatchService could quietly stop detecting changes in a
directory with no indication why | Check the return value and log a warning
naming the directory |
+| 13 | A `NoClassDefFoundError` (or similar) while registering one component's
`servicedef` directory could abort the loop, leaving every later component's
directory unwatched | Only the first few components' `services.xml` changes
would ever be detected | Wrapped each component's registration in its own
`catch (Throwable)` so one failure never blocks the rest |
+| 14 | Comparing modification times across two output directories (overlay vs.
Gradle's) to decide which compiled class "wins," including deleting the stale
copy when Gradle's was newer | Subtle correctness risk: a timestamp comparison
plus a reconciling side-effect (file deletion) that could easily fall out of
sync with the classpath's actual resolution order | Simplified to one rule: the
overlay directory always wins when a copy exists there — consistent with its
fixed position on the [...]
+| 15 | *(historical, superseded)* Multi-cycle reload loss — editing file B
produced a classloader that only knew about B; class A (changed in an earlier
reload) fell back to its stale, startup-time version | Any class changed more
than one reload ago silently regressed to old behavior | Prototype-only fix (an
`allChangedClasses` accumulator); not applicable to the current design, since
`redefineClasses` always applies the latest compiled bytecode directly with no
classloader to lose trac [...]
+| 16 | *(historical, superseded)* `ClassFormatError` (a `LinkageError`)
escaped as an unhandled `Error` when a class was read mid-write by a racing
compiler | Unhandled `Error` could crash a request in the servlet container |
Prototype-only fix (catch `LinkageError` during classload); not applicable now
— `applyCompile()` reads its own compiler output synchronously, so there's no
separate framework-side read racing the write |
+| 17 | *(historical, superseded)* Used
`Thread.currentThread().getContextClassLoader()` as a parent reference from a
background thread | Silent class-resolution failures if the servlet container
mutated the thread's context classloader | Prototype-only fix (switched to a
deterministic classloader reference); not applicable now — there's no custom
classloader in the current design at all |
+| 18 | *(historical)* A code comment claimed
`synchronized(DevReloadContainer.class)` while the actual code used
`synchronized(this)` | A future developer trusting the comment could introduce
a real data race | Comment/code mismatch fixed by reverting to a `private
final` instance field with unambiguous `synchronized(this)` |
+| 19 | *(historical)* A single save produced two reload attempts for the same
class — one direct call plus one from the WatchService event for the same
`.class` file | Harmless but wasteful; duplicate "Hot-reload complete" log
lines on every save | Originally patched with a timing-based suppression
window; the current design's real fix is architectural (fix #10) — one shared
debouncer per change type, so a duplicate at most causes one redundant,
harmless redefinition |
+| 20 | *(historical)* Hardcoded, per-OS guesses at common IDE install paths to
auto-detect a DCEVM-patched JVM (IntelliJ on macOS/Linux/Windows, JetBrains
Toolbox, etc.) | Silently broke for any install layout not on the guessed list
— a "works for some users, not others" pattern that grew another special case
with every new packaging variant | Removed all guessing in favor of one
explicit, always-honored input: `-PdcevmHome` or `DCEVM_HOME` |
+| 21 | A single unsupported structural change in a debounced batch caused
`Instrumentation.redefineClasses` to reject the *entire* batch in one call,
since every changed class's `ClassDefinition` was passed together | An
unrelated, valid method-body edit that happened to land in the same ~300ms
debounce window as a rejected class silently failed to apply too, with nothing
in the log distinguishing it from the actual offender |
`redefineWithPerClassFallback` tries the batch call first (th [...]
+| 22 | `applyCompile()` passed a `null` `DiagnosticListener` to
`javax.tools.JavaCompiler`, so a failed in-process compile produced no detail
about what actually failed | The log's only output on a broken save was the
generic "compilation failed — fix the error and save again", with no file, line
number, or compiler message to act on | Pass a
`DiagnosticCollector<JavaFileObject>` to the compiler task and log each
`ERROR`-level diagnostic (`file:line: message`) alongside the existing warning |
+| 23 | The post-compile reload step guessed which `.class` files to hot-swap
from the *source file's own base name* (`Outer.class`/`Outer$*.class`) | A
secondary top-level class in the same source file, or a lone non-public
top-level class named differently from its file (both legal Java), compiled to
disk correctly but was never queued for redefinition — it silently kept running
stale bytecode while the log claimed "Hot-reload complete" | Wrap the
compiler's file manager in `RecordingFi [...]
+| 24 | In-process compilation read source files using
`Charset.defaultCharset()` (passed `null` to `getStandardFileManager`) | The
project's real `compileJava` forces `options.encoding = 'UTF-8'` (root
`build.gradle`); on any JVM/OS whose platform default charset isn't UTF-8
(pre-JDK18, or an overridden `-Dfile.encoding` — plausible on Windows), a
hot-reload compile of a file with non-ASCII characters (i18n literals,
non-English comments) decoded differently than Gradle's own compile, pr [...]
+| 25 | A brand-new subdirectory created with files already inside it in one
filesystem operation (e.g. a `git checkout` that adds a whole package, an IDE
"extract to new package" refactor, or unzipping a folder into a watched source
tree) could have those pre-existing files never picked up | The OS/JDK
`WatchService` doesn't retroactively report `ENTRY_CREATE` for files that
existed before their directory was registered, so such files silently never
compiled until each was individually r [...]
diff --git a/devreload/src/main/java/org/apache/ofbiz/devreload/Debouncer.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/Debouncer.java
new file mode 100644
index 000000000..1d10a87cc
--- /dev/null
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/Debouncer.java
@@ -0,0 +1,77 @@
+/*******************************************************************************
+ * 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.util.HashSet;
+import java.util.Set;
+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.function.Supplier;
+
+/**
+ * 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 of {@link DevReloadContainer}'s
change
+ * pipelines (class reload, {@code services.xml} reload, Java compile) instead
of each
+ * hand-rolling its own pending-set/cancel/reschedule bookkeeping.
+ *
+ * <p>The backing executor is supplied lazily via {@code executorSupplier}
rather than
+ * captured directly: {@link DevReloadContainer} builds its three {@code
Debouncer}
+ * instances as field initializers, which run before its own executor is
created in
+ * {@code init()}.
+ */
+final class Debouncer<T> {
+ private final Supplier<ScheduledExecutorService> executorSupplier;
+ private final Set<T> pending = new HashSet<>();
+ private final Consumer<Set<T>> action;
+ private ScheduledFuture<?> scheduled;
+
+ Debouncer(Supplier<ScheduledExecutorService> executorSupplier,
Consumer<Set<T>> action) {
+ this.executorSupplier = executorSupplier;
+ 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 = executorSupplier.get().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);
+ }
+}
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
index 1f9539cb2..5c82638f1 100644
--- a/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
+++ b/devreload/src/main/java/org/apache/ofbiz/devreload/DevReloadContainer.java
@@ -25,6 +25,7 @@ import java.lang.management.ManagementFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
@@ -38,17 +39,18 @@ import java.nio.file.WatchService;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
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.Diagnostic;
+import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.StandardLocation;
import javax.tools.ToolProvider;
@@ -146,15 +148,24 @@ public class DevReloadContainer implements Container {
private static final String MODULE = DevReloadContainer.class.getName();
private static final String SERVICE_MODEL_CACHE_NAME =
"service.ModelServiceMapByModel";
+ // Shared by start()'s aggregated warning and warnUnwatched()'s
per-directory warning,
+ // so the two can't silently drift apart if the property/flag names ever
change.
+ private static final String SCOPE_HINT =
"-Dofbiz.hotreload.components=compA,compB (or "
+ + "-Photreload.components=compA,compB with the ofbizDev Gradle
task) to fit under "
+ + "the OS watch limit.";
+
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);
+ // The executor is supplied lazily (a Supplier, not a direct reference)
because these
+ // fields are initialized before startDebounceExecutor() creates
debounceExecutor --
+ // see Debouncer's own javadoc.
+ private final Debouncer<String> classReloadDebouncer = new Debouncer<>(()
-> debounceExecutor, this::applyReload);
+ private final Debouncer<Path> xmlReloadDebouncer = new Debouncer<>(() ->
debounceExecutor, this::applyServiceXmlReload);
+ private final Debouncer<Path> compileDebouncer = new Debouncer<>(() ->
debounceExecutor, this::applyCompile);
// Counts across
registerServicedefDirs()/registerSourceDirs()/registerAll(), so
// start() can emit one aggregated warning instead of leaving individual
failures
@@ -191,6 +202,11 @@ public class DevReloadContainer implements Container {
*/
private Set<String> allowedComponents;
+ /** {@code true} when {@code componentName} should be watched: no scoping
set, or it's in the scoped set. */
+ private boolean isAllowedComponent(String componentName) {
+ return allowedComponents == null ||
allowedComponents.contains(componentName);
+ }
+
/**
* Whether {@link #classesDir} ({@code build/classes/java/main}) itself is
watched, from
* {@code -Dofbiz.hotreload.watchBuildOutput}; defaults to {@code false}.
This tree mirrors
@@ -327,10 +343,7 @@ public class DevReloadContainer implements Container {
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);
+ logWalkFailure(classesDir, e);
}
Debug.logInfo("Hot-reload: watching compiled-output directory " +
classesDir.toAbsolutePath()
+ " for externally-produced .class files (set via
-Dofbiz.hotreload.watchBuildOutput=true).",
@@ -383,9 +396,7 @@ public class DevReloadContainer implements Container {
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);
+ + "hot-reload to just the components you're working on
with " + SCOPE_HINT, MODULE);
}
watchThread = new Thread(this::watchLoop, "ofbiz-hot-reload-watcher");
watchThread.setDaemon(true);
@@ -401,7 +412,7 @@ public class DevReloadContainer implements Container {
*/
private void registerServicedefDirs() {
for (ComponentConfig.ServiceResourceInfo sri :
ComponentConfig.getAllServiceResourceInfos("model")) {
- if (allowedComponents != null &&
!allowedComponents.contains(sri.getComponentConfig().getComponentName())) {
+ if
(!isAllowedComponent(sri.getComponentConfig().getComponentName())) {
continue;
}
try {
@@ -410,16 +421,8 @@ public class DevReloadContainer implements Container {
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);
- }
+ if (dir != null && Files.isDirectory(dir) &&
servicedefDirs.add(dir) && registerWatch(dir)) {
+ Debug.logInfo("Hot-reload: watching servicedef directory "
+ dir, MODULE);
}
} catch (GenericConfigException | URISyntaxException e) {
Debug.logWarning("Hot-reload: could not register servicedef
dir for "
@@ -450,7 +453,7 @@ public class DevReloadContainer implements Container {
if (cc.rootLocation() == null) {
continue;
}
- if (allowedComponents != null &&
!allowedComponents.contains(cc.getComponentName())) {
+ if (!isAllowedComponent(cc.getComponentName())) {
continue;
}
Path srcDir = cc.rootLocation().resolve("src/main/java");
@@ -459,10 +462,7 @@ public class DevReloadContainer implements Container {
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);
+ logWalkFailure(srcDir, e);
}
}
}
@@ -520,26 +520,23 @@ public class DevReloadContainer implements Container {
Path changed = dir.resolve(((WatchEvent<Path>)
event).context());
if (kind == StandardWatchEventKinds.ENTRY_CREATE &&
Files.isDirectory(changed)) {
- // New package directory created during compilation —
register it.
+ // New package directory (or a whole new subtree -- e.g. a
`git checkout`
+ // that adds a package, an IDE "extract to new package"
refactor, or
+ // unzipping a folder) -- register it AND dispatch any
files already inside
+ // it. The OS/JDK WatchService does not retroactively
report CREATE events
+ // for files that already existed before a directory was
registered, so
+ // without this seeding step, files that land here in the
same operation
+ // that created the directory would silently never compile
until each one
+ // is individually re-saved.
try {
- registerAll(changed);
+ registerAllAndSeed(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);
+ } else if (kind == StandardWatchEventKinds.ENTRY_CREATE ||
kind == StandardWatchEventKinds.ENTRY_MODIFY) {
+ // Ignore ENTRY_DELETE so that removing a source file (and
its .class
+ // output) does not cause a redefinition attempt against a
now-missing file.
+ dispatchChangedFile(dir, changed);
}
}
if (!key.reset()) {
@@ -549,52 +546,29 @@ public class DevReloadContainer implements Container {
}
}
- //
-------------------------------------------------------------------------
- // 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.
+ * Routes a single created/modified regular file through the correct
reload pipeline --
+ * a {@code .class} file under {@link #classesDir}, a {@code .xml} file
under a
+ * registered servicedef directory, or a {@code .java} file under a
registered source
+ * root. Shared between {@link #watchLoop}'s live WatchService events and
+ * {@link #registerAllAndSeed}'s seeding of files that already existed
when a new
+ * directory was first discovered.
*/
- 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);
+ private void dispatchChangedFile(Path dir, Path changed) {
+ String name = changed.toString();
+ if (name.endsWith(".class")) {
+ reloadClassFile(classesDir, changed);
+ } else if (name.endsWith(".xml") &&
servicedefDirs.stream().anyMatch(dir::startsWith)) {
+ xmlReloadDebouncer.add(changed);
+ } else if (name.endsWith(".java") &&
sourceRootDirs.stream().anyMatch(dir::startsWith)) {
+ compileDebouncer.add(changed);
}
}
+ //
-------------------------------------------------------------------------
+ // Debounced batching
+ //
-------------------------------------------------------------------------
+
/** 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);
@@ -612,6 +586,8 @@ public class DevReloadContainer implements Container {
return;
}
+ Map<String, Class<?>> loadedByName = indexLoadedClasses(batch);
+
List<ClassDefinition> defs = new ArrayList<>();
for (String className : batch) {
Path relative = Paths.get(className.replace('.', '/') + ".class");
@@ -624,15 +600,15 @@ public class DevReloadContainer implements Container {
+ hotReloadOutputDir.toAbsolutePath() + " or " +
classesDir.toAbsolutePath(), MODULE);
continue;
}
+ Class<?> loaded = loadedByName.get(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;
+ }
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);
@@ -644,6 +620,36 @@ public class DevReloadContainer implements Container {
return;
}
+ redefineWithPerClassFallback(defs);
+ }
+
+ /**
+ * Builds a {@code className -> Class} map covering just the classes in
{@code batch},
+ * scanning {@link Instrumentation#getAllLoadedClasses} once instead of
once per class
+ * name — that array holds every class loaded in the JVM (routinely tens
of thousands
+ * in a running OFBiz instance), so re-scanning it per class name in a
batch made every
+ * save pay for an O(batchSize * totalLoadedClasses) walk.
+ */
+ private Map<String, Class<?>> indexLoadedClasses(Set<String> batch) {
+ Map<String, Class<?>> byName = new HashMap<>();
+ for (Class<?> c : instrumentation.getAllLoadedClasses()) {
+ if (batch.contains(c.getName())) {
+ byName.put(c.getName(), c);
+ }
+ }
+ return byName;
+ }
+
+ /**
+ * Redefines every class in {@code defs} in one {@link
Instrumentation#redefineClasses}
+ * call when possible — the common, fast path. If the JVM rejects the
whole batch
+ * because one class contains a structural change it can't apply
+ * ({@link UnsupportedOperationException}), falls back to redefining each
class
+ * individually instead: otherwise, an unrelated valid method-body edit
that happened
+ * to land in the same debounced batch as an unsupported structural change
would
+ * silently fail to apply right along with it.
+ */
+ private void redefineWithPerClassFallback(List<ClassDefinition> defs) {
try {
instrumentation.redefineClasses(defs.toArray(new
ClassDefinition[0]));
// Clear service definition cache so newly added service methods
are discovered.
@@ -651,22 +657,52 @@ public class DevReloadContainer implements Container {
// 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);
+ Debug.logInfo("Hot-reload complete for: " + classNames(defs),
MODULE);
+ return;
} catch (UnsupportedOperationException e) {
+ Debug.logWarning("Hot-reload: batch redefinition rejected (" +
e.getMessage()
+ + ") -- retrying each class individually so other, valid
changes in the "
+ + "same save still apply.", MODULE);
+ } catch (Throwable e) {
+ Debug.logError(e, "Hot-reload failed for " + classNames(defs),
MODULE);
+ return;
+ }
+
+ List<String> applied = new ArrayList<>();
+ List<String> rejected = new ArrayList<>();
+ for (ClassDefinition def : defs) {
+ String className = def.getDefinitionClass().getName();
+ try {
+ instrumentation.redefineClasses(def);
+ applied.add(className);
+ } catch (UnsupportedOperationException e) {
+ rejected.add(className);
+ } catch (Throwable e) {
+ Debug.logError(e, "Hot-reload failed for " + className,
MODULE);
+ rejected.add(className);
+ }
+ }
+ if (!applied.isEmpty()) {
+ UtilCache.clearCache(SERVICE_MODEL_CACHE_NAME);
+ Debug.logInfo("Hot-reload complete for: " + applied, MODULE);
+ }
+ if (!rejected.isEmpty()) {
// ./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 "
+ Debug.logWarning("Hot-reload: " + rejected + " contain 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);
+ + "hot-swap. Restart OFBiz to pick it up.", MODULE);
}
}
+ private static List<String> classNames(List<ClassDefinition> defs) {
+ return defs.stream().map(def ->
def.getDefinitionClass().getName()).collect(Collectors.toList());
+ }
+
/**
* Best-effort detection of whether this JVM was launched with
* {@code -XX:+AllowEnhancedClassRedefinition} (e.g. a JetBrains Runtime),
which is
@@ -680,16 +716,6 @@ public class DevReloadContainer implements Container {
.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 {
@@ -707,48 +733,36 @@ public class DevReloadContainer implements Container {
if (compiler == null) {
return;
}
- try (StandardJavaFileManager fm =
compiler.getStandardFileManager(null, null, null)) {
- fm.setLocation(StandardLocation.CLASS_OUTPUT,
+ DiagnosticCollector<JavaFileObject> diagnostics = new
DiagnosticCollector<>();
+ // Explicit UTF-8: the project's real compileJava forces
options.encoding = 'UTF-8'
+ // (root build.gradle). Passing null here would fall back to
Charset.defaultCharset(),
+ // which isn't guaranteed to be UTF-8 on every JVM/OS (notably
pre-JDK18, or any JVM
+ // launched with an overridden -Dfile.encoding) and would silently
decode source files
+ // containing non-ASCII characters differently than Gradle's own
compile does.
+ try (StandardJavaFileManager standardFm =
+ compiler.getStandardFileManager(diagnostics, null,
StandardCharsets.UTF_8)) {
+ standardFm.setLocation(StandardLocation.CLASS_OUTPUT,
List.of(hotReloadOutputDir.toAbsolutePath().toFile()));
+ RecordingFileManager fm = new RecordingFileManager(standardFm);
// 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();
+ var units = standardFm.getJavaFileObjectsFromPaths(batch);
+ boolean ok = compiler.getTask(null, fm, diagnostics, 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);
- }
+ // Reload every .class file the compiler actually wrote
for this batch, per
+ // RecordingFileManager -- covers inner/anonymous classes
(Foo$1.class) and,
+ // unlike a name-based guess derived from the source
file's own base name,
+ // also a secondary top-level class in the same source
file, or a lone
+ // non-public top-level class named differently from its
file (both legal
+ // Java). A name-based guess would compile such a class to
disk correctly
+ // but never queue it for redefinition, leaving it
silently running stale
+ // bytecode.
+ for (Path absFile : fm.outputFiles) {
+ Path rel = hotReloadOutputDir.resolve(
+
hotReloadOutputDir.toAbsolutePath().relativize(absFile));
+ reloadClassFile(hotReloadOutputDir, rel);
}
// Re-register class directories so external compilations
(./gradlew classes
@@ -766,7 +780,8 @@ public class DevReloadContainer implements Container {
}
}
} else {
- Debug.logWarning("Hot-reload: compilation failed — fix the
error and save again", MODULE);
+ Debug.logWarning("Hot-reload: compilation failed — fix the
error and save again\n"
+ + formatErrors(diagnostics), MODULE);
}
}
} catch (Throwable e) {
@@ -774,23 +789,18 @@ public class DevReloadContainer implements Container {
}
}
- /**
- * 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);
- }
+ /** Formats the error-level diagnostics from a failed compile, one per
line, as {@code file:line: message}. */
+ private static String formatErrors(DiagnosticCollector<JavaFileObject>
diagnostics) {
+ StringBuilder sb = new StringBuilder();
+ for (Diagnostic<? extends JavaFileObject> d :
diagnostics.getDiagnostics()) {
+ if (d.getKind() != Diagnostic.Kind.ERROR) {
+ continue;
}
+ String source = d.getSource() != null ? d.getSource().getName() :
"?";
+ sb.append("
").append(source).append(':').append(d.getLineNumber())
+ .append(": ").append(d.getMessage(null)).append('\n');
}
- return null;
+ return sb.toString();
}
//
-------------------------------------------------------------------------
@@ -804,27 +814,75 @@ public class DevReloadContainer implements Container {
* so one overloaded directory never leaves the rest of the tree unwatched.
*/
private void registerAll(Path start) throws IOException {
+ walkAndRegister(start, false);
+ }
+
+ /**
+ * Logs that {@link #registerAll} itself failed for {@code dir} --
something more
+ * fundamental broke walking the tree at all (e.g. can't even list {@code
dir}), as
+ * opposed to an individual directory failing to register, which {@code
registerAll()}
+ * already logs and skips on its own via {@link #warnUnwatched}.
+ */
+ private void logWalkFailure(Path dir, IOException e) {
+ Debug.logWarning("Hot-reload: could not fully walk " + dir + ": " +
e.getMessage(), MODULE);
+ }
+
+ /**
+ * Like {@link #registerAll}, but also dispatches every regular file
already present
+ * under {@code start} through {@link #dispatchChangedFile}, as if a
CREATE event had
+ * fired for each. Used only when a brand-new directory materializes
mid-session (see
+ * {@link #watchLoop}) -- deliberately not used for the bulk registration
done at
+ * startup or the periodic re-registration in {@link #applyCompile}, where
+ * re-dispatching every already-known file on every call would be both
wasteful and
+ * cause spurious repeat reloads.
+ */
+ private void registerAllAndSeed(Path start) throws IOException {
+ walkAndRegister(start, true);
+ }
+
+ private void walkAndRegister(Path start, boolean seedExistingFiles) 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);
+ registerWatch(dir);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs) {
+ if (seedExistingFiles) {
+ dispatchChangedFile(file.getParent(), file);
}
return FileVisitResult.CONTINUE;
}
});
}
+ /**
+ * Registers {@code dir} with the WatchService for create/modify events,
incrementing
+ * {@link #watchDirsAttempted} and calling {@link #warnUnwatched} on
failure instead of
+ * letting it propagate. Shared by {@link #registerServicedefDirs} and
+ * {@link #walkAndRegister} so both directory-registration paths get the
same specific
+ * failure handling instead of each hand-rolling its own copy.
+ *
+ * @return {@code true} if the registration succeeded.
+ */
+ private boolean registerWatch(Path dir) {
+ watchDirsAttempted++;
+ try {
+ dir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY);
+ return true;
+ } 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 false;
+ }
+
/**
* 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
@@ -837,8 +895,7 @@ public class DevReloadContainer implements Container {
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);
+ + SCOPE_HINT, MODULE);
}
/**
diff --git
a/devreload/src/main/java/org/apache/ofbiz/devreload/RecordingFileManager.java
b/devreload/src/main/java/org/apache/ofbiz/devreload/RecordingFileManager.java
new file mode 100644
index 000000000..3fcb8e669
--- /dev/null
+++
b/devreload/src/main/java/org/apache/ofbiz/devreload/RecordingFileManager.java
@@ -0,0 +1,54 @@
+/*******************************************************************************
+ * 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.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import javax.tools.FileObject;
+import javax.tools.ForwardingJavaFileManager;
+import javax.tools.JavaFileManager;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+
+/**
+ * Wraps the compiler's standard file manager to record the absolute path of
every
+ * {@code .class} file it actually writes, so {@link DevReloadContainer}'s
post-compile
+ * reload step can hot-swap exactly what the compiler produced instead of
guessing from
+ * the source file's own base name -- a guess that misses a secondary
top-level class
+ * declared in the same file, or a lone non-public top-level class named
differently
+ * from its file.
+ */
+final class RecordingFileManager extends
ForwardingJavaFileManager<StandardJavaFileManager> {
+ final List<Path> outputFiles = new ArrayList<>();
+
+ RecordingFileManager(StandardJavaFileManager fileManager) {
+ super(fileManager);
+ }
+
+ @Override
+ public JavaFileObject getJavaFileForOutput(JavaFileManager.Location
location, String className,
+ JavaFileObject.Kind kind, FileObject sibling) throws IOException {
+ JavaFileObject file = super.getJavaFileForOutput(location, className,
kind, sibling);
+ outputFiles.add(Paths.get(file.toUri()));
+ return file;
+ }
+}