Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8ed7962
fix(devloop): restart for a Spring bean the running context never saw
totally-not-ai[bot] Sep 9, 2026
25509e0
docs(devloop): record the new-bean escalation in the daemon README
totally-not-ai[bot] Sep 9, 2026
9685bd3
fix(devloop): decide a new bean from the inventory, not from what is …
totally-not-ai[bot] Sep 9, 2026
eab199b
test(devloop): unit-test the decision and the reply the connector sends
totally-not-ai[bot] Sep 9, 2026
0893610
test(devloop): pin what inspect reads off the loaded class
totally-not-ai[bot] Sep 9, 2026
22a02c1
Merge remote-tracking branch 'origin/main' into issues/25559-new-spri…
totally-not-ai[bot] Sep 10, 2026
5ce2661
refactor(devloop): name the source suffix, and hoist the descriptor l…
totally-not-ai[bot] Sep 10, 2026
a7a1a80
docs(devloop): link the stereotype list rather than pointing at it
totally-not-ai[bot] Sep 10, 2026
e88dd24
test(devloop): say why the new-bean fixture touches no loaded class
totally-not-ai[bot] Sep 10, 2026
d3ab425
test(devloop): cover @Component as well as @Service for a new bean
totally-not-ai[bot] Sep 11, 2026
dcfb4b3
test(devloop): assert every stereotype descriptor, not two of them
totally-not-ai[bot] Sep 11, 2026
74ac99d
fix(devloop): a new bean the inventory was seeded with is still new
totally-not-ai[bot] Sep 11, 2026
168a502
fix(devloop): take the baseline when the app registers, not on first …
totally-not-ai[bot] Sep 11, 2026
1536984
fix(devloop): read the baseline against the launch, not against the c…
totally-not-ai[bot] Sep 11, 2026
53830e3
fix(devloop): ask per class and by binary name, not per source and by…
totally-not-ai[bot] Sep 11, 2026
a8ee6d3
fix(devloop): snapshot what the launch had, rather than dating class …
totally-not-ai[bot] Sep 11, 2026
9bb071d
Merge branch 'main' into issues/25559-new-spring-bean-restart
tltv Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions flow-devloop-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,29 @@ its answer rather than claiming success.
old one. This includes Spring Data repositories, which are bare interfaces with
no annotation to spot them by, so the connector keys on the loaded proxy
instead.
- **A bean or an entity the application has never seen must restart too.**
Component scanning runs once, at startup, over the classes that existed then,
and HA's Spring plugins that would rescan are disabled (below) — so a class
that is only now being given `@Component`, `@Service`, `@Repository`,
`@Controller`, `@RestController`, `@ControllerAdvice`,
`@RestControllerAdvice` or `@Configuration` gets no bean definition, and the
first injection point fails with `NoSuchBeanDefinitionException` naming
Spring rather than the loop. A brand-new `@Entity` is in exactly the same
position against a metamodel and a schema fixed at startup. It is the one
escalation with no redefine behind it: the class was never loaded, so there
is nothing to swap and every signal read off a loaded class is empty. It
takes both sides to say so, and deliberately: `REDEFINE` answers which of
the change-set's classes carry a stereotype (`stereotypes=`, read out of the
compiled bytes — the same reading `@Entity` already needed), and the
daemon's own inventory answers which of them the running application never
had. **Asking the app whether it has loaded the class does not work**:
HotswapAgent watches the output directory on its own schedule and defines a
new class when it sees one, so that answer flips between applies — and a
defined class is still not a bean definition. The inventory is re-seeded
from disk at every registration, so it does not flip. A stereotype composed
through a project's own meta-annotation is the known gap: only the custom
annotation is in the class's constant pool, so that one is still a restart
to ask for by hand.
- **Hot-swap coverage differs sharply between stock HotSpot and a JBR.** Only a
JBR gets `-XX:+AllowEnhancedClassRedefinition`; on stock HotSpot a structural
change is simply rejected and escalates. A project needing a Java version no
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found three issues:

  • [P1] Compile.java:844 treats a known source file as proof that every class it declares existed at startup. Adding a nested static @Component, or another package-private top-level component, to an existing source therefore produces no unknownTypes entry. The connector reports the new class’s stereotype, but blockedReason does not restart, leaving the bean unregistered—the core failure this PR intends to fix.

  • [P1] Compile.java:870 updates the same applied map used as the startup inventory after successful hot swaps. Sequence: add a plain class → apply successfully → annotate it with @Component → apply again. The first apply inserts its source into applied; the second considers the class known even though component scanning has never seen it, so no restart occurs.

  • [P2] Compile.java:814 and TransactionEngine.java:1662 correlate classes by simple name. If an existing stereotype and a newly added plain class share a name in different packages, editing both in one transaction falsely reports the plain class as a new Spring bean and forces an unnecessary restart. Binary names are needed to preserve identity.

Also take care of the remaining 2 Sonar findings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tltv All three fixed, with a test each, and the two Sonar findings with them.

P1, per-source answer. Right — and the fix is to stop asking about sources. The question is now asked of each class the apply compiled, against the class files its output packages held before it compiled them: a class file that was not there, or that is newer than the launch, is one the application started without. A second top-level @Component in a file the application has always had now escalates under its own name.

One thing worth recording from testing it: a nested @Component escalates on this JVM for an unrelated reason — nesting rewrites the enclosing class's NestMembers attribute and redefineClasses refuses outright (class redefinition failed: attempted to change the class NestHost, NestMembers...). So the test uses a second top-level class, which leaves the enclosing class byte-identical and actually exercises this rule rather than the JVM's refusal. The nested case is covered too, by the same per-class read.

P1, applied doubling as the startup inventory. Same root, same fix: the class file an earlier apply wrote is newer than the launch, so add-plain-class → apply → annotate → apply now escalates on the second apply. There is an integration test for exactly that sequence.

P2, simple-name correlation. Agreed, it was an identity bug. stereotypes= now carries binary names — alone among the reply's fields, because it is the one read by machine rather than by a person, and the daemon matches the change-set against it exactly. Only the reason shortens the name again, so the message still reads new Spring bean (Extra). The unit test pins that a stereotype sharing a simple name with a new class in another package escalates nothing.

Sonar. The class-file read no longer swallows an IOException: an unreadable output directory is not a state to decide a verdict from, so the apply fails with that as its reason. And the protocol's free-text tail is a constant.

Green: 149 daemon and 346 vaadin-dev-server unit tests, and 38 dev-loop ITs — the exception being DevLoopBrowserIT.siblingModuleEdit_isVisibleOnceTheViewRendersAgain, which times out on my machine identically at the commit before any of this work, so it is not from these changes.

Re-measured end to end on the reported flow (start, add a class with a bare @Component, apply) and on the variant where something else compiles the class first: both tx#1 classification=restart, escalating with the named reason.

Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ private static List<Path> joined(List<Path> first, List<Path> second) {
private static final List<String> PUBLIC_RESOURCE_ROOTS = List
.of("META-INF/resources/", "static/", "public/", "resources/");

/**
* What makes a file a Java source, and what a type's name is its file name
* minus.
*/
private static final String JAVA_SUFFIX = ".java";

/**
* What makes a file a class file, and what a binary name is its path minus.
*/
private static final String CLASS_SUFFIX = ".class";

/** A file and the module it belongs to, which is all a walk ever needs. */
private interface Visitor {
void accept(Reactor.Module module, Path file, Stamp stamp);
Expand All @@ -208,6 +219,21 @@ private interface Visitor {
/** Fingerprints of Java sources as of the last time they went live. */
private final Map<Path, Stamp> applied = new java.util.concurrent.ConcurrentHashMap<>();

/**
* The binary names of the class files the running application was launched
* with, as of the last seed.
* <p>
* This is the whole of the answer to "has the application ever had this
* class?", and it has to be a snapshot rather than anything computed later.
* A timestamp cannot serve: the class file of a bean the application does
* run is rewritten by the first apply that hot swaps it, and every apply
* after that would then read it as a class the application never had -
* measured, the second method-body edit to a @Service restarted for a bean
* the context had held all along.
*/
private final Set<String> launchedWith = java.util.concurrent.ConcurrentHashMap
.newKeySet();

/**
* Fingerprints as of the last browser notification, keyed by source path.
*/
Expand Down Expand Up @@ -724,7 +750,7 @@ List<Path> removeClassArtifacts(List<Path> sources) throws IOException {
removed.add(artifact);
}
String nestedPrefix = fileName.toString().substring(0,
fileName.toString().length() - ".class".length()) + "$";
fileName.toString().length() - CLASS_SUFFIX.length()) + "$";
for (Path nested : nestedClasses(directory, nestedPrefix)) {
if (Files.deleteIfExists(nested)) {
removed.add(nested);
Expand All @@ -747,7 +773,7 @@ private static List<Path> nestedClasses(Path directory, String prefix) {
return entries.filter(path -> {
Path name = path.getFileName();
return name != null && name.toString().startsWith(prefix)
&& name.toString().endsWith(".class");
&& name.toString().endsWith(CLASS_SUFFIX);
}).toList();
} catch (IOException e) {
return List.of();
Expand Down Expand Up @@ -787,7 +813,7 @@ List<Path> classpathForced(Launch.Project project) {
continue;
}
walk(module, module.sourceDir(),
path -> path.toString().endsWith(".java"),
path -> path.toString().endsWith(JAVA_SUFFIX),
(owner, file, stamp) -> forced.add(file));
}
forced.sort(Comparator.naturalOrder());
Expand All @@ -805,6 +831,37 @@ List<String> classpathChangedModules(Launch.Project project) {
}).map(Reactor.Module::name).toList();
}

/**
* Which of these classes the running application never had.
* <p>
* Asked per class rather than per source, which is the only way to get it
* right: a second top-level class or a nested one, added to a file the
* application has always had, is a class the application has never had. And
* answered from the snapshot taken when the application was launched, which
* is the only thing that stays true - an apply rewrites the class files it
* swaps, so anything read off the classpath afterwards says the
* application's own beans are strangers to it.
* <p>
* A class compiled by an earlier apply is therefore still unknown, and
* rightly: the application has had it on the classpath since, but component
* scanning ran before it existed and no apply re-runs that. Only the
* restart does, and that re-seeds this.
*
* @param binaryNames
* the classes this apply compiled
* @return those of them the application never had, sorted
*/
List<String> classesUnknownToTheApp(List<String> binaryNames) {
return binaryNames.stream().filter(name -> !launchedWith.contains(name))
.sorted(Comparator.naturalOrder()).toList();
}

private static String binaryNameOf(Reactor.Module module, Path classFile) {
String relative = module.classesDir().relativize(classFile).toString();
return relative.substring(0, relative.length() - CLASS_SUFFIX.length())
.replace(File.separatorChar, '.');
}

/** Records that these sources are now live in the running JVM. */
void markSourcesApplied(List<Path> sources) {
for (Path source : sources) {
Expand All @@ -818,17 +875,33 @@ void markSourcesApplied(List<Path> sources) {
* there.
*/
void seedFromDisk() {
seedFromDisk(Long.MAX_VALUE);
seedFromDisk(Long.MAX_VALUE, Long.MAX_VALUE);
}

/**
* @param startedAtMillis
* when the running application was launched, which bounds which
* sources it can have read
* @param frontendCutoffMillis
* how new a frontend file may be and still count as live; see
* {@link #seedFrontend(long)}
*/
void seedFromDisk(long frontendCutoffMillis) {
void seedFromDisk(long startedAtMillis, long frontendCutoffMillis) {
seedClasses();
applied.clear();
forEachSource((module, source, stamp) -> applied.put(source, stamp));
forEachSource((module, source, stamp) -> {
// Only what the application could have read. A source written
// after it was launched is not one it started with, whenever this
// baseline happens to be taken - and it can be taken late: the
// compile leg is built lazily, and a registration is handled a
// moment after the command that waited for it returned. Both are
// windows a developer's next keystroke fits into, and a baseline
// that walks "whatever is on disk now" claims those edits as the
// application's own.
if (stamp.modified() <= startedAtMillis) {
applied.put(source, stamp);
}
});
seedResources();
// Load-bearing for the frontend leg, not just tidiness: a bundled
// frontend edit escalates to a restart, the restart re-registers, and
Expand All @@ -838,10 +911,27 @@ void seedFromDisk(long frontendCutoffMillis) {
seedFrontend(frontendCutoffMillis);
}

/**
* The classpath as the application was launched with it, by binary name.
* <p>
* One walk per application start, which is where a walk of the output
* directories belongs: every other answer about it is a comparison against
* this.
*/
private void seedClasses() {
launchedWith.clear();
for (Reactor.Module module : modules) {
walk(module, module.classesDir(),
path -> path.toString().endsWith(CLASS_SUFFIX),
(owner, file, stamp) -> launchedWith
.add(binaryNameOf(owner, file)));
}
}

private void forEachSource(Visitor action) {
for (Reactor.Module module : modules) {
walk(module, module.sourceDir(),
path -> path.toString().endsWith(".java"), action);
path -> path.toString().endsWith(JAVA_SUFFIX), action);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,41 @@ Project project(Log progress) {
}
}

/**
* The resolved project if a sound one is already in hand, and never a
* resolution.
* <p>
* For callers on a path that must not block: {@link #project()} runs Maven
* when the stamp has moved, which is seconds, and the registration
* connection is being answered on the thread that would wait for it.
* <p>
* The fallback does not count. A resolution that wrote a current stamp and
* then failed to be read back leaves the application module alone standing
* in for the project, and a caller that builds on that builds on the wrong
* module set - which the next apply then reports as "module set changed"
* with no pom edit behind it. Such a caller is better off waiting for the
* apply that can resolve properly.
*
* @return the current project, or empty if resolving - or resolving again -
* is what it would take to have a sound one
*/
Optional<Project> projectIfResolved() {
Project current = project;
return current != null && !classpathUnusable && stampIsCurrent()
? Optional.of(current)
: Optional.empty();
}

/**
* The daemon's own sink, for a caller that has nobody else to report to.
* <p>
* Work triggered by an application registering has no client waiting on it,
* and what it has to say belongs in {@code daemon.log} rather than nowhere.
*/
Log log() {
return log;
}

/**
* Set when {@link #project()} last had to fall back; empty when it is
* sound.
Expand Down
Loading
Loading