Skip to content

Wrap failures in the woven final-field writer like the runtime accessor - #4364

Merged
evanchooly merged 2 commits into
masterfrom
woven-final-writer-exception
Oct 6, 2026
Merged

evanchooly merged 2 commits into
masterfrom
woven-final-writer-exception

Conversation

@evanchooly

@evanchooly evanchooly commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #4355

The __writeXxx method that AddFieldAccessorMethods#writeFinalField weaves for final fields called Field.set without handling its checked IllegalAccessException, so a failure surfaced as an undeclared checked exception.

The Field.set call now sits in a trying(...) block that catches Exception and rethrows new RuntimeException("Failed to set final field '<name>'", e). This is the same type, message and catch scope as the runtime path in NestmateAccessorGenerator, so both tiers fail the same way. The catch covers all Exceptions, not only IllegalAccessException, to match the runtime tier, and every failure names the field. The Field lookup and caching stay outside the try.

Test: TestGeneration#testGeneratorFinalFieldWriteFailureIsWrapped puts a Field that was never made accessible into the woven class's __fieldCode cache. That makes Field.set throw IllegalAccessException through the AOT accessor. The test then checks the exception type, message and cause. With the fix reverted, the test fails because the raw IllegalAccessException reaches the caller.

Results: core 1300 tests (16 skipped), 0 failures under both critter and reflection. critter-maven verify passes (6 unit tests and 3 invoker tests).

Merge order

This PR is one of a batch that came out of the critter review issues (#4352–#4359). Suggested merge order:

  1. Run each JDK test leg on its own JDK #4361, Add a non-blocking CI leg that denies final field mutation (JEP 500) #4360 (CI only, independent)
  2. Don't re-set properties already passed to an entity's constructor #4362, then Test record entities with both mappers #4366 (Test record entities with both mappers #4366 depends on Don't re-set properties already passed to an entity's constructor #4362)
  3. Wrap failures in the woven final-field writer like the runtime accessor #4364, Give mapper copies with different mapping settings their own runtime models #4363, Name the cause when critter runtime generation can't access an entity #4365 (+ Document critter runtime generation's class loader requirement morphia-docs#20), Critter clean-ups: CritterClassLoader.findClass ordering and stale docs #4367 (independent, any order)
  4. Strengthen critter mapper tests: tier assertions and weak-cache release #4368 last (conflicts with Give mapper copies with different mapping settings their own runtime models #4363 and Name the cause when critter runtime generation can't access an entity #4365 in TestCritterMapper.java; rebase after they merge)

This PR: no dependencies; merges cleanly with the rest of the batch.

The __writeXxx method woven for final fields called Field.set without
handling its checked IllegalAccessException, so a failure escaped
undeclared. Wrap the call in a try/catch that rethrows
RuntimeException("Failed to set final field '<name>'", cause), matching
NestmateAccessorGenerator.

Fixes #4355
Copilot AI balanced review requested due to automatic review settings October 5, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The focused change aligns exception behavior and includes targeted regression coverage, with no unresolved findings.

Review effort: Balanced
Findings: None

What changed in this PR

Aligns woven final-field write failures with runtime accessor behavior, addressing #4355.

Changes:

  • Wraps Field.set exceptions in a field-specific RuntimeException, preserving the cause.
  • Adds a regression test checking the exception type, message, and cause.
File Description
core/​src/​test/​java/​dev/​morphia/​critter/​parser/​generator/​TestGeneration.java Tests wrapping of a forced final-field write failure.
core/​src/​main/​java/​dev/​morphia/​critter/​parser/​generator/​AddFieldAccessorMethods.java Adds exception wrapping while leaving field lookup and caching unchanged.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

evanchooly added a commit that referenced this pull request Oct 5, 2026
The `JdkTests` job's matrix lists `java: [ 17, 21, 25 ]`, but it passed
a hard-coded `java: 17` to the shared workflow. So all three legs (times
both mappers) ran on JDK 17, and 21 and 25 were never tested. It now
passes `${{ matrix.java }}`.

The legs still use the build compiled on JDK 17 (`reuseBuild: true`),
which is what we want: JDK 17 bytecode running on newer JVMs.

Expect this PR's CI to be the first real run on 21 and 25, so any
failures there are newly visible, not newly introduced.

Found while working on #4360.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. Merge any time.
evanchooly added a commit that referenced this pull request Oct 5, 2026
…4360)

Refs #4353

## What

- **`pom.xml`**: new `final-field-mutation` profile. It activates only
when **both** the JDK is 26+ (`<jdk>[26,)</jdk>`) **and**
`-Dfinal.field.mutation=<mode>` is set. It sets the `argLine` property
to `--illegal-final-field-mutation=<mode>`, so surefire passes it to the
forked test JVM. On older JDKs the profile stays off, because those JVMs
reject the option with `Unrecognized option` and won't start. It uses
the `argLine` property instead of surefire `<configuration>`, so it
still works with jacoco's `prepare-agent` in the `coverage` profile.
- **`.github/workflows/build.yml`**: new `FinalFieldMutationTests` job.
It runs `reflection` and `critter` on JDK 26 with
`-Dfinal.field.mutation=deny`, using `reuseBuild` like the other legs.
It is **`optional: true`** (`continue-on-error`) and is not in
`Release.needs`.

Option and values come from [JEP 500](https://openjdk.org/jeps/500):
`--illegal-final-field-mutation=allow|warn|debug|deny`. The JDK 26
default is `warn`. With `deny`, `Field::set` on a final field throws
`IllegalAccessException`.

## Why non-blocking

This leg is **expected to fail** for now. Morphia still mutates final
fields in the reflection mapper's `FieldAccessor`, critter's runtime
nestmate accessors, critter's AOT `__writeXxx` for final fields, and
`ConstructorCreator`'s re-set after construction (#4352). The leg's job
is to list every remaining dependency and catch new ones. Once those
paths are removed or put behind an explicit opt-in, drop `optional:
true` so it blocks. This PR uses `Refs` rather than `Fixes` because
#4353 also asks for a decision on entities whose finals can only be set
reflectively.

## Reproduce locally (JDK 26+)

```
./mvnw install -DskipTests
cd core && ../mvnw surefire:test -Dmorphia.mapper=critter -Dfinal.field.mutation=deny
```

## Notes

- I had no JDK 26 locally, so I haven't yet seen which tests fail under
`deny`. The first CI run of this leg will be the first list.
- Separate issue, not changed here: the existing `JdkTests` matrix
defines `java: [17, 21, 25]` but passes `java: 17` to the reusable
workflow, so every JDK leg currently runs on 17.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. The new JDK 26 leg is non-blocking and
expected to fail until the reflective final-field writes are removed
(#4362 removes the most common one).
evanchooly added a commit that referenced this pull request Oct 5, 2026
)

Fixes #4352

## Change
`ConstructorCreator.set()` used to queue every decoded property into
`pendingModels`/`pendingValues`, and `getInstance()` re-set all of them
after construction, including the ones the constructor had just
received. For an immutable, constructor-mapped entity that meant a
redundant reflective `Field.set` on each `final` field for every decoded
document. JDK 26 warns about that under [JEP
500](https://openjdk.org/jeps/500).

Now only properties with no matching constructor parameter are queued.
Properties the chosen constructor doesn't take, like the extra fields
when `bestConstructor` picks a partial constructor, are still set after
construction, as before.

## Behavior change
If a constructor transforms its argument (normalizes, copies, wraps it
in an unmodifiable collection, etc.), the constructor's result is kept.
Before, the raw decoded value silently overwrote it. This is almost
certainly what users expect, but it is a visible change for any entity
that relied on the overwrite.

## Tests
- New
`ConstructorCreatorTest#constructorArgumentsAreNotResetAfterConstruction`:
an immutable entity whose constructor lowercases/trims `name` and sorts
`tags` into an unmodifiable list, plus a non-constructor field `note`.
It checks that the normalized values survive the decode and that `note`
is still set. With the fix reverted it fails under both mappers
(`expected: <mixed case> but was: < MiXeD Case >`).
- Full `morphia-core` suite:
  - critter: 1300 run, 0 failures, 0 errors, 16 skipped
  - reflection: 1300 run, 0 failures, 0 errors, 16 skipped


## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4366 depends on it**; merge this first.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…models (#4363)

`CritterMapper(CritterMapper other, MorphiaConfig config)` reused
`other.runtimeModels` even when `config` changed settings that runtime
generation bakes into bytecode (collection/property naming,
discriminator function/key, property discovery, annotation providers).
Entities first mapped through the copy got classes generated under the
original's settings.

The copy now keeps the original's runtime models when the config is the
same instance or both configs have equal generation keys. Otherwise it
uses `RuntimeModels.forConfig(config, classLoader)`, which means
unshared models when the key is null (custom strategies) or the parent
is a `CritterClassLoader`. Entities already mapped are still cloned from
the original as before. The common copy paths (`copy()`, the datastore
copy, and copies that change only the database name) still share
generated classes.

Tests:
- `testCopyWithDifferentNamingGeneratesItsOwnModels`: fails without the
fix (`expected: <Name> but was: <name>`)
- `testCopyWithDifferentDatabaseSharesRuntimeModels`: a copy that
changes only the database still reuses the generated class

Core suite: 1301 tests, 0 failures, 16 skipped, with both critter and
reflection mappers.

Fixes #4356

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4368 conflicts with it** in
`TestCritterMapper.java`, so merge this before #4368.
@evanchooly
evanchooly merged commit 9e477cd into master Oct 6, 2026
110 of 115 checks passed
@evanchooly
evanchooly deleted the woven-final-writer-exception branch October 6, 2026 03:22
evanchooly added a commit that referenced this pull request Oct 6, 2026
…#4365)

Fixes #4357

Runtime critter generation defines property accessors as hidden
nestmates (`privateLookupIn(...).defineHiddenClass(..., NESTMATE)`),
which needs full privilege access to the entity, i.e. the entity must be
in Morphia's module (on the classpath: the same class loader). In
app-server shared-lib, isolated-loader, or JPMS layouts this failed with
a raw `IllegalAccessException` message in the fallback warning.

- `CritterGenerator.defineNestmate` wraps an `IllegalAccessException`
from `privateLookupIn`/`defineHiddenClass` (only those calls) in a new
internal `NestmateAccessException`.
- `CritterMapper.tryRuntimeGeneration` looks for it in the cause chain
and logs a clear warning: the entity isn't in Morphia's module/class
loader, falling back to reflection, pre-generate with critter-maven.
Other failures are logged as before.
- Tests: an entity compiled at test time and loaded by its own
`URLClassLoader` falls back to a reflective `EntityModel` and logs the
new message once; a unit test covers the cause-chain classification.
- Updated `.claude/skills/diagnose/SKILL.md`.

Docs: MorphiaOrg/morphia-docs#20

Core suite: critter 1301 run, 0 failures/errors, 16 skipped; reflection
1301 run, 0 failures/errors, 16 skipped.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies. **#4368 conflicts with it** in
`TestCritterMapper.java`, so merge this before #4368.
@evanchooly evanchooly added this to the 3.0.0 milestone Oct 6, 2026
evanchooly added a commit that referenced this pull request Oct 6, 2026
Adds round-trip tests (save, then find through the Datastore) for Java
records mapped as entities. Covers #4354.

**Depends on #4352.** These tests fail under every mapper and tier until
`ConstructorCreator` stops re-setting properties it already passed to
the constructor.

## What's covered
`dev.morphia.test.records.TestRecordEntities`:
- a record `@Entity` with an `@Id`, a `String`, an `int` renamed with
`@Property("years")`, and a `List<String>`. The test checks the stored
document's shape, then finds by `_id` and by the renamed component.
- a record embedded in a regular class entity, both as a single field
and in a list.

Each scenario runs against two sets of fixtures:
| Fixtures | Package | Critter tier |
|---|---|---|
| `AotRecordPerson` / `AotRecordAddress` / `AotRecordHolder` |
`dev.morphia.test.models.records` (under `morphia.packages`) | AOT,
generated by critter-maven `generate-test-models` |
| `RuntimeRecordPerson` / `RuntimeRecordAddress` / `RuntimeRecordHolder`
| `dev.morphia.test.records` (outside `morphia.packages`) | runtime
generation |

Every test asserts which tier built the model: a reflection
`EntityModel` under `reflection`, and under `critter` a
`CritterEntityModel` whose class loader is or isn't a
`CritterClassLoader`. If critter quietly falls back to another tier, the
test fails rather than passing against the wrong tier.

## Current failure (all 4 tests, both mappers)
Saving works. Decoding fails in `ConstructorCreator.getInstance()`. The
canonical constructor builds the record correctly, then the
`pendingModels` loop calls `PropertyModel.setValue` on every component
again. `Field.set` always throws for record fields, even after
`setAccessible(true)`:
- reflection: `FieldAccessor.set` -> `IllegalAccessException: Can not
set final ... AotRecordPerson.id`
- critter AOT: the generated `IdAccessor.set` -> the injected
`AotRecordPerson.__writeId` -> `Field.set` -> same exception
- critter runtime: `Failed to set final field 'id'`

I checked this locally. Queuing only the properties that have no
constructor parameter (the #4352 fix) makes all 4 tests pass under both
`critter` and `reflection`, with the tier assertions passing too. That
change is not in this PR.

Critter generation needed no fixes: AOT and runtime both generate models
for the records without complaint.

## Full core suite (on this branch, without the #4352 fix)
- critter: 1303 run, 0 failures, 4 errors (the new tests), 16 skipped
- reflection: 1303 run, 0 failures, 4 errors (the new tests), 16 skipped

Fixes #4354


## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** #4362 is merged and this branch includes it, so it's ready
to merge.

### CI note

Pull-request CI runs `install` without critter-maven's
`generate-test-models`, so there are no pre-generated models there and
the `Aot*` fixtures get runtime-generated models. The tier check expects
AOT exactly when the pre-generated model is on the classpath (push
builds and local runs that generate them) and runtime generation
otherwise. With the check forced to expect runtime, the AOT tests fail
when models are present, so it still catches a silent tier fallback.
Follow-up for the CI gap: #4369.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…cs (#4367)

Fixes #4358

- **`CritterClassLoader.findClass`**: the name is now added to
`definedTypes` only after `defineClass` succeeds, so a
`ClassFormatError`/`VerifyError` from bad bytes no longer leaves
`getResource` hiding the parent's `.class` resource. The removed bytes
are deliberately not restored: a retry would define the same bytes and
fail the same way.
- **`CritterMapper`**: the class-level and `tryRuntimeGeneration`
javadocs now say runtime generation uses hidden nestmate accessors, not
VarHandles.
- **`CritterClassLoader.shouldRegister`**: the comment now explains that
`NestmateAccessorRegistry`'s map, though keyed per `CritterClassLoader`,
is a static field, so the generator (which registers through the
parent's copy of the class) and the generated models (which look
accessors up) must see the same `Class`.

New test `CritterClassLoaderTest.failedDefinitionsDoNotHideResources`
registers invalid bytes for `dev.morphia.critter.Critter`, expects
`ClassFormatError` on load, then asserts the `.class` resource is
visible again. It fails without the fix.

Core suite: 1300 tests, 0 failures, 16 skipped with both
`-Dmorphia.mapper=critter` and `reflection`.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** no dependencies; merges cleanly with the rest of the batch.
evanchooly added a commit that referenced this pull request Oct 6, 2026
…se (#4368)

Fixes #4359

Test-only changes in `TestCritterMapper`:

- **Tier assertions.** New `assertRuntimeGenerated(EntityModel)` helper
checks the model is a `CritterEntityModel` whose class was loaded by a
`CritterClassLoader`. It's used in every test meant to exercise runtime
generation, so they fail loudly instead of quietly testing AOT models if
`dev.morphia.mapping` is ever added to the test `morphia.packages`. It
isn't used in the reflection-fallback, imported-model (`register`), or
null/non-entity tests.
- **Weak-cache release.** `testRuntimeClassLoaderReleasedWithItsMappers`
maps an entity through two sharing mappers (plus a copy) inside a helper
that returns only a `WeakReference` to the generated loader, then runs
`System.gc()` in a bounded loop (up to 10s) and asserts the reference
clears. The config uses a discriminator key no other test uses, so no
other mapper in the JVM can share and pin that `RuntimeModels`. This
covers both `CritterMapper.RuntimeModels.SHARED` and
`NestmateAccessorRegistry`. No leak found: the loader is collected. I
checked the test can fail by temporarily holding the mapper in a static
field, and the test then failed as expected.
- **Concurrency.** `testConcurrentMappingAcrossSharingMappers`: 8
threads, each with its own `CritterMapper` using the same config (a
fresh key, so generation actually races), released together by a
`CountDownLatch`. All succeed, all models share one generated class,
each mapper registers its own model instance.

Full core suite: 1301 tests, 0 failures, 0 errors, 16 skipped, with both
`-Dmorphia.mapper=critter` and `-Dmorphia.mapper=reflection`.

## Merge order

This PR is one of a batch that came out of the critter review issues
(#4352–#4359). Suggested merge order:

1. #4361, #4360 (CI only, independent)
2. #4362, then #4366 (#4366 depends on #4362)
3. #4364, #4363, #4365 (+ MorphiaOrg/morphia-docs#20), #4367
(independent, any order)
4. #4368 last (conflicts with #4363 and #4365 in
`TestCritterMapper.java`; rebase after they merge)

**This PR:** **merge last.** It conflicts with #4363 and #4365 in
`TestCritterMapper.java` (all three add tests there); rebase onto master
after they merge and re-run the suite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Woven final-field writer lets IllegalAccessException escape unwrapped

2 participants