Skip to content

feat(class-loader): handle backend requests from multiple classloaders - #713

Merged
BrayanDSO merged 7 commits into
ankidroid:mainfrom
david-allison:classloader
Oct 18, 2026
Merged

BrayanDSO merged 7 commits into
ankidroid:mainfrom
david-allison:classloader

Conversation

@david-allison

@david-allison david-allison commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Note

Assisted-by: Claude Opus 5 - implementation

Note

(cherry picked from commit cde8375) - the change should not have gone to main without review.

Each Classloader needs a separate on-disk file or a UnsatisfiedLinkError is thrown.

To fix this:

  • Persist a collection of 10 files on-disk
    • We do this as we cannot guarantee file deletion on Windows
librsdroid-<sha1>.dylib      the extracted library
librsdroid-<sha1>-1.dylib    a copy of the library, created if a second classloader requires it
  • Walk through the libraries using a cursor and load an unused one
  • Devise a script for Anki-Android to warn developers if there are too many stale copies on disk (each is 60MB)

@BrayanDSO

Copy link
Copy Markdown
Member

I'm okay with the compromise.

Is the ideal number of copies 10? Could it be less or is it the best alternative?

@david-allison

david-allison commented Aug 9, 2026 •

Copy link
Copy Markdown
Member Author

10 exists to allow for some growth room. I'd be happy to halve it to 5.

I suspect in reality we'll be fine with 3 for a while, but this will need to be merged and implemented to know for certain

@david-allison

david-allison commented Aug 9, 2026 •

Copy link
Copy Markdown
Member Author

Halved to 5. Will squash when I'm back at a PC

@criticalAY criticalAY left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am ok with this change, just one question rest is good

try {
copyTo(temporary, overwrite = true)
// this fails on Windows if another process created `destination` first
temporary.renameTo(destination)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can this fail?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes - security issues/disk space issues. Thanks!

A later UnsatisfiedLinkError would expose this, but I've fixed it to be more explicit

@david-allison
david-allison marked this pull request as draft August 11, 2026 00:05
@david-allison

Copy link
Copy Markdown
Member Author

I plan to add 2/3 more commits here

@mikehardy mikehardy left a comment

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.

Human note: the changes in general looked good to me but there seem to be a few edge cases to puzzle through based on an agent look
Assisted-by: Grok 4.7

The copy-per-classloader approach is the right fix for already loaded in another classloader. A few edge cases in the new helpers:

createAtomically replaces the destination on macOS and Linux

File.renameTo replaces an existing file on POSIX and returns true, so !renameTo && !exists() misses a lost race. Windows keeps the existing file because rename fails when the target exists. Both writers currently write the same bytes, so a race still publishes a valid library, but the comment's guarantee ("their file is left in place") holds on Windows only. A partial or different winner would be published.

Files.move with ATOMIC_MOVE and without REPLACE_EXISTING, catching FileAlreadyExistsException, matches that comment on both platforms.

A zero-length destination is reused forever

numberedCopy skips the write when exists() is true. A crash leftover, or a failed publish that still created the path, is then loaded as-is. Runtime.load throws an UnsatisfiedLinkError whose message is not "already loaded in another classloader", so the cursor does not advance to the next slot. Compare the destination length to the source and rewrite when they differ.

Exhaustion path

loadCopyFreedByGc() calls System.gc() and Thread.sleep(50) ten times on the test thread, inside ensureSetup's monitor. System.gc() does not unload the library; the classloader cleaner does that later, and a sandbox that is still referenced never frees its copy. A worker that is out of slots pays for those GCs on every later load and then throws.

Thread.sleep also clears the interrupt flag, and the exception leaves ensureSetup. Restore the interrupt status and return.

The failure text says "More than $maxCopies classloaders". Capacity is the original file plus maxCopies copies (6 with the default). The KDoc already describes it that way.

Copies accumulate with no signal

Copies stay in java.io.tmpdir for the life of that checksum (~60MB each, original plus up to maxCopies per backend version). The README assigns cleanup warnings to the caller, and the PR description mentions a script for Anki-Android, but this diff never logs that a copy was created. One line at creation (index, cap, path, size) gives that caller something to warn on.

The PR description and the first commit message still say 10 files. The code default is 5.

Other

  • nextCopy is a system property updated with no lock. Two classloaders can start at the same slot. The walk still finds a free file, so this stays correct.
  • No tests for slot order, wrap from maxCopies back to 1, "destination already exists", or the exhaustion error. The cursor math does not need a 60MB library.
  • New files use an SPDX header. RustBackendLoader.kt still has the full GPL header.
  • On macOS, dlopen can collapse two files that share an LC_ID_DYLIB. Worth one AnkiDroid run on macOS that enters a second sandbox and calls a backend method. This repo's CI does not do that.

Assisted-by: GPT-6-Astra xhigh
Each Classloader needs a separate on-disk file or a UnsatisfiedLinkError
 is thrown.

To fix this:

* Persist a collection of 10 files on-disk
  * We do this as we cannot guarantee file deletion on Windows

```
librsdroid-<sha1>.dylib      the extracted library
librsdroid-<sha1>-1.dylib    a copy of the library, created if a second classloader requires it
```

* Walk through the libraries using a cursor and load
  an unused one
* Devise a script for Anki-Android to warn developers if
  there are too many stale copies on disk (each is 60MB)

Part of ankidroid/Anki-Android 14796

Assisted-by: Claude Opus 5 - implementation
Assisted-by: GPT-6-Astra xhigh
(cherry picked from commit cde8375 with additional changes)
A copy is freed when the classloader is GCed, and we may run out of
copies if a GC never occurred.

When we run out of copied, GC and wait for the async task to complete.
We wait for 500ms for the JVM to close the libraries of the classloader.

Stop retrying if the cleanup wait is interrupted and restore the interrupt flag.

Assisted-by: Claude Fable 5
Assisted-by: GPT-6-Astra xhigh
@david-allison

Copy link
Copy Markdown
Member Author

Still awaiting me

@david-allison
david-allison marked this pull request as draft October 15, 2026 19:58
Verify that a second JVM preserves the first JVM's published library
without executing the competing writer's creation callback.

Assisted-by: GPT-6-Astra xhigh
Ensures that a library file can be used even if the original owner
has been GCed.

Assisted-by: GPT-6-Astra xhigh
Tests over 3 classloaders, with the third reporting exhaustion of
all libraries.

Assisted-by: GPT-6-Astra xhigh
@david-allison david-allison removed their assignment Oct 17, 2026
@david-allison
david-allison marked this pull request as ready for review October 17, 2026 15:48

@mikehardy mikehardy left a comment

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.

Assisted-by: Grok 4.7

Approving. The earlier notes are addressed: atomic publish under a lock, length checks so a short copy is rewritten, the capacity message counts the original file, the interrupt flag is restored, copy creation is logged, and a second classloader actually calls into the backend. macOS, Windows, and Ubuntu CI are green on f365014.

The GC-and-wait when every copy is still owned is the right tradeoff. Waiting is better than the current failure.

One possible follow-on, not a blocker: the copy-creation line goes to stderr, and the backend has usually already redirected that stream by the time a later classloader creates a copy. David has likely already thought through how Anki-Android will pick this up.

@david-allison

david-allison commented Oct 18, 2026 •

Copy link
Copy Markdown
Member Author

@criticalAY @BrayanDSO

To confirm: can we land this? CI and testing speedup in exchange for up to 6 more copies of the backend library existing per version

I'll add a cleanup script to Anki-Android to handle old versions

@BrayanDSO
BrayanDSO added this pull request to the merge queue Oct 18, 2026
@BrayanDSO BrayanDSO added the pending-merge Waiting on CI or question responses to merge, but otherwise ready label Oct 18, 2026
Merged via the queue into ankidroid:main with commit 6c589c6 Oct 18, 2026
11 checks passed
@david-allison
david-allison deleted the classloader branch October 18, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-merge Waiting on CI or question responses to merge, but otherwise ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants