Skip to content

fix: ForkJoinTask captured context is retained until GC (#626) - #627

Open
DZQOX wants to merge 1 commit into
mainfrom
fix/forkjointask-captured-cache-retention
Open

fix: ForkJoinTask captured context is retained until GC (#626)#627
DZQOX wants to merge 1 commit into
mainfrom
fix/forkjointask-captured-cache-retention

Conversation

@DZQOX

@DZQOX DZQOX commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #626

Summary

The constructor advice captures a context snapshot for every ForkJoinTask, but the exec/run exit restores the context and never removes the entry. Eviction therefore depends on the task being collected, the weak key being enqueued, and some later put/get happening to drain the queue.

What's wrong

This is not a leak in the strict sense — the snapshot holds no reference back to the task, so the weak key is still collectible. The effect is the same, though. The snapshot is the map value, so it stays strongly reachable from the static cache: it survives every young GC and is promoted to the old generation. And once traffic stops nothing calls check() again, so the entries simply stay.

This is worst on JDK 21 virtual threads. VirtualThread.runContinuation is a plain Runnable, so ForkJoinPool.execute(Runnable) allocates a new RunnableExecuteAction on every submit — one entry per park/unpark, each one also paying a full Transmitter.capture() (a fresh HashMap plus deep-copied CallDepth values).

The fix

Drop the entry at the exec/run exit. That is the task's terminal execution. Completion.run() and exec() are final on the base class and are both tryFire(ASYNC); the ASYNC branch skips claim(), runs the function, then nulls src/dep/fn, so any later tryFire returns at the entry null check. Same shape on JDK 8 and 21. For plain ForkJoinTasks, doExec() calls exec() once.

Guard the removal on backup != null. That holds exactly when captured was non-null on entry, because replay(null) returns null. Otherwise nothing was being recorded, the constructor advice is gated on TraceContextManager and never put an entry, and exec is one of the hottest methods in the JVM.

Add onThrowable to the exit advice. Throwing out of exec is a normal path, not a corner case: RunnableExecuteAction.exec() is a bare runnable.run(), AdaptedCallable.exec() rethrows, and AdaptedXxx.run() is invoke(), which reports by rethrowing — so both the run and exec exits are skipped. Without it the replayed context is never restored and the worker picks up its next task still carrying the previous trace, recording downstream calls under a foreign traceId. That is a pre-existing defect, and the new removal would be skipped on the same paths.

CAPTURED_CACHE is declared as WeakCache so the advice can call remove. Cache itself does not gain a remove method: TrieCache is a prefix tree with no matching semantics, and CAPTURED_CACHE is the only caller.

Not covered

Called out in the class javadoc so it is not mistaken for a complete fix. Only tasks that reach exec/run get their entry dropped:

  • Completions built by the non-async operators (thenApply, thenCompose, ...) have a null executor and are only driven through tryFire(SYNC) / tryFire(NESTED).
  • CompletableFuture$Signaller is captured but never submitted to the pool.
  • Tasks constructed but never executed (cancelled, or cleared by cleanStack()).

Those still wait for the weak key to be collected. There is no safe removal signal inside tryFire: a null return means both "spun without firing" and "fired with no dependent to propagate", and Completion.isLive() is package-private, so inlined advice cannot reach it. Binding the snapshot to a field on the task itself would remove the map altogether and cover these too, at the cost of adding a field-injection mechanism.

Behaviour change

After ForkJoinTask.reinitialize() the same instance can be forked again, and the second execution no longer replays. It previously replayed the snapshot taken at construction time, which was already the wrong context for a fresh run. The JDK never calls reinitialize() itself.

Verification

arex-agent-bootstrap 138/138, arex-executors 23/23.

Mutation checked — each guard has a test that fails without it:

Mutation Failing test
Drop the backup != null guard execAdviceSkipsRemoveWhenNothingWasReplayed
Delete the remove call execAdviceRemovesCapturedEntryOnExit
Drop onThrowable exitAdviceAlsoRunsOnTheExceptionPath

The constructor advice captures a context snapshot for every ForkJoinTask, but
the exec/run exit only restores and never removes, so eviction depends entirely
on the task being collected, the weak key being enqueued, and some later put/get
happening to drain the queue.

That is not a leak in the strict sense -- the snapshot does not reference the
task back, so the weak key is still collectible. It behaves like one, though:
the snapshot is the map value and stays strongly reachable from the static
cache, so it survives every young GC and is promoted to the old generation, and
entries linger once traffic stops because nothing calls check() any more.

Worst on JDK 21 virtual threads. VirtualThread.runContinuation is a plain
Runnable, so ForkJoinPool.execute(Runnable) allocates a new RunnableExecuteAction
on every submit -- one entry per park/unpark, and a full Transmitter.capture()
(a fresh HashMap plus deep-copied CallDepth values) on each one.

Changes:

- Drop the entry at the exec/run exit. This is that task's terminal execution:
  Completion.run()/exec() are final on the base class and are both
  tryFire(ASYNC), and the ASYNC branch skips claim(), runs the function and then
  nulls src/dep/fn, so any later tryFire returns at the entry null check. Same
  on JDK 8 and 21.
- Guard the removal on backup != null, which holds exactly when captured was
  non-null on entry because replay(null) returns null. Nothing was recorded
  otherwise, the constructor advice is gated on TraceContextManager and never
  put an entry, and exec is one of the hottest methods in the JVM.
- Add onThrowable to the exit advice. Throwing out of exec is a normal path:
  RunnableExecuteAction.exec() is a bare runnable.run(), AdaptedCallable.exec()
  rethrows, and AdaptedXxx.run() is invoke(), which reports by rethrowing, so
  both the run and exec exits are skipped. Without it the replayed context is
  never restored and the worker picks up its next task still carrying the
  previous trace, recording downstream calls under a foreign traceId. That is a
  pre-existing defect; the new removal would be skipped on the same paths.
- CAPTURED_CACHE is declared as WeakCache so the advice can call remove.
  Cache itself does not get a remove method: TrieCache is a prefix tree and has
  no matching semantics, and CAPTURED_CACHE is the only caller.

Not covered, and called out in the class javadoc: only tasks that reach exec/run
get their entry dropped. Completions built by the non-async operators have a null
executor and are only driven through tryFire(SYNC)/tryFire(NESTED), and
CompletableFuture$Signaller is captured but never submitted to the pool; both
still wait for the weak key. There is no safe removal signal inside tryFire --
a null return means both "spun without firing" and "fired with no dependent to
propagate", and Completion.isLive() is package-private so inlined advice cannot
reach it. Binding the snapshot to a field on the task would remove the map
altogether and cover those too, at the cost of a field-injection mechanism.

Behaviour change: after reinitialize() the same instance can be forked again and
the second execution no longer replays. It previously replayed the snapshot taken
at construction, which was already the wrong context. The JDK never calls
reinitialize() itself.

Verified: arex-agent-bootstrap 138/138, arex-executors 23/23. Mutation checked --
dropping the backup guard fails execAdviceSkipsRemoveWhenNothingWasReplayed,
deleting the remove fails execAdviceRemovesCapturedEntryOnExit, and dropping
onThrowable fails exitAdviceAlsoRunsOnTheExceptionPath.
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant