Skip to content

LDEV-6455 - read spooler tasks with the class loader that created them - #2807

Closed
shane-tw wants to merge 2 commits into
lucee:7.1from
shane-tw:fix/spooler-extension-classloader-71
Closed

shane-tw wants to merge 2 commits into
lucee:7.1from
shane-tw:fix/spooler-extension-classloader-71

Conversation

@shane-tw

@shane-tw shane-tw commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Ticket

LDEV-6455

Problem

SpoolerEngineImpl.getTask() deserializes task files with the core class loader. Since mail moved into an extension, MailSpoolerTask and the jakarta.mail types it holds live in the extension's RPC class loader (declared via maven="org.lucee:mail:1.1.0.8-RC"), so reading the file fails with ClassNotFoundException: org.lucee.extension.mail.spooler.MailSpoolerTask and the file is deleted. Spooled cfmail is stored fine and then silently lost.

Fix

The engine is handed the live task in add(), so it already knows the loader that defines the task class. It now remembers that loader per task class and deserializes each file with the loader of the class that wrote it, the same rule Java serialization itself follows. The core loader remains the default, so core tasks are read exactly as before. No other class loaders are consulted.

A file whose task class cannot be resolved is no longer deleted. It is skipped, logged, and read once the class becomes known again (for example after a restart, as soon as the extension queues its next task). Files that fail for any other reason are still deleted as before.

Verification

test/tickets/LDEV6455.cfc spools a mail with a sendTime a day out, then lists spooler tasks, which reads the file back. Fails on 7.1 as it stands, passes with this change.

The second commit replaces the class loader search from the first one; the first commit is kept for history.

🤖 Generated with Claude Code

@shane-tw
shane-tw marked this pull request as draft September 7, 2026 08:00
A spooler task does not have to come from the core. Since mail moved out into the
Mail Extension, MailSpoolerTask is contributed by an extension, and an extension's
classes are not visible to the core class loader: they live either in the
extension's own OSGi bundle or - when the extension declares its implementation
through Maven coordinates, as the mail extension does with
<tag-class maven="org.lucee:mail:..."> - in one of the RPC class loaders.

getTask() resolved classes against the core class loader alone, so reading the task
back threw ClassNotFoundException, and because the task file is then deleted the
queued mail disappeared. Nothing surfaced to the caller: cfmail returned normally
and the spooler simply had nothing left to run.

Fall back to ClassUtil (bundles) and then to the class loaders
PhysicalClassLoaderFactory has cached (Maven coordinates), keeping the core class
loader as the first choice.

Everything the task references then has to be resolved through whichever loader was
found, so that loader is remembered for the rest of the stream and preferred from
then on. Resolving each class independently is not enough: the classes come out of
different loaders and the same name becomes two distinct types, which fails later as
"cannot assign instance of jakarta.mail.internet.InternetAddress to field
SMTPClient.from of type jakarta.mail.internet.InternetAddress". Preferring the
extension loader is safe for core classes, since it delegates to the core loader for
anything it does not provide itself.

This is the other half of 032ca3d: storing a task that cannot be persisted no
longer loses it, but until now a task that stored fine was still lost on the way
back in.
@shane-tw
shane-tw force-pushed the fix/spooler-extension-classloader-71 branch from ba17ac6 to f146aec Compare September 7, 2026 08:17
@shane-tw
shane-tw marked this pull request as ready for review September 7, 2026 08:26
Comment thread core/src/main/java/lucee/commons/lang/PhysicalClassLoaderFactory.java Outdated
Comment thread core/src/main/java/lucee/runtime/spooler/SpoolerEngineImpl.java
@shane-tw

shane-tw commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

This is okay to close if you want. At least the test case should help.
Edit: I'll have Claude take another look later anyway but it's probably better a human try

Replaces the class loader search: the engine already receives the live
task in add(), so it remembers that class's loader and deserializes the
file with it. No other loaders are consulted. A file whose task class is
not loaded is skipped instead of deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@shane-tw shane-tw changed the title LDEV-6455 - read back a spooler task whose class comes from an extension LDEV-6455 - read spooler tasks with the class loader that created them Sep 11, 2026
@shane-tw

Copy link
Copy Markdown
Contributor Author

I've had claude rework/simplify this PR now, ready for re-review. Passed the full test suite again

@michaeloffner

Copy link
Copy Markdown
Contributor

A fix for LDEV-6455 has landed on 7.1 in commit a884d212e.

Approach: on deserialization, when the core engine class loader cannot resolve a task class, the class is resolved on demand against the currently installed extension — via the cfmail tag's registered ClassDefinition (getTag("mail").getTagClassDefinition().getClazz().getClassLoader()). That loader is reused for the rest of the same object graph. It sits in JavaConverter.ObjectInputStreamImpl.resolveClass, so it covers both the spooler reload (SpoolerEngineImpl.getTask) and ObjectLoad.

Why resolve on demand rather than cache the creating loader: a spool task is written now and read later, and the read can happen in a different class loader context than the write (after a restart, or after an extension update). So the read must resolve the class against whatever loader currently provides it, not a loader remembered from write time. Resolving from the current tag registration works straight after a restart (the tag library is registered at startup) and tracks extension updates.

Also added test/tags/MailSpoolSerialization.cfc (an ObjectSaveObjectLoad round-trip of a real MailSpoolerTask, which exercises both the write side — the 1.1.0.6 NotSerializableException — and the read side — the ClassNotFoundException) and bumped the loader to 7.1.1.4-SNAPSHOT.

@michaeloffner

Copy link
Copy Markdown
Contributor

@shane-tw thanks for digging into this and for the clear writeup — the diagnosis is exactly right, and your LDEV6455.cfc (sendTime a day out, so no mail server is needed) is a nicer test than mine.

I went a different route for the fix, and I want to explain why we can't take the cached-loader approach.

Remembering the loader that created the task (the taskLoaders map populated in add()) ties reading to the write-time class loader instance. But the point of the spooler is persistence: a task is written now and read later, and the read can happen in a different class loader context than the write. That breaks the cache in exactly the cases persistence exists for:

  • After a restart, the map is in-memory, so it is empty. The .tsk files that survived the restart — the whole reason the spooler writes to disk — then cannot be resolved, because the one thing needed to read them (the creating loader) did not survive. They sit unread until some later add() happens to repopulate the map, which in a quiet app may never come. Keeping the file instead of deleting it avoids data loss, but the task still is not delivered.
  • After an extension update, the cached instance is stale; the task has to be read against the new loader.

So the read has to resolve the class fresh, against whatever loader currently provides it, rather than a loader remembered from write time. That is what the landed fix does (resolve via the current cfmail tag registration), so it works right after a restart and tracks extension updates. The one thing no loader strategy can fix is serialization format compatibility across an incompatible class change (serialVersionUID) — that is inherent.

Your "keep the file on ClassNotFoundException instead of deleting it" is a good improvement in its own right, and we will likely fold that in as a safety net on top. Thanks again for the work.

@shane-tw
shane-tw deleted the fix/spooler-extension-classloader-71 branch September 11, 2026 13:27
michaeloffner added a commit that referenced this pull request Sep 11, 2026
Spools a cfmail with a sendTime a day out (stored, never sent, no mail server
needed), then reads it back via getSpoolerTasks - the path that failed with
ClassNotFoundException before the deserialization fix. Test authored by Shane
Weeks (@shane-tw) in PR #2807.
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.

2 participants