Skip to content

Propagate request context across JDK executors - #343

Open
Mishenevd wants to merge 1 commit into
feat/context-propagation-primitivesfrom
feat/executor-context-propagation-instrumentation
Open

Propagate request context across JDK executors#343
Mishenevd wants to merge 1 commit into
feat/context-propagation-primitivesfrom
feat/executor-context-propagation-instrumentation

Conversation

@Mishenevd

@Mishenevd Mishenevd commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Second of the stack, on top of #342 — this is where the primitives get used.

We weave the JDK executor types so a task submitted from a request thread runs with that request's context on the pool worker: ThreadPoolExecutor, ForkJoinPool, AbstractExecutorService, ScheduledThreadPoolExecutor and the Executors$Delegated* services. In practice that covers @Async, CompletableFuture, plain thread pools and scheduled tasks — all of which used to lose the context on the hop and silently miss attacks in the async path.

Two loading strategies, on purpose:

  • ThreadPool/ForkJoin/AbstractExecutorService go through a cached reflection bridge (ExecutorContextPropagation).
  • ScheduledThreadPoolExecutor and the delegated executors load agent_api per call via a short-lived URLClassLoader. They run early during class loading, where the cached bridge deadlocks, so the per-call loader is required here — and it's now closed after wrapping.

ExecutorWrapperTest covers this with 9 integration tests under the woven agent: propagation across every executor type plus CompletableFuture, nested submits, two concurrent tasks each keeping their own context, a pooled worker not leaking context into the next task, and the no-context passthrough.

Comment on lines +45 to +48
if (task instanceof Runnable) {
task = ExecutorContextPropagation.wrap((Runnable) task);
} else if (task instanceof Callable) {
task = ExecutorContextPropagation.wrap((Callable) task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium - Bootstrap executor advice calls an agent-only helper that JDK classes cannot resolve

The new AbstractExecutorService, ThreadPoolExecutor, and ForkJoinPool advices inject direct calls to ExecutorContextPropagation.wrap(...) into java.util.concurrent classes even though this JVM setup never injects agent helper classes into the bootstrap classloader. The project already handles bootstrap-loaded JDK wrappers via reflective loading for that reason, and these advices also suppress any linkage error, so submit/execute on core executors silently runs without propagated request context. As a result, async work scheduled onto common JDK executors loses the request metadata that Zen uses to attribute and enforce protections on downstream sinks.

Show fix

Do not reference dev.aikido.agent... helpers directly from advice woven into bootstrap-loaded JDK classes. Either move these executor wrappers to the same reflective bridge pattern already used for bootstrap JDK wrappers, or explicitly append the helper classes to the bootstrap classloader search before instrumenting java.util.concurrent so the injected calls can actually resolve.

More info - Reply on this comment to give feedback or ignore the issue.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment on lines +58 to +68
// The wrapper returned by wrap() resolves through the parent (system) classloader,
// so this per-call loader can be closed once wrapping is done.
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });
try {
Class<?> contextPropagationClass = classLoader.loadClass(
"dev.aikido.agent_api.context.ContextPropagation"
);

if (task instanceof Runnable) {
Method wrapRunnable = contextPropagationClass.getMethod("wrap", Runnable.class);
task = wrapRunnable.invoke(null, task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium - Delegated and scheduled executor wrappers reopen agent_api.jar on every task submission

Both bootstrap-safe executor wrappers now allocate a fresh URLClassLoader, load ContextPropagation, reflect the wrap(...) method, and close the loader for every submit/execute or schedule call. These methods are on the hot path for common asynchronous work, so the change adds repeated JAR parsing and reflective lookup overhead to every task dispatch instead of amortizing it once per JVM. Under request-driven executor usage this can materially reduce throughput and increase allocation pressure for the very async workloads this feature targets.

Show fix

Cache the reflected ContextPropagation class and wrap methods across calls instead of constructing a new URLClassLoader per task. If bootstrap isolation prevents direct helper references, initialize the reflective bridge lazily once in bootstrap-safe code and reuse the cached Method/MethodHandle objects for all subsequent submissions and schedules.

More info - Reply on this comment to give feedback or ignore the issue.

@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from 29b12e9 to cba33a0 Compare August 17, 2026 10:54
@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from cba33a0 to 2a53ce0 Compare August 17, 2026 15:58
Comment on lines +29 to +35
public ElementMatcher getMatcher() {
return isMethod()
.and(named("schedule"))
.and(
takesArguments(Runnable.class, long.class, TimeUnit.class)
.or(takesArguments(Callable.class, long.class, TimeUnit.class))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium - Periodic scheduled executor methods are left uninstrumented

ScheduledThreadPoolExecutorWrapper only matches the one-shot schedule(...) overloads, so scheduleAtFixedRate(...) and scheduleWithFixedDelay(...) still enqueue the original runnable without ContextPropagation.wrap(...). Work moved onto those periodic APIs therefore runs with no propagated request context, and Zen's request-scoped detection and blocking logic loses the metadata it needs to attribute and enforce protections on downstream async operations. Applications that fan request work out through recurring scheduled tasks can silently bypass the new executor propagation coverage.

Show fix
Suggested change
public ElementMatcher getMatcher() {
return isMethod()
.and(named("schedule"))
.and(
takesArguments(Runnable.class, long.class, TimeUnit.class)
.or(takesArguments(Callable.class, long.class, TimeUnit.class))
);
return isMethod()
.and(
named("schedule").and(
takesArguments(Runnable.class, long.class, TimeUnit.class)
.or(takesArguments(Callable.class, long.class, TimeUnit.class))
)
.or(
named("scheduleAtFixedRate")
.and(takesArguments(Runnable.class, long.class, long.class, TimeUnit.class))
)
.or(
named("scheduleWithFixedDelay")
.and(takesArguments(Runnable.class, long.class, long.class, TimeUnit.class))
)
);

More info - Reply on this comment to give feedback or ignore the issue.

@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from 2a53ce0 to 76ca743 Compare August 17, 2026 16:33

// The wrapper returned by wrap() resolves through the parent (system) classloader,
// so this per-call loader can be closed once wrapping is done.
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Creating and closing a URLClassLoader for every delegated executor submission adds substantial repeated class-loading overhead on a high-frequency task-submission path.

Details

✨ AI Reasoning
​Task submission can occur at high throughput. Each invocation constructs a class loader, loads the context propagation class, reflects its wrapping method, and closes the loader. This repeated resource creation is directly introduced by the advice and is avoidable through cached initialization.

🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info


// The wrapper returned by wrap() resolves through the parent (system) classloader,
// so this per-call loader can be closed once wrapping is done.
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Creating and closing a URLClassLoader for every scheduled task adds substantial repeated class-loading overhead on a high-frequency scheduling path.

Details

✨ AI Reasoning
​Scheduling operations may be frequent in applications using periodic or delayed tasks. Every call performs class-loader construction, class loading, reflective method lookup, and cleanup, even when the same propagation class and methods are repeatedly needed. This is newly introduced in the advice.

🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from 76ca743 to bda9fd1 Compare August 17, 2026 16:40

// The wrapper returned by wrap() resolves through the parent (system) classloader,
// so this per-call loader can be closed once wrapping is done.
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The explicit try/finally around classLoader.close() is unnecessarily verbose for a scoped resource. Use try-with-resources to express the same lifecycle more directly.

Details

✨ AI Reasoning
​The loader is created, used within one block, and always closed afterward. Java's try-with-resources construct expresses this lifecycle directly while preserving exception behavior and cleanup.

🔧 How do I fix it?
Rewrite the snippet in the simpler, behavior-equivalent form: return a boolean expression directly instead of if cond return true else return false, avoid using lists when they are guaranteed to contain one element, etc.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info


// The wrapper returned by wrap() resolves through the parent (system) classloader,
// so this per-call loader can be closed once wrapping is done.
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The explicit try/finally around classLoader.close() is unnecessarily verbose for a scoped resource. Use try-with-resources to express the same lifecycle more directly.

Details

✨ AI Reasoning
​The loader is created, used within one block, and always closed afterward. Java's try-with-resources construct expresses this lifecycle directly while preserving exception behavior and cleanup.

🔧 How do I fix it?
Rewrite the snippet in the simpler, behavior-equivalent form: return a boolean expression directly instead of if cond return true else return false, avoid using lists when they are guaranteed to contain one element, etc.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from bda9fd1 to a2a3d32 Compare August 17, 2026 23:49
@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from a2a3d32 to 724dd78 Compare August 17, 2026 23:54
Weave ThreadPoolExecutor, ForkJoinPool, AbstractExecutorService, ScheduledThreadPoolExecutor and the Executors$Delegated* services so a task submitted from a request thread runs under that request's Context on the pool worker. Helper-backed wrappers share a cached reflection bridge (ExecutorContextPropagation); the Scheduled/Delegated wrappers load agent_api per call through a URLClassLoader that is closed once wrapping is done. Integration tests cover preservation, nested submits, concurrent isolation and pooled-worker reuse.
@Mishenevd
Mishenevd force-pushed the feat/executor-context-propagation-instrumentation branch from 724dd78 to 3ed5925 Compare August 18, 2026 10:22
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.

1 participant