Skip to content

Avoid ClassLoader leak from static default async executor (#3178)#3394

Merged
velo merged 2 commits into
OpenFeign:masterfrom
seonwooj0810:fix/issue-3178-async-executor-leak
Jun 11, 2026
Merged

Avoid ClassLoader leak from static default async executor (#3178)#3394
velo merged 2 commits into
OpenFeign:masterfrom
seonwooj0810:fix/issue-3178-async-executor-leak

Conversation

@seonwooj0810

Copy link
Copy Markdown
Contributor

Fixes #3178

Problem

AsyncFeign held its default ExecutorService in a private static singleton (LazyInitializedExecutorService) that is never shut down:

private static class LazyInitializedExecutorService {
  private static final ExecutorService instance =
      Executors.newCachedThreadPool(r -> { Thread t = new Thread(r); t.setDaemon(true); return t; });
}

Inside a servlet container, marking the threads as daemon only lets the JVM exit — it does not help on redeploy. The static cached thread pool retains a strong reference to the initial application's ContextClassLoader, so each redeployment leaks the previous application's ClassLoader and its threads, eventually leading to a Metaspace OutOfMemoryError.

Change

  • Replace the static singleton with an instance-scoped default executor created per built client (defaultExecutorService()). Once the built client is discarded, the executor and its (daemon, idle-timing-out) threads become eligible for garbage collection, releasing the ClassLoader.
  • Add AsyncBuilder.executorService(ExecutorService) so callers can supply and own a managed, shut-downable executor — the recommended way to control this lifecycle explicitly. It is ignored when a custom client(AsyncClient) is provided.

Default behavior is preserved for callers who don't configure anything: a cached daemon thread pool is still used; it is simply no longer a process-wide static singleton.

Note: feign.kotlin.CoroutineFeign contains the same static-singleton pattern. I kept this PR scoped to AsyncFeign (the reported class); happy to follow up on the Kotlin module if you'd like it addressed here too.

Tests

Added to core/src/test/java/feign/AsyncFeignTest.java:

  • usesProvidedExecutorService — builds an AsyncFeign with a spied ExecutorService, performs a request, and verifies the provided executor actually ran the work (submit(...) invoked).
  • defaultExecutorServiceStillExecutesRequests — guards that the default (no executor configured) path still works end-to-end after removing the static singleton.

Test evidence

./mvnw -pl core -am test -Dtest=AsyncFeignTestTests run: 54, Failures: 0, Errors: 0, Skipped: 0. Code format validated with git-code-format:validate-code-format (BUILD SUCCESS).

Verification done: confirmed no in-flight PR referencing #3178; reproduced the static-singleton pattern still present on master (AsyncFeign.java); maintainer invited a PR with automated tests on the issue; fix touches only .java files; build + targeted tests + format check pass locally on JDK 25.

AsyncFeign held its default ExecutorService in a static singleton
(LazyInitializedExecutorService) that was never shut down. Inside a
servlet container, the daemon thread pool retained a strong reference to
the initial application's ContextClassLoader, so each redeployment leaked
the previous application's ClassLoader and threads, eventually causing a
Metaspace OutOfMemoryError.

Replace the static singleton with an instance-scoped default executor
created per built client, so it (and its threads) become eligible for GC
once the client is discarded. Also add AsyncBuilder.executorService(...)
so callers can supply and own a managed, shut-downable executor.

Fixes OpenFeign#3178

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com>

@velo velo 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.

Thanks for the fix and the executor coverage. I cannot approve this while the required CircleCI pr-build check is failing.\n\nGate results:\n1. Test coverage: PASS.\n2. Backwards compatibility: PASS.\n3. Security: PASS.\n\nBlocking issue:\n- Required check ci/circleci: pr-build is currently failing for this PR. Please get the build green before this can be approved or merged.

@seonwooj0810

Copy link
Copy Markdown
Contributor Author

I tracked the failing ci/circleci: pr-build down to feign.BaseBuilderTest.checkEnrichTouchesAllAsyncBuilderFields. The PR added the AsyncFeign.AsyncBuilder.executorService field, but the test still expected 12 enrichable fields, while reflection now correctly sees 13.

I pushed ea6c1db to update the expected field count. CI has been retriggered on the new head.

@seonwooj0810 seonwooj0810 force-pushed the fix/issue-3178-async-executor-leak branch from ea6c1db to 268d8e9 Compare June 10, 2026 08:27
@seonwooj0810

Copy link
Copy Markdown
Contributor Author

Follow-up: the first push only exposed the next assertion in the same test. The final fix is 268d8e941055393fbefed2aecc98a83f7fd91ee0: executorService is now excluded from BaseBuilder#getFieldsToEnrich() because it is a caller-owned lifecycle resource and Capability does not enrich ExecutorService. The expected async builder field count remains 12.

@velo velo 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.

Thanks for updating the branch. The per-client default executor and caller-managed executor option are covered by regression tests, preserve existing API behavior, and introduce no security concern. Required checks are green.

@velo velo merged commit e6e3a7b into OpenFeign:master Jun 11, 2026
3 checks passed
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.

[Bug] Static ExecutorService in AsyncFeign causes ClassLoader Leak (Metaspace OOM)

2 participants